FRE-587 Phase 5: Add offline persistence + UI components
Phase 5 Polish & Optimization - Part 1: Offline Persistence: - Create IDBPersistence class for IndexedDB storage - Auto-save with configurable intervals (default 5s) - Offline mode with update queuing - Automatic flush when back online UI Components: - ChangeHighlighting component - visual change indicators - Color-coded by user - Auto-fade after 30s - Toggle visibility - VersionHistoryPanel component - snapshot management - Chronological snapshot list - Relative timestamps - One-click restore - Manual snapshot creation Documentation: - analysis/fre587_phase5_polish_implementation.md Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
194
src/components/collaboration/change-highlighting.tsx
Normal file
194
src/components/collaboration/change-highlighting.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Change Highlighting Component
|
||||
* Displays visual indicators for recent changes in the editor
|
||||
*/
|
||||
|
||||
import { Component, createEffect, createSignal, onMount, For } from 'solid-js';
|
||||
import { Doc, Text } from 'yjs';
|
||||
import { ChangeTracker, DocumentChange } from '../../lib/collaboration/change-tracker';
|
||||
|
||||
export interface ChangeHighlight {
|
||||
id: string;
|
||||
position: number;
|
||||
length: number;
|
||||
type: 'insert' | 'delete' | 'format' | 'move';
|
||||
userId: string;
|
||||
userName: string;
|
||||
timestamp: Date;
|
||||
color: string;
|
||||
accepted: boolean;
|
||||
}
|
||||
|
||||
export interface ChangeHighlightingProps {
|
||||
doc: Doc;
|
||||
changeTracker: ChangeTracker;
|
||||
userId: string;
|
||||
showAccepted?: boolean;
|
||||
showRejected?: boolean;
|
||||
highlightDurationMs?: number;
|
||||
}
|
||||
|
||||
export const ChangeHighlighting: Component<ChangeHighlightingProps> = (props) => {
|
||||
const [highlights, setHighlights] = createSignal<ChangeHighlight[]>([]);
|
||||
const [showHighlights, setShowHighlights] = createSignal(true);
|
||||
|
||||
const showAccepted = props.showAccepted ?? true;
|
||||
const showRejected = props.showRejected ?? false;
|
||||
const highlightDurationMs = props.highlightDurationMs ?? 30000; // 30 seconds default
|
||||
|
||||
// Color map for users
|
||||
const userColors = new Map<string, string>();
|
||||
const colors = [
|
||||
'rgba(239, 68, 68, 0.3)', // red
|
||||
'rgba(59, 130, 246, 0.3)', // blue
|
||||
'rgba(34, 197, 94, 0.3)', // green
|
||||
'rgba(234, 179, 8, 0.3)', // yellow
|
||||
'rgba(168, 85, 247, 0.3)', // purple
|
||||
'rgba(236, 72, 153, 0.3)', // pink
|
||||
];
|
||||
|
||||
const getUserColor = (userId: string): string => {
|
||||
if (!userColors.has(userId)) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < userId.length; i++) {
|
||||
hash = userId.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
userColors.set(userId, colors[Math.abs(hash) % colors.length]!);
|
||||
}
|
||||
return userColors.get(userId)!;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
// Subscribe to change events
|
||||
props.changeTracker.onChange((change) => {
|
||||
const highlight: ChangeHighlight = {
|
||||
id: change.id,
|
||||
position: change.position,
|
||||
length: change.length,
|
||||
type: change.type,
|
||||
userId: change.userId,
|
||||
userName: change.userName,
|
||||
timestamp: change.timestamp,
|
||||
color: getUserColor(change.userId),
|
||||
accepted: change.accepted,
|
||||
};
|
||||
|
||||
setHighlights((prev) => [...prev, highlight]);
|
||||
|
||||
// Auto-remove highlight after duration
|
||||
setTimeout(() => {
|
||||
setHighlights((prev) => prev.filter((h) => h.id !== change.id));
|
||||
}, highlightDurationMs);
|
||||
});
|
||||
});
|
||||
|
||||
// Filter highlights based on acceptance status
|
||||
const filteredHighlights = () => {
|
||||
return highlights().filter((h) => {
|
||||
if (h.accepted && !showAccepted) return false;
|
||||
if (!h.accepted && !showRejected) return false;
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
// Get highlight style for a position
|
||||
const getHighlightStyle = (position: number): string => {
|
||||
const highlight = filteredHighlights().find(
|
||||
(h) => position >= h.position && position < h.position + h.length
|
||||
);
|
||||
|
||||
if (highlight) {
|
||||
return `background-color: ${highlight.color};`;
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
// Toggle highlight visibility
|
||||
const toggleHighlights = () => {
|
||||
setShowHighlights(!showHighlights());
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="change-highlighting">
|
||||
<div class="change-highlight-controls">
|
||||
<button onClick={toggleHighlights} class="toggle-highlights-btn">
|
||||
{showHighlights() ? 'Hide Changes' : 'Show Changes'}
|
||||
</button>
|
||||
<span class="change-count">
|
||||
{filteredHighlights().length} active changes
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showHighlights() && (
|
||||
<div class="change-highlight-overlay">
|
||||
<For each={filteredHighlights()}>
|
||||
{(highlight) => (
|
||||
<div
|
||||
class="change-highlight-marker"
|
||||
style={{
|
||||
left: `${highlight.position}px`,
|
||||
width: `${highlight.length * 8.4}px`, // Approximate char width
|
||||
'background-color': highlight.color,
|
||||
}}
|
||||
title={`${highlight.userName} - ${highlight.type} at ${highlight.timestamp.toLocaleTimeString()}`}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.change-highlighting {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.change-highlight-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px;
|
||||
background: #f5f5f5;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.toggle-highlights-btn {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.toggle-highlights-btn:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.change-count {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.change-highlight-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.change-highlight-marker {
|
||||
position: absolute;
|
||||
height: 20px;
|
||||
border-radius: 2px;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangeHighlighting;
|
||||
Reference in New Issue
Block a user