From 1dee825d17ed0da4aeda254530611d261de21a3e Mon Sep 17 00:00:00 2001 From: Laurent Haan Date: Wed, 13 May 2026 16:57:57 +0200 Subject: [PATCH 01/16] Add freehand annotations feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional annotations layer (toggled via Settings → Enable annotations) that renders on top of the UML diagram and moves/zooms with it. Annotations are view-specific (conceptual/logical/physical) and saved in the .mdlz file. Tools: pen (color, thickness), marker (color, thickness, opacity), text label (color, font size), eraser (size, whole/partial mode). Each drawing tool has a color preset row with 8 swatches and a custom color picker. The toolbox uses Bootstrap Icons. Undo/redo is unified with the existing Edit menu history: every annotation mutation pushes a combined snapshot of nodes, edges, model name and annotations, so Ctrl+Z undoes both diagram and annotation changes in a single stack. --- src/App.jsx | 97 ++++- .../annotations/AnnotationLayer.jsx | 183 +++++++++ .../annotations/AnnotationToolbox.jsx | 360 +++++++++++++++++ src/components/layout/Navbar.jsx | 10 + src/hooks/useAnnotations.js | 370 ++++++++++++++++++ src/hooks/useFileActions.js | 21 +- src/hooks/useModelState.js | 25 +- 7 files changed, 1059 insertions(+), 7 deletions(-) create mode 100644 src/components/annotations/AnnotationLayer.jsx create mode 100644 src/components/annotations/AnnotationToolbox.jsx create mode 100644 src/hooks/useAnnotations.js diff --git a/src/App.jsx b/src/App.jsx index aa72705..a8ce2f4 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import ReactFlow, { Background, ConnectionMode, @@ -19,6 +19,9 @@ import { useFileActions } from './hooks/useFileActions.js' import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts.js' import { useModelState } from './hooks/useModelState.js' import DefaultValuesPanel from './components/flow/overlays/DefaultValuesPanel.jsx' +import AnnotationLayer from './components/annotations/AnnotationLayer.jsx' +import AnnotationToolbox from './components/annotations/AnnotationToolbox.jsx' +import { useAnnotations } from './hooks/useAnnotations.js' import { sanitizeFileName } from './model/fileUtils.js' import { ASSOCIATION_EDGE_TYPE, @@ -48,6 +51,8 @@ const STORAGE_KEYS = { showCompositionAggregation: 'modelizer.showCompositionAggregation', showNotes: 'modelizer.showNotes', showAreas: 'modelizer.showAreas', + showAnnotations: 'modelizer.showAnnotations', + annotationToolSettings: 'modelizer.annotationToolSettings', } const readStoredBool = (key, fallback) => { @@ -109,6 +114,13 @@ const writeStoredString = (key, value) => { function App() { const reactFlowWrapper = useRef(null) const [reactFlowInstance, setReactFlowInstance] = useState(null) + // Stable ref-based bridge to break the circular dependency between useModelState + // and useAnnotations: each hook needs something the other produces. + const annotationsSnapshotRef = useRef(null) + const annotationsRestoreRef = useRef(null) + const pushHistorySnapshotRef = useRef(null) + const getAnnotationsSnapshot = useCallback(() => annotationsSnapshotRef.current?.(), []) + const onRestoreAnnotations = useCallback((a) => annotationsRestoreRef.current?.(a), []) const [infoWidth, setInfoWidth] = useState(370) const [showBackground, setShowBackground] = useState(() => readStoredBool(STORAGE_KEYS.showBackground, true), @@ -144,6 +156,9 @@ function App() { const [showAreas, setShowAreas] = useState(() => readStoredBool(STORAGE_KEYS.showAreas, false), ) + const [showAnnotations, setShowAnnotations] = useState(() => + readStoredBool(STORAGE_KEYS.showAnnotations, false), + ) const [activeView, setActiveView] = useState(DEFAULT_VIEW) const [openClassId, setOpenClassId] = useState('') const [autoEditClassId, setAutoEditClassId] = useState('') @@ -291,6 +306,9 @@ function App() { useEffect(() => { writeStoredBool(STORAGE_KEYS.showAreas, showAreas) }, [showAreas]) + useEffect(() => { + writeStoredBool(STORAGE_KEYS.showAnnotations, showAnnotations) + }, [showAnnotations]) const onDuplicateDialogOpenChange = useCallback((open) => { if (!open) { @@ -379,6 +397,7 @@ function App() { onRedo, canUndo, canRedo, + pushHistorySnapshot, onNodeDragStart, onNodeDragStop, } = useModelState({ @@ -390,6 +409,41 @@ function App() { nullDisplayMode, onDuplicateEdge, activeView, + getAnnotationsSnapshot, + onRestoreAnnotations, + }) + + const { + annotations, + activeTool: activeAnnotationTool, + penSettings, + markerSettings, + textSettings, + eraserSettings, + currentStroke, + pendingText, + dirtySignal: annotationsDirtySignal, + getAnnotationsSnapshot: getAnnotationsSnapshotFn, + setTool: onSetAnnotationTool, + updatePenSettings: onPenSettingsChange, + updateMarkerSettings: onMarkerSettingsChange, + updateTextSettings: onTextSettingsChange, + updateEraserSettings: onEraserSettingsChange, + onPointerDown: onAnnotationPointerDown, + onPointerMove: onAnnotationPointerMove, + onPointerUp: onAnnotationPointerUp, + onCommitText: onAnnotationCommitText, + onUpdateText: onAnnotationUpdateText, + onClearAnnotations, + onLoadAnnotations, + } = useAnnotations({ activeView, reactFlowInstance, pushHistorySnapshot }) + + // Wire stable ref callbacks after every render so useModelState can read/restore + // annotation state without a hard circular dependency between the two hooks. + useLayoutEffect(() => { + annotationsSnapshotRef.current = getAnnotationsSnapshotFn + annotationsRestoreRef.current = onLoadAnnotations + pushHistorySnapshotRef.current = pushHistorySnapshot }) const onAddClass = useCallback(() => { @@ -499,6 +553,10 @@ function App() { }) }, [activeSidebarItem, setActiveSidebarItem]) + const onToggleAnnotations = useCallback(() => { + setShowAnnotations((current) => !current) + }, []) + const requestDelete = useCallback( ({ kind, action }) => { if (!confirmDelete) { @@ -618,6 +676,8 @@ function App() { } = useFileActions({ nodes, edges, + annotations, + annotationsDirtySignal, modelName, setModel, setNodes, @@ -630,9 +690,11 @@ function App() { showCompositionAggregation, onHiddenContent, onImportWarning, + onLoadAnnotations, onNewModelCreated: () => { setActiveView(VIEW_CONCEPTUAL) resetViewport() + onLoadAnnotations(null) }, onModelLoaded: () => { setActiveView(VIEW_CONCEPTUAL) @@ -847,6 +909,8 @@ function App() { } onToggleNotes={onToggleNotes} onToggleAreas={onToggleAreas} + showAnnotations={showAnnotations} + onToggleAnnotations={onToggleAnnotations} viewSpecificSettingsOnly={viewSpecificSettingsOnly} onToggleViewSpecificSettingsOnly={() => setViewSpecificSettingsOnly((current) => !current) @@ -968,6 +1032,37 @@ function App() { {showBackground ? : null} + {showAnnotations ? ( + <> + +
+ onClearAnnotations(activeView)} + /> +
+ + ) : null} {activeView === VIEW_PHYSICAL ? ( diff --git a/src/components/annotations/AnnotationLayer.jsx b/src/components/annotations/AnnotationLayer.jsx new file mode 100644 index 0000000..469ea3a --- /dev/null +++ b/src/components/annotations/AnnotationLayer.jsx @@ -0,0 +1,183 @@ +import { useRef } from 'react' +import { useViewport } from 'reactflow' + +function pointsToPath(points) { + if (!points || points.length === 0) return '' + if (points.length === 1) { + const { x, y } = points[0] + return `M ${x} ${y} L ${x} ${y}` + } + if (points.length === 2) { + return `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y}` + } + // Catmull-Rom to cubic bezier + let d = `M ${points[0].x} ${points[0].y}` + for (let i = 0; i < points.length - 1; i++) { + const p0 = points[Math.max(0, i - 1)] + const p1 = points[i] + const p2 = points[i + 1] + const p3 = points[Math.min(points.length - 1, i + 2)] + const cp1x = p1.x + (p2.x - p0.x) / 6 + const cp1y = p1.y + (p2.y - p0.y) / 6 + const cp2x = p2.x - (p3.x - p1.x) / 6 + const cp2y = p2.y - (p3.y - p1.y) / 6 + d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}` + } + return d +} + +function AnnotationStroke({ stroke }) { + const d = pointsToPath(stroke.points) + if (!d) return null + return ( + + ) +} + +function AnnotationText({ item, onEdit }) { + const handleDoubleClick = (e) => { + e.stopPropagation() + const newText = window.prompt('Edit annotation text:', item.text) + if (newText !== null) { + onEdit(item.id, newText) + } + } + + const scaledFontSize = item.fontSize + return ( + + {item.text} + + ) +} + +function PendingTextInput({ pending, zoom, onCommit }) { + const ref = useRef(null) + + const handleKeyDown = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + onCommit(ref.current?.value ?? '') + } + if (e.key === 'Escape') { + onCommit('') + } + } + + const handleBlur = () => { + onCommit(ref.current?.value ?? '') + } + + const scaledFontSize = pending.fontSize + const width = 200 / zoom + const height = (pending.fontSize + 8) / zoom + + return ( + + + + ) +} + +export default function AnnotationLayer({ + annotations, + activeView, + activeTool, + currentStroke, + pendingText, + onPointerDown, + onPointerMove, + onPointerUp, + onCommitText, + onUpdateText, +}) { + const { x, y, zoom } = useViewport() + const items = annotations?.[activeView]?.items ?? [] + + return ( +
+ + + {items.map((item) => + item.kind === 'stroke' ? ( + + ) : ( + + ), + )} + {currentStroke && } + {pendingText && ( + + )} + + +
+ ) +} diff --git a/src/components/annotations/AnnotationToolbox.jsx b/src/components/annotations/AnnotationToolbox.jsx new file mode 100644 index 0000000..f57e62e --- /dev/null +++ b/src/components/annotations/AnnotationToolbox.jsx @@ -0,0 +1,360 @@ +import { useRef, useState } from 'react' +import { Panel } from 'reactflow' +import * as Tooltip from '@radix-ui/react-tooltip' +import { CirclePicker } from 'react-color' + +const PRESET_COLORS = [ + '#ef4444', + '#f97316', + '#eab308', + '#22c55e', + '#3b82f6', + '#a855f7', + '#000000', + '#ffffff', +] + +const TOOLTIP_CLASS = + 'rounded-md border border-base-content/10 bg-base-100 px-2 py-1 text-xs text-base-content shadow-lg' + +function ToolTip({ label, children, side = 'left' }) { + return ( + + {children} + + + {label} + + + + + ) +} + +function ToolButton({ label, active, disabled, onClick, children }) { + return ( + + + + ) +} + +function ColorPresetRow({ color, onChange }) { + const [pickerOpen, setPickerOpen] = useState(false) + const pickerRef = useRef(null) + + const isCustom = !PRESET_COLORS.includes(color) + + return ( +
+ {PRESET_COLORS.map((c) => ( +
+ )} + + ) +} + +function Slider({ label, min, max, step = 1, value, onChange }) { + return ( +
+
+ {label} + {value} +
+ onChange(Number(e.target.value))} + className="w-full accent-primary" + /> +
+ ) +} + +function SubPanel({ children }) { + return ( +
+ {children} +
+ ) +} + +// Bootstrap Icons +const PointerIcon = () => ( + + + +) + +const PenIcon = () => ( + + + +) + +const MarkerIcon = () => ( + + + +) + +const TextIcon = () => ( + + + +) + +const EraserIcon = () => ( + + + +) + +const TrashIcon = () => ( + + + + +) + +export default function AnnotationToolbox({ + activeTool, + onSetTool, + penSettings, + onPenSettingsChange, + markerSettings, + onMarkerSettingsChange, + textSettings, + onTextSettingsChange, + eraserSettings, + onEraserSettingsChange, + onClearView, +}) { + const [clearArmed, setClearArmed] = useState(false) + const clearTimerRef = useRef(null) + + const handleClear = () => { + if (clearArmed) { + clearTimeout(clearTimerRef.current) + setClearArmed(false) + onClearView() + } else { + setClearArmed(true) + clearTimerRef.current = setTimeout(() => setClearArmed(false), 2000) + } + } + + return ( + + + onSetTool('pointer')} + > + + + +
+ + onSetTool('pen')} + > + + + {activeTool === 'pen' && ( + + onPenSettingsChange({ color: c })} + /> + onPenSettingsChange({ thickness: v })} + /> + + )} + + onSetTool('marker')} + > + + + {activeTool === 'marker' && ( + + onMarkerSettingsChange({ color: c })} + /> + onMarkerSettingsChange({ thickness: v })} + /> + onMarkerSettingsChange({ opacity: v / 100 })} + /> + + )} + + onSetTool('text')} + > + + + {activeTool === 'text' && ( + + onTextSettingsChange({ color: c })} + /> + onTextSettingsChange({ fontSize: v })} + /> + + )} + + onSetTool('eraser')} + > + + + {activeTool === 'eraser' && ( + + onEraserSettingsChange({ size: v })} + /> +
+ + +
+
+ )} + +
+ + + + + + + ) +} diff --git a/src/components/layout/Navbar.jsx b/src/components/layout/Navbar.jsx index 9226b4c..bff253d 100644 --- a/src/components/layout/Navbar.jsx +++ b/src/components/layout/Navbar.jsx @@ -28,6 +28,8 @@ export default function Navbar({ onToggleNotes, showAreas, onToggleAreas, + showAnnotations, + onToggleAnnotations, viewSpecificSettingsOnly, onToggleViewSpecificSettingsOnly, nullDisplayMode, @@ -298,6 +300,14 @@ export default function Navbar({ {showAreas ? 'Disable areas' : 'Enable areas'} + onToggleAnnotations?.()} + > + + {showAnnotations ? 'Disable annotations' : 'Enable annotations'} + + diff --git a/src/hooks/useAnnotations.js b/src/hooks/useAnnotations.js new file mode 100644 index 0000000..a50afc8 --- /dev/null +++ b/src/hooks/useAnnotations.js @@ -0,0 +1,370 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +const STORAGE_KEY_TOOL_SETTINGS = 'modelizer.annotationToolSettings' + +const VIEWS = ['conceptual', 'logical', 'physical'] + +const DEFAULT_TOOL_SETTINGS = { + pen: { color: '#ef4444', thickness: 2 }, + marker: { color: '#facc15', thickness: 12, opacity: 0.35 }, + text: { color: '#1e293b', fontSize: 14 }, + eraser: { size: 20, mode: 'whole' }, + activeTool: 'pointer', +} + +function readToolSettings() { + try { + const raw = window.localStorage.getItem(STORAGE_KEY_TOOL_SETTINGS) + if (!raw) return DEFAULT_TOOL_SETTINGS + const parsed = JSON.parse(raw) + return { + pen: { ...DEFAULT_TOOL_SETTINGS.pen, ...parsed.pen }, + marker: { ...DEFAULT_TOOL_SETTINGS.marker, ...parsed.marker }, + text: { ...DEFAULT_TOOL_SETTINGS.text, ...parsed.text }, + eraser: { ...DEFAULT_TOOL_SETTINGS.eraser, ...parsed.eraser }, + activeTool: parsed.activeTool ?? DEFAULT_TOOL_SETTINGS.activeTool, + } + } catch { + return DEFAULT_TOOL_SETTINGS + } +} + +function writeToolSettings(settings) { + try { + window.localStorage.setItem(STORAGE_KEY_TOOL_SETTINGS, JSON.stringify(settings)) + } catch { + // ignore + } +} + +function makeEmptyAnnotations() { + return { + conceptual: { items: [] }, + logical: { items: [] }, + physical: { items: [] }, + } +} + +function normalizeAnnotations(raw) { + const result = makeEmptyAnnotations() + if (!raw || typeof raw !== 'object') return result + for (const view of VIEWS) { + const viewData = raw[view] + if (!viewData || typeof viewData !== 'object') continue + const items = Array.isArray(viewData.items) ? viewData.items : [] + result[view].items = items.filter( + (item) => + item && + typeof item === 'object' && + typeof item.id === 'string' && + (item.kind === 'stroke' || item.kind === 'text'), + ) + } + return result +} + +function dist(p1, p2) { + const dx = p1.x - p2.x + const dy = p1.y - p2.y + return Math.sqrt(dx * dx + dy * dy) +} + +function strokeIntersectsEraser(stroke, center, radius) { + return stroke.points.some((p) => dist(p, center) <= radius) +} + +function partialEraseStroke(stroke, center, radius) { + const segments = [] + let current = [] + for (const pt of stroke.points) { + if (dist(pt, center) > radius) { + current.push(pt) + } else { + if (current.length >= 2) { + segments.push(current) + } + current = [] + } + } + if (current.length >= 2) segments.push(current) + return segments.map((points) => ({ + ...stroke, + id: crypto.randomUUID(), + points, + })) +} + +export function useAnnotations({ activeView, reactFlowInstance, pushHistorySnapshot }) { + const initialSettings = readToolSettings() + + const [annotations, setAnnotations] = useState(makeEmptyAnnotations) + const [activeTool, setActiveTool] = useState(initialSettings.activeTool) + const [penSettings, setPenSettings] = useState(initialSettings.pen) + const [markerSettings, setMarkerSettings] = useState(initialSettings.marker) + const [textSettings, setTextSettings] = useState(initialSettings.text) + const [eraserSettings, setEraserSettings] = useState(initialSettings.eraser) + const [currentStroke, setCurrentStroke] = useState(null) + const [pendingText, setPendingText] = useState(null) + const [dirtySignal, setDirtySignal] = useState(0) + + const annotationsRef = useRef(annotations) + const isDrawingRef = useRef(false) + + useEffect(() => { + annotationsRef.current = annotations + }, [annotations]) + + const bumpDirty = useCallback(() => setDirtySignal((n) => n + 1), []) + + const getAnnotationsSnapshot = useCallback(() => annotationsRef.current, []) + + const setTool = useCallback((tool) => { + setActiveTool(tool) + const settings = readToolSettings() + writeToolSettings({ ...settings, activeTool: tool }) + setPendingText(null) + setCurrentStroke(null) + isDrawingRef.current = false + }, []) + + const updatePenSettings = useCallback((updates) => { + setPenSettings((cur) => { + const next = { ...cur, ...updates } + const settings = readToolSettings() + writeToolSettings({ ...settings, pen: next }) + return next + }) + }, []) + + const updateMarkerSettings = useCallback((updates) => { + setMarkerSettings((cur) => { + const next = { ...cur, ...updates } + const settings = readToolSettings() + writeToolSettings({ ...settings, marker: next }) + return next + }) + }, []) + + const updateTextSettings = useCallback((updates) => { + setTextSettings((cur) => { + const next = { ...cur, ...updates } + const settings = readToolSettings() + writeToolSettings({ ...settings, text: next }) + return next + }) + }, []) + + const updateEraserSettings = useCallback((updates) => { + setEraserSettings((cur) => { + const next = { ...cur, ...updates } + const settings = readToolSettings() + writeToolSettings({ ...settings, eraser: next }) + return next + }) + }, []) + + const screenToFlow = useCallback( + (clientX, clientY) => { + if (!reactFlowInstance) return { x: clientX, y: clientY } + return reactFlowInstance.screenToFlowPosition({ x: clientX, y: clientY }) + }, + [reactFlowInstance], + ) + + const onEraseAt = useCallback( + (flowPt) => { + const radius = eraserSettings.size / 2 + if (eraserSettings.mode === 'whole') { + setAnnotations((cur) => { + const items = cur[activeView].items.filter((item) => { + if (item.kind !== 'stroke') return true + return !strokeIntersectsEraser(item, flowPt, radius) + }) + if (items.length === cur[activeView].items.length) return cur + bumpDirty() + return { ...cur, [activeView]: { items } } + }) + } else { + setAnnotations((cur) => { + const nextItems = [] + let changed = false + for (const item of cur[activeView].items) { + if (item.kind !== 'stroke') { + nextItems.push(item) + continue + } + if (!strokeIntersectsEraser(item, flowPt, radius)) { + nextItems.push(item) + continue + } + const segments = partialEraseStroke(item, flowPt, radius) + nextItems.push(...segments) + changed = true + } + if (!changed) return cur + bumpDirty() + return { ...cur, [activeView]: { items: nextItems } } + }) + } + }, + [activeView, eraserSettings, bumpDirty], + ) + + const onPointerDown = useCallback( + (e) => { + if (activeTool === 'pointer') return + e.preventDefault() + e.stopPropagation() + const flowPt = screenToFlow(e.clientX, e.clientY) + + if (activeTool === 'pen' || activeTool === 'marker') { + isDrawingRef.current = true + const settings = activeTool === 'pen' ? penSettings : markerSettings + setCurrentStroke({ + id: crypto.randomUUID(), + kind: 'stroke', + tool: activeTool, + points: [flowPt], + color: settings.color, + thickness: settings.thickness, + opacity: activeTool === 'pen' ? 1 : markerSettings.opacity, + }) + } else if (activeTool === 'eraser') { + pushHistorySnapshot?.() + onEraseAt(flowPt) + isDrawingRef.current = true + } else if (activeTool === 'text') { + setPendingText({ x: flowPt.x, y: flowPt.y, text: '', color: textSettings.color, fontSize: textSettings.fontSize }) + } + }, + [activeTool, penSettings, markerSettings, textSettings, screenToFlow, pushHistorySnapshot, onEraseAt], + ) + + const onPointerMove = useCallback( + (e) => { + if (!isDrawingRef.current) return + e.preventDefault() + const flowPt = screenToFlow(e.clientX, e.clientY) + + if (activeTool === 'pen' || activeTool === 'marker') { + setCurrentStroke((cur) => { + if (!cur) return cur + return { ...cur, points: [...cur.points, flowPt] } + }) + } else if (activeTool === 'eraser') { + onEraseAt(flowPt) + } + }, + [activeTool, screenToFlow, onEraseAt], + ) + + const onPointerUp = useCallback( + () => { + if (activeTool === 'pen' || activeTool === 'marker') { + if (isDrawingRef.current && currentStroke) { + pushHistorySnapshot?.() + const stroke = currentStroke + setAnnotations((cur) => ({ + ...cur, + [activeView]: { + items: [...cur[activeView].items, stroke], + }, + })) + bumpDirty() + } + setCurrentStroke(null) + } + isDrawingRef.current = false + }, + [activeTool, activeView, currentStroke, pushHistorySnapshot, bumpDirty], + ) + + const onCommitText = useCallback( + (text) => { + if (!pendingText) return + if (text.trim()) { + pushHistorySnapshot?.() + const item = { + id: crypto.randomUUID(), + kind: 'text', + x: pendingText.x, + y: pendingText.y, + text: text.trim(), + color: pendingText.color, + fontSize: pendingText.fontSize, + } + setAnnotations((cur) => ({ + ...cur, + [activeView]: { items: [...cur[activeView].items, item] }, + })) + bumpDirty() + } + setPendingText(null) + }, + [activeView, pendingText, pushHistorySnapshot, bumpDirty], + ) + + const onUpdateText = useCallback( + (id, text) => { + pushHistorySnapshot?.() + if (!text.trim()) { + setAnnotations((cur) => ({ + ...cur, + [activeView]: { + items: cur[activeView].items.filter((i) => i.id !== id), + }, + })) + } else { + setAnnotations((cur) => ({ + ...cur, + [activeView]: { + items: cur[activeView].items.map((i) => + i.id === id ? { ...i, text: text.trim() } : i, + ), + }, + })) + } + bumpDirty() + }, + [activeView, pushHistorySnapshot, bumpDirty], + ) + + const onClearAnnotations = useCallback( + (view) => { + pushHistorySnapshot?.() + setAnnotations((cur) => ({ ...cur, [view ?? activeView]: { items: [] } })) + bumpDirty() + }, + [activeView, pushHistorySnapshot, bumpDirty], + ) + + const onLoadAnnotations = useCallback((raw) => { + setAnnotations(normalizeAnnotations(raw)) + setDirtySignal(0) + }, []) + + return { + annotations, + activeTool, + penSettings, + markerSettings, + textSettings, + eraserSettings, + currentStroke, + pendingText, + dirtySignal, + getAnnotationsSnapshot, + setTool, + updatePenSettings, + updateMarkerSettings, + updateTextSettings, + updateEraserSettings, + onPointerDown, + onPointerMove, + onPointerUp, + onCommitText, + onUpdateText, + onClearAnnotations, + onLoadAnnotations, + } +} diff --git a/src/hooks/useFileActions.js b/src/hooks/useFileActions.js index 45ceb23..af3e816 100644 --- a/src/hooks/useFileActions.js +++ b/src/hooks/useFileActions.js @@ -119,6 +119,11 @@ export const buildHashPayload = (payload) => ({ edges: Array.isArray(payload?.edges) ? payload.edges.map(normalizeEdgeForPayload) : [], + annotations: payload?.annotations ?? { + conceptual: { items: [] }, + logical: { items: [] }, + physical: { items: [] }, + }, }) export const isSamePosition = (a, b) => @@ -196,6 +201,8 @@ export const hasMeaningfulEdgeChange = (prevEdges, nextEdges) => { export function useFileActions({ nodes, edges, + annotations, + annotationsDirtySignal, modelName, setModel, setNodes, @@ -208,6 +215,7 @@ export function useFileActions({ showCompositionAggregation = false, onHiddenContent, onImportWarning, + onLoadAnnotations, onNewModelCreated, onModelLoaded, }) { @@ -228,6 +236,7 @@ export function useFileActions({ modelName: 'Untitled model', nodes: [], edges: [], + annotations: null, }), null, 2, @@ -241,8 +250,9 @@ export function useFileActions({ modelName: modelName || 'Untitled model', nodes, edges, + annotations, }) - }, [edges, modelName, nodes]) + }, [annotations, edges, modelName, nodes]) const getSerializedModelForDirty = useCallback( () => JSON.stringify(buildModelPayload(), null, 2), @@ -279,6 +289,11 @@ export function useFileActions({ setIsDirty(getSerializedModelForDirty() !== lastSavedRef.current) }, [edges, getSerializedModelForDirty, isDragging, modelName, nodes]) + useEffect(() => { + if (annotationsDirtySignal === 0) return + setIsDirty(getSerializedModelForDirty() !== lastSavedRef.current) + }, [annotationsDirtySignal, getSerializedModelForDirty]) + const applyLoadedModel = useCallback( (payload, handle) => { const nextNodes = (payload?.nodes ?? []).map((node, index) => { @@ -391,11 +406,13 @@ export function useFileActions({ typeof payload?.modelName === 'string' && payload.modelName.trim() ? payload.modelName : 'Untitled model' + const nextAnnotations = payload?.annotations ?? null const nextBasePayload = buildHashPayload({ version: payload?.version ?? MODEL_VERSION, modelName: nextModelName, nodes: nextNodes, edges: nextEdges, + annotations: nextAnnotations, }) if (setModel) { setModel(nextNodes, nextEdges, nextModelName) @@ -404,6 +421,7 @@ export function useFileActions({ setEdges(nextEdges) setModelName(nextModelName, { skipHistory: true }) } + onLoadAnnotations?.(nextAnnotations) setActiveSidebarItem('tables') fileHandleRef.current = handle ?? null lastSavedRef.current = JSON.stringify(nextBasePayload, null, 2) @@ -437,6 +455,7 @@ export function useFileActions({ showNotes, showCompositionAggregation, onHiddenContent, + onLoadAnnotations, onModelLoaded, ], ) diff --git a/src/hooks/useModelState.js b/src/hooks/useModelState.js index 1110883..a390987 100644 --- a/src/hooks/useModelState.js +++ b/src/hooks/useModelState.js @@ -118,6 +118,8 @@ export function useModelState({ nullDisplayMode, onDuplicateEdge, activeView = DEFAULT_VIEW, + getAnnotationsSnapshot, + onRestoreAnnotations, }) { const [nodes, setNodes] = useNodesState(initialNodes) const [edges, setEdges] = useEdgesState(normalizeEdges(initialEdges)) @@ -148,8 +150,14 @@ export function useModelState({ nodes: nodesRef.current, edges: edgesRef.current, modelName: modelNameRef.current, + annotations: getAnnotationsSnapshot?.() ?? null, }), - [], + [getAnnotationsSnapshot], + ) + + const pushHistorySnapshot = useCallback( + () => pushHistory(getHistorySnapshot()), + [pushHistory, getHistorySnapshot], ) const setModelName = useCallback( @@ -229,16 +237,22 @@ export function useModelState({ const onUndo = useCallback(() => { historyUndo( getHistorySnapshot(), - ({ nodes: n, edges: e, modelName: name }) => restoreModel(n, e, name), + ({ nodes: n, edges: e, modelName: name, annotations }) => { + restoreModel(n, e, name) + onRestoreAnnotations?.(annotations) + }, ) - }, [getHistorySnapshot, historyUndo, restoreModel]) + }, [getHistorySnapshot, historyUndo, restoreModel, onRestoreAnnotations]) const onRedo = useCallback(() => { historyRedo( getHistorySnapshot(), - ({ nodes: n, edges: e, modelName: name }) => restoreModel(n, e, name), + ({ nodes: n, edges: e, modelName: name, annotations }) => { + restoreModel(n, e, name) + onRestoreAnnotations?.(annotations) + }, ) - }, [getHistorySnapshot, historyRedo, restoreModel]) + }, [getHistorySnapshot, historyRedo, restoreModel, onRestoreAnnotations]) const normalizedActiveView = activeView === VIEW_LOGICAL || activeView === VIEW_PHYSICAL @@ -2996,6 +3010,7 @@ export function useModelState({ onRedo, canUndo, canRedo, + pushHistorySnapshot, onNodeDragStart, onNodeDragStop, } From 40a634dc3131ae8827044cb26e799212dc86d6ba Mon Sep 17 00:00:00 2001 From: Laurent Haan Date: Wed, 13 May 2026 17:06:00 +0200 Subject: [PATCH 02/16] Show tool-aware cursor on annotation layer The cursor now reflects the active annotation tool and its configured size: a scaled SVG circle for pen, marker (with semi-transparent fill), and eraser; text cursor for the text tool; default arrow for pointer. --- src/App.jsx | 3 +++ .../annotations/AnnotationLayer.jsx | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/App.jsx b/src/App.jsx index a8ce2f4..abdfdc8 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1038,6 +1038,9 @@ function App() { annotations={annotations} activeView={activeView} activeTool={activeAnnotationTool} + penSettings={penSettings} + markerSettings={markerSettings} + eraserSettings={eraserSettings} currentStroke={currentStroke} pendingText={pendingText} onPointerDown={onAnnotationPointerDown} diff --git a/src/components/annotations/AnnotationLayer.jsx b/src/components/annotations/AnnotationLayer.jsx index 469ea3a..5873304 100644 --- a/src/components/annotations/AnnotationLayer.jsx +++ b/src/components/annotations/AnnotationLayer.jsx @@ -1,6 +1,25 @@ import { useRef } from 'react' import { useViewport } from 'reactflow' +function buildCircleCursor(diameter, color, fillOpacity = 0) { + const size = Math.min(128, Math.max(8, Math.round(diameter) + 4)) + const r = (size - 2) / 2 + const cx = size / 2 + const svg = encodeURIComponent( + `` + ) + return `url("data:image/svg+xml,${svg}") ${cx} ${cx}, crosshair` +} + +function getCursorForTool(activeTool, penSettings, markerSettings, eraserSettings, zoom) { + if (activeTool === 'pointer') return 'default' + if (activeTool === 'text') return 'text' + if (activeTool === 'pen') return buildCircleCursor(penSettings.thickness * zoom, penSettings.color) + if (activeTool === 'marker') return buildCircleCursor(markerSettings.thickness * zoom, markerSettings.color, markerSettings.opacity * 0.5) + if (activeTool === 'eraser') return buildCircleCursor(eraserSettings.size * zoom, '#6b7280') + return 'crosshair' +} + function pointsToPath(points) { if (!points || points.length === 0) return '' if (points.length === 1) { @@ -126,6 +145,9 @@ export default function AnnotationLayer({ annotations, activeView, activeTool, + penSettings, + markerSettings, + eraserSettings, currentStroke, pendingText, onPointerDown, @@ -136,6 +158,7 @@ export default function AnnotationLayer({ }) { const { x, y, zoom } = useViewport() const items = annotations?.[activeView]?.items ?? [] + const cursor = getCursorForTool(activeTool, penSettings, markerSettings, eraserSettings, zoom) return (
Date: Wed, 13 May 2026 17:08:37 +0200 Subject: [PATCH 03/16] Improve text annotation editing experience Replace window.prompt with an inline textarea that appears directly on the canvas. Supports multiline text via Shift+Enter, auto-resizes as you type, and commits on Enter or blur. Double-clicking an existing text item opens it for inline editing in place. Rendered text now splits on newlines using tspan elements. --- .../annotations/AnnotationLayer.jsx | 160 ++++++++++++------ 1 file changed, 105 insertions(+), 55 deletions(-) diff --git a/src/components/annotations/AnnotationLayer.jsx b/src/components/annotations/AnnotationLayer.jsx index 5873304..360fdaa 100644 --- a/src/components/annotations/AnnotationLayer.jsx +++ b/src/components/annotations/AnnotationLayer.jsx @@ -1,4 +1,4 @@ -import { useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import { useViewport } from 'reactflow' function buildCircleCursor(diameter, color, fillOpacity = 0) { @@ -62,85 +62,120 @@ function AnnotationStroke({ stroke }) { ) } -function AnnotationText({ item, onEdit }) { - const handleDoubleClick = (e) => { - e.stopPropagation() - const newText = window.prompt('Edit annotation text:', item.text) - if (newText !== null) { - onEdit(item.id, newText) - } - } +// Inline textarea for both new text and editing existing text +function TextEditor({ x, y, color, fontSize, initialValue, zoom, onCommit }) { + const ref = useRef(null) + const committedRef = useRef(false) + const lineHeight = fontSize * 1.3 - const scaledFontSize = item.fontSize - return ( - - {item.text} - - ) -} + useEffect(() => { + const el = ref.current + if (!el) return + el.focus() + el.setSelectionRange(el.value.length, el.value.length) + resizeTextarea(el, fontSize, zoom) + }, []) // eslint-disable-line react-hooks/exhaustive-deps -function PendingTextInput({ pending, zoom, onCommit }) { - const ref = useRef(null) + const commit = (value) => { + if (committedRef.current) return + committedRef.current = true + onCommit(value) + } const handleKeyDown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() - onCommit(ref.current?.value ?? '') + commit(ref.current?.value ?? '') } if (e.key === 'Escape') { - onCommit('') + commit(null) } } - const handleBlur = () => { - onCommit(ref.current?.value ?? '') + const handleInput = (e) => { + resizeTextarea(e.target, fontSize, zoom) } - const scaledFontSize = pending.fontSize - const width = 200 / zoom - const height = (pending.fontSize + 8) / zoom - return ( - - +