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>
195 lines
5.3 KiB
TypeScript
195 lines
5.3 KiB
TypeScript
/**
|
|
* 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;
|