diff --git a/package-lock.json b/package-lock.json
index cebd243..30098c5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "modelizer",
- "version": "0.9.2",
+ "version": "0.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "modelizer",
- "version": "0.9.2",
+ "version": "0.10.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
@@ -18,6 +18,7 @@
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-menubar": "^1.1.6",
+ "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.1.3",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.4",
@@ -1520,6 +1521,61 @@
}
}
},
+ "node_modules/@radix-ui/react-popover": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
+ "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.8",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-popper": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
diff --git a/package.json b/package.json
index 2cace25..316865f 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,7 @@
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-menubar": "^1.1.6",
+ "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.1.3",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.4",
diff --git a/src/App.jsx b/src/App.jsx
index aa72705..21f431a 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,7 @@ const STORAGE_KEYS = {
showCompositionAggregation: 'modelizer.showCompositionAggregation',
showNotes: 'modelizer.showNotes',
showAreas: 'modelizer.showAreas',
+ showAnnotations: 'modelizer.showAnnotations',
}
const readStoredBool = (key, fallback) => {
@@ -109,6 +113,12 @@ 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 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 +154,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 +304,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 +395,7 @@ function App() {
onRedo,
canUndo,
canRedo,
+ pushHistorySnapshot,
onNodeDragStart,
onNodeDragStop,
} = useModelState({
@@ -390,6 +407,50 @@ function App() {
nullDisplayMode,
onDuplicateEdge,
activeView,
+ getAnnotationsSnapshot,
+ onRestoreAnnotations,
+ })
+
+ const {
+ annotations,
+ activeTool: activeAnnotationTool,
+ penSettings,
+ markerSettings,
+ textSettings,
+ eraserSettings,
+ currentStroke,
+ pendingText,
+ selectedTextId,
+ editingTextId,
+ isTemporaryPanMode,
+ dirtySignal: annotationsDirtySignal,
+ getAnnotationsSnapshot: getAnnotationsSnapshotFn,
+ setTool: onSetAnnotationTool,
+ updatePenSettings: onPenSettingsChange,
+ updateMarkerSettings: onMarkerSettingsChange,
+ updateTextSettings: onTextSettingsChange,
+ updateEraserSettings: onEraserSettingsChange,
+ onPointerDown: onAnnotationPointerDown,
+ onPointerMove: onAnnotationPointerMove,
+ onPointerUp: onAnnotationPointerUp,
+ onCommitText: onAnnotationCommitText,
+ onCommitTextEdit: onAnnotationCommitTextEdit,
+ onTextPointerDown: onAnnotationTextPointerDown,
+ onTextDoubleClick: onAnnotationTextDoubleClick,
+ onClearAnnotations,
+ onLoadAnnotations,
+ } = useAnnotations({
+ activeView,
+ reactFlowInstance,
+ pushHistorySnapshot,
+ enabled: showAnnotations,
+ })
+
+ // 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
})
const onAddClass = useCallback(() => {
@@ -499,6 +560,10 @@ function App() {
})
}, [activeSidebarItem, setActiveSidebarItem])
+ const onToggleAnnotations = useCallback(() => {
+ setShowAnnotations((current) => !current)
+ }, [])
+
const requestDelete = useCallback(
({ kind, action }) => {
if (!confirmDelete) {
@@ -618,6 +683,8 @@ function App() {
} = useFileActions({
nodes,
edges,
+ annotations,
+ annotationsDirtySignal,
modelName,
setModel,
setNodes,
@@ -630,9 +697,11 @@ function App() {
showCompositionAggregation,
onHiddenContent,
onImportWarning,
+ onLoadAnnotations,
onNewModelCreated: () => {
setActiveView(VIEW_CONCEPTUAL)
resetViewport()
+ onLoadAnnotations(null)
},
onModelLoaded: () => {
setActiveView(VIEW_CONCEPTUAL)
@@ -847,6 +916,8 @@ function App() {
}
onToggleNotes={onToggleNotes}
onToggleAreas={onToggleAreas}
+ showAnnotations={showAnnotations}
+ onToggleAnnotations={onToggleAnnotations}
viewSpecificSettingsOnly={viewSpecificSettingsOnly}
onToggleViewSpecificSettingsOnly={() =>
setViewSpecificSettingsOnly((current) => !current)
@@ -968,6 +1039,45 @@ 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..9760792
--- /dev/null
+++ b/src/components/annotations/AnnotationLayer.jsx
@@ -0,0 +1,459 @@
+import { useEffect, 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, isTemporaryPanMode) {
+ if (isTemporaryPanMode) return 'grab'
+ 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 forwardWheelToReactFlow(e) {
+ const overlay = e.currentTarget
+ const sourceEvent = e.nativeEvent
+ if (!overlay || sourceEvent.__annotationWheelForwarded) {
+ return
+ }
+
+ // Temporarily opting the overlay out of hit-testing lets the browser resolve
+ // the React Flow element underneath. The style is restored before this handler
+ // returns, so the mutation is scoped to one synchronous event dispatch.
+ const previousPointerEvents = overlay.style.pointerEvents
+ overlay.style.pointerEvents = 'none'
+ const target = document.elementFromPoint(sourceEvent.clientX, sourceEvent.clientY)
+ overlay.style.pointerEvents = previousPointerEvents
+
+ if (!target || overlay.contains(target)) {
+ return
+ }
+
+ e.preventDefault()
+ e.stopPropagation()
+
+ const forwardedEvent = new WheelEvent('wheel', {
+ bubbles: true,
+ cancelable: true,
+ composed: true,
+ deltaX: sourceEvent.deltaX,
+ deltaY: sourceEvent.deltaY,
+ deltaZ: sourceEvent.deltaZ,
+ deltaMode: sourceEvent.deltaMode,
+ clientX: sourceEvent.clientX,
+ clientY: sourceEvent.clientY,
+ screenX: sourceEvent.screenX,
+ screenY: sourceEvent.screenY,
+ ctrlKey: sourceEvent.ctrlKey,
+ shiftKey: sourceEvent.shiftKey,
+ altKey: sourceEvent.altKey,
+ metaKey: sourceEvent.metaKey,
+ })
+
+ Object.defineProperty(forwardedEvent, '__annotationWheelForwarded', {
+ value: true,
+ })
+ target.dispatchEvent(forwardedEvent)
+}
+
+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 (
+
+ )
+}
+
+const LINE_HEIGHT_RATIO = 1.3
+
+// Inline textarea for both new text placement and editing existing text.
+// y is the text baseline in flow coords. The foreignObject is shifted up so the
+// textarea's rendered baseline aligns with y, matching SVG dominantBaseline="alphabetic".
+function TextEditor({ x, y, color, fontSize, initialValue, zoom, onCommit, textareaRef }) {
+ const ref = useRef(null)
+ const committedRef = useRef(false)
+
+ useEffect(() => {
+ const el = ref.current
+ if (!el) return
+ if (textareaRef) textareaRef.current = el
+ el.focus()
+ el.setSelectionRange(el.value.length, el.value.length)
+ resizeTextarea(el, fontSize, zoom)
+ return () => { if (textareaRef) textareaRef.current = null }
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
+
+ const commit = (value) => {
+ if (committedRef.current) return
+ committedRef.current = true
+ onCommit(value)
+ }
+
+ const handleKeyDown = (e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault()
+ commit(ref.current?.value ?? '')
+ }
+ if (e.key === 'Escape') {
+ commit(null)
+ }
+ }
+
+ const stopEditorEventPropagation = (e) => {
+ e.stopPropagation()
+ }
+
+ const lineHeight = fontSize * LINE_HEIGHT_RATIO
+ const topOffset = fontSize * 0.85
+
+ return (
+
+
+ )
+}
+
+function resizeTextarea(el, fontSize, zoom) {
+ el.style.width = `${80 / zoom}px`
+ el.style.height = 'auto'
+ el.style.width = `${Math.max(el.scrollWidth, 80 / zoom)}px`
+ el.style.height = `${el.scrollHeight}px`
+}
+
+function getTextBounds(item) {
+ const lines = item.text.split('\n')
+ const lineHeight = item.fontSize * LINE_HEIGHT_RATIO
+ const longestLine = lines.reduce((max, line) => Math.max(max, line.length), 0)
+ // Text hit targets are an SVG-side approximation so selection works without
+ // measuring rendered DOM on every annotation render.
+ const width = Math.max(item.fontSize * 2, longestLine * item.fontSize * 0.62)
+ return {
+ x: item.x,
+ y: item.y - item.fontSize * 0.95,
+ width,
+ height: Math.max(lineHeight, lines.length * lineHeight),
+ }
+}
+
+function TextSelection({ bounds, zoom }) {
+ const handleSize = 6 / zoom
+ const half = handleSize / 2
+ const handles = [
+ [bounds.x, bounds.y],
+ [bounds.x + bounds.width, bounds.y],
+ [bounds.x, bounds.y + bounds.height],
+ [bounds.x + bounds.width, bounds.y + bounds.height],
+ ]
+
+ return (
+
+
+ {handles.map(([x, y], index) => (
+
+ ))}
+
+ )
+}
+
+function AnnotationText({
+ item,
+ editing,
+ selected,
+ activeTool,
+ zoom,
+ onPointerDown,
+ onDoubleClick,
+}) {
+ if (editing) {
+ return null // caller renders TextEditor directly so it can pass textareaRef
+ }
+
+ const lines = item.text.split('\n')
+ const lineHeight = item.fontSize * LINE_HEIGHT_RATIO
+ const bounds = getTextBounds(item)
+ const canInteract = activeTool === 'text'
+
+ return (
+
+ {selected ? : null}
+ {canInteract ? (
+ onPointerDown(item.id, e)}
+ onDoubleClick={(e) => onDoubleClick(item.id, e)}
+ />
+ ) : null}
+
+ {lines.map((line, i) => (
+
+ {line || ' '}
+
+ ))}
+
+
+ )
+}
+
+export default function AnnotationLayer({
+ annotations,
+ activeView,
+ activeTool,
+ penSettings,
+ markerSettings,
+ eraserSettings,
+ currentStroke,
+ pendingText,
+ isTemporaryPanMode,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+ onCommitText,
+ onCommitTextEdit,
+ selectedTextId,
+ editingTextId,
+ onTextPointerDown,
+ onTextDoubleClick,
+}) {
+ const activeTextareaRef = useRef(null)
+ const { x, y, zoom } = useViewport()
+ const items = annotations?.[activeView]?.items ?? []
+ const cursor = getCursorForTool(
+ activeTool,
+ penSettings,
+ markerSettings,
+ eraserSettings,
+ zoom,
+ isTemporaryPanMode,
+ )
+
+ const handleEditCommit = (id, value) => {
+ onCommitTextEdit(id, value)
+ }
+
+ // Div is the event surface. When an active text input exists, blur it to commit,
+ // then return so we don't immediately place a new text annotation.
+ const handlePointerDown = (e) => {
+ const activeTextarea = activeTextareaRef.current
+ if (activeTextarea) {
+ if (e.target instanceof Node && activeTextarea.contains(e.target)) {
+ return
+ }
+ activeTextarea.blur()
+ return
+ }
+ onPointerDown(e)
+ }
+
+ const handleTextPointerDown = (id, e) => {
+ e.preventDefault()
+ e.stopPropagation()
+ if (activeTextareaRef.current) {
+ activeTextareaRef.current.blur()
+ return
+ }
+ onTextPointerDown(id, e)
+ }
+
+ const handleTextDoubleClick = (id, e) => {
+ e.preventDefault()
+ e.stopPropagation()
+ if (activeTextareaRef.current) {
+ activeTextareaRef.current.blur()
+ }
+ onTextDoubleClick(id, e)
+ }
+
+ const editingItem = editingTextId ? items.find((i) => i.id === editingTextId) : null
+
+ return (
+
+
+
+ )
+}
diff --git a/src/components/annotations/AnnotationToolbox.jsx b/src/components/annotations/AnnotationToolbox.jsx
new file mode 100644
index 0000000..49b0303
--- /dev/null
+++ b/src/components/annotations/AnnotationToolbox.jsx
@@ -0,0 +1,475 @@
+import { forwardRef, useState } from 'react'
+import { Panel } from 'reactflow'
+import * as AlertDialog from '@radix-ui/react-alert-dialog'
+import * as Popover from '@radix-ui/react-popover'
+import * as Tooltip from '@radix-ui/react-tooltip'
+import { CirclePicker } from 'react-color'
+import { dialogStyles } from '../dialogs/dialogStyles.js'
+
+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 ToolGroup({ children }) {
+ return {children}
+}
+
+function AnimatedOptionsSlot({ active, children }) {
+ return (
+
+ {active ? children : null}
+
+ )
+}
+
+function ColorPresetRow({ color, onChange }) {
+ const [pickerOpen, setPickerOpen] = useState(false)
+
+ 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 ToolOptionsPopover({ trigger, children }) {
+ return (
+
+ {trigger}
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function ClearAnnotationsDialog({ open, onOpenChange, onConfirm }) {
+ return (
+
+
+
+
+
+ Clear annotations?
+
+
+ This removes all annotations in the current view. You can undo this action.
+
+
+
+ Cancel
+
+
+ Clear
+
+
+
+
+
+ )
+}
+
+const ColorSwatchButton = forwardRef(function ColorSwatchButton(
+ { label, color, ...buttonProps },
+ ref,
+) {
+ return (
+
+
+
+ )
+})
+
+const EraserSizeButton = forwardRef(function EraserSizeButton(
+ { size, ...buttonProps },
+ ref,
+) {
+ const diameter = Math.max(8, Math.min(22, Math.round(size / 3)))
+
+ return (
+
+
+
+ )
+})
+
+// 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 [isClearDialogOpen, setIsClearDialogOpen] = useState(false)
+
+ const handleConfirmClear = () => {
+ setIsClearDialogOpen(false)
+ onClearView()
+ }
+
+ return (
+
+
+ onSetTool('pointer')}
+ >
+
+
+
+
+
+
+ onSetTool('pen')}
+ >
+
+
+
+ }
+ >
+ onPenSettingsChange({ color: c })}
+ />
+ onPenSettingsChange({ thickness: v })}
+ />
+
+
+
+
+
+ onSetTool('marker')}
+ >
+
+
+
+ }
+ >
+ onMarkerSettingsChange({ color: c })}
+ />
+ onMarkerSettingsChange({ thickness: v })}
+ />
+ onMarkerSettingsChange({ opacity: v / 100 })}
+ />
+
+
+
+
+
+ onSetTool('text')}
+ >
+
+
+
+ }
+ >
+ onTextSettingsChange({ color: c })}
+ />
+ onTextSettingsChange({ fontSize: v })}
+ />
+
+
+
+
+
+ onSetTool('eraser')}
+ >
+
+
+
+ }
+ >
+ onEraserSettingsChange({ size: v })}
+ />
+
+ onEraserSettingsChange({ mode: 'whole' })}
+ className={`flex-1 rounded px-1 py-0.5 text-[10px] transition-colors ${
+ eraserSettings.mode === 'whole'
+ ? 'bg-primary text-primary-content'
+ : 'bg-base-200 text-base-content hover:bg-base-300'
+ }`}
+ >
+ Whole
+
+ onEraserSettingsChange({ mode: 'partial' })}
+ className={`flex-1 rounded px-1 py-0.5 text-[10px] transition-colors ${
+ eraserSettings.mode === 'partial'
+ ? 'bg-primary text-primary-content'
+ : 'bg-base-200 text-base-content hover:bg-base-300'
+ }`}
+ >
+ Partial
+
+
+
+
+
+
+
+
+
+ setIsClearDialogOpen(true)}
+ className="flex h-8 w-8 items-center justify-center rounded-md text-base-content transition-colors hover:bg-base-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20"
+ >
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/annotations/__tests__/AnnotationLayer.test.jsx b/src/components/annotations/__tests__/AnnotationLayer.test.jsx
new file mode 100644
index 0000000..4547487
--- /dev/null
+++ b/src/components/annotations/__tests__/AnnotationLayer.test.jsx
@@ -0,0 +1,271 @@
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import AnnotationLayer from '../AnnotationLayer.jsx'
+
+vi.mock('reactflow', () => ({
+ useViewport: () => ({ x: 0, y: 0, zoom: 1 }),
+}))
+
+afterEach(() => {
+ cleanup()
+})
+
+const baseProps = {
+ annotations: {
+ conceptual: {
+ items: [
+ {
+ id: 'text-1',
+ kind: 'text',
+ x: 120,
+ y: 80,
+ text: 'Existing annotation',
+ color: '#1e293b',
+ fontSize: 14,
+ },
+ ],
+ },
+ },
+ activeView: 'conceptual',
+ activeTool: 'text',
+ penSettings: { color: '#ef4444', thickness: 2 },
+ markerSettings: { color: '#facc15', thickness: 12, opacity: 0.35 },
+ eraserSettings: { size: 20, mode: 'whole' },
+ currentStroke: null,
+ pendingText: null,
+ isTemporaryPanMode: false,
+ selectedTextId: null,
+ editingTextId: null,
+ onPointerDown: vi.fn(),
+ onPointerMove: vi.fn(),
+ onPointerUp: vi.fn(),
+ onCommitText: vi.fn(),
+ onCommitTextEdit: vi.fn(),
+ onTextPointerDown: vi.fn(),
+ onTextDoubleClick: vi.fn(),
+}
+
+function renderLayer(props = {}) {
+ return render()
+}
+
+describe('AnnotationLayer', () => {
+ it('selects existing text without opening the editor when clicked with the text tool', () => {
+ const onPointerDown = vi.fn()
+ const onTextPointerDown = vi.fn()
+ const { container } = renderLayer({ onPointerDown, onTextPointerDown })
+
+ const hitTarget = container.querySelector('[data-annotation-hit-target="true"]')
+
+ fireEvent.pointerDown(hitTarget)
+
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
+ expect(onPointerDown).not.toHaveBeenCalled()
+ expect(onTextPointerDown).toHaveBeenCalledWith('text-1', expect.any(Object))
+ })
+
+ it('shows a selection outline for selected text', () => {
+ const { container } = renderLayer({ selectedTextId: 'text-1' })
+
+ expect(container.querySelector('[data-annotation-selection="true"]')).toBeInTheDocument()
+ })
+
+ it('requests editing when selected text is double clicked', () => {
+ const onTextDoubleClick = vi.fn()
+ const { container } = renderLayer({ selectedTextId: 'text-1', onTextDoubleClick })
+
+ const hitTarget = container.querySelector('[data-annotation-hit-target="true"]')
+
+ fireEvent.doubleClick(hitTarget)
+
+ expect(onTextDoubleClick).toHaveBeenCalledWith('text-1', expect.any(Object))
+ })
+
+ it('commits active text editing and opens another text item on double click', () => {
+ const onCommitTextEdit = vi.fn()
+ const onTextDoubleClick = vi.fn()
+ const { container } = renderLayer({
+ annotations: {
+ conceptual: {
+ items: [
+ ...baseProps.annotations.conceptual.items,
+ {
+ id: 'text-2',
+ kind: 'text',
+ x: 220,
+ y: 120,
+ text: 'Second annotation',
+ color: '#1e293b',
+ fontSize: 14,
+ },
+ ],
+ },
+ },
+ selectedTextId: 'text-1',
+ editingTextId: 'text-1',
+ onCommitTextEdit,
+ onTextDoubleClick,
+ })
+
+ const editor = screen.getByRole('textbox')
+ const secondHitTarget = container.querySelector(
+ '[data-annotation-text-id="text-2"] [data-annotation-hit-target="true"]',
+ )
+
+ fireEvent.change(editor, { target: { value: 'Edited first annotation' } })
+ fireEvent.doubleClick(secondHitTarget)
+
+ expect(onCommitTextEdit).toHaveBeenCalledWith('text-1', 'Edited first annotation')
+ expect(onTextDoubleClick).toHaveBeenCalledWith('text-2', expect.any(Object))
+ })
+
+ it('opens an editor for editing text and commits changes on blur', () => {
+ const onCommitTextEdit = vi.fn()
+ renderLayer({ selectedTextId: 'text-1', editingTextId: 'text-1', onCommitTextEdit })
+
+ const editor = screen.getByRole('textbox')
+ expect(editor).toHaveValue('Existing annotation')
+
+ fireEvent.change(editor, { target: { value: 'Updated annotation' } })
+ fireEvent.blur(editor)
+
+ expect(onCommitTextEdit).toHaveBeenCalledWith('text-1', 'Updated annotation')
+ })
+
+ it('keeps text editing active when interacting with the textarea by mouse', () => {
+ const onPointerDown = vi.fn()
+ const onPointerMove = vi.fn()
+ const onPointerUp = vi.fn()
+ const onCommitTextEdit = vi.fn()
+ renderLayer({
+ selectedTextId: 'text-1',
+ editingTextId: 'text-1',
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+ onCommitTextEdit,
+ })
+
+ const editor = screen.getByRole('textbox')
+ expect(editor).toHaveFocus()
+
+ fireEvent.pointerDown(editor)
+ fireEvent.pointerMove(editor)
+ fireEvent.pointerUp(editor)
+ fireEvent.mouseDown(editor)
+ fireEvent.click(editor)
+ fireEvent.doubleClick(editor)
+
+ expect(editor).toHaveFocus()
+ expect(onCommitTextEdit).not.toHaveBeenCalled()
+ expect(onPointerDown).not.toHaveBeenCalled()
+ expect(onPointerMove).not.toHaveBeenCalled()
+ expect(onPointerUp).not.toHaveBeenCalled()
+ })
+
+ it('commits active text edits when clicking outside the textarea', () => {
+ const onPointerDown = vi.fn()
+ const onCommitTextEdit = vi.fn()
+ const { container } = renderLayer({
+ selectedTextId: 'text-1',
+ editingTextId: 'text-1',
+ onPointerDown,
+ onCommitTextEdit,
+ })
+
+ const editor = screen.getByRole('textbox')
+ const overlay = container.querySelector('.react-flow__annotation-layer')
+
+ fireEvent.change(editor, { target: { value: 'Committed from outside click' } })
+ fireEvent.pointerDown(overlay)
+
+ expect(onCommitTextEdit).toHaveBeenCalledWith('text-1', 'Committed from outside click')
+ expect(onPointerDown).not.toHaveBeenCalled()
+ })
+
+ it('commits new text from a pending text editor', () => {
+ const onCommitText = vi.fn()
+ renderLayer({
+ pendingText: { x: 40, y: 50, color: '#ef4444', fontSize: 16 },
+ onCommitText,
+ })
+
+ const editor = screen.getByRole('textbox')
+ fireEvent.change(editor, { target: { value: 'New annotation' } })
+ fireEvent.blur(editor)
+
+ expect(onCommitText).toHaveBeenCalledWith('New annotation')
+ })
+
+ it('forwards wheel events to the element below the annotation overlay', () => {
+ const underlying = document.createElement('div')
+ const onWheel = vi.fn()
+ underlying.addEventListener('wheel', onWheel)
+ document.body.appendChild(underlying)
+ const originalElementFromPoint = document.elementFromPoint
+ Object.defineProperty(document, 'elementFromPoint', {
+ configurable: true,
+ value: vi.fn(() => underlying),
+ })
+
+ try {
+ const { container } = renderLayer()
+ const overlay = container.querySelector('.react-flow__annotation-layer')
+
+ fireEvent.wheel(overlay, { clientX: 10, clientY: 20, deltaY: 120 })
+
+ expect(onWheel).toHaveBeenCalledTimes(1)
+ const forwardedEvent = onWheel.mock.calls[0][0]
+ expect(forwardedEvent.deltaY).toBe(120)
+ expect(forwardedEvent.__annotationWheelForwarded).toBe(true)
+ } finally {
+ Object.defineProperty(document, 'elementFromPoint', {
+ configurable: true,
+ value: originalElementFromPoint,
+ })
+ underlying.remove()
+ }
+ })
+
+ it('does not re-forward wheel events that already came through the annotation overlay', () => {
+ const originalElementFromPoint = document.elementFromPoint
+ const elementFromPoint = vi.fn()
+ Object.defineProperty(document, 'elementFromPoint', {
+ configurable: true,
+ value: elementFromPoint,
+ })
+
+ try {
+ const { container } = renderLayer()
+ const overlay = container.querySelector('.react-flow__annotation-layer')
+ const wheelEvent = new WheelEvent('wheel', {
+ bubbles: true,
+ cancelable: true,
+ clientX: 10,
+ clientY: 20,
+ deltaY: 120,
+ })
+ Object.defineProperty(wheelEvent, '__annotationWheelForwarded', {
+ value: true,
+ })
+
+ overlay.dispatchEvent(wheelEvent)
+
+ expect(elementFromPoint).not.toHaveBeenCalled()
+ } finally {
+ Object.defineProperty(document, 'elementFromPoint', {
+ configurable: true,
+ value: originalElementFromPoint,
+ })
+ }
+ })
+
+ it('shows a pan cursor while temporary pan mode is active', () => {
+ const { container } = renderLayer({ isTemporaryPanMode: true })
+
+ expect(container.querySelector('.react-flow__annotation-layer')).toHaveStyle({
+ cursor: 'grab',
+ })
+ })
+})
diff --git a/src/components/annotations/__tests__/AnnotationToolbox.test.jsx b/src/components/annotations/__tests__/AnnotationToolbox.test.jsx
new file mode 100644
index 0000000..52a6bce
--- /dev/null
+++ b/src/components/annotations/__tests__/AnnotationToolbox.test.jsx
@@ -0,0 +1,168 @@
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import AnnotationToolbox from '../AnnotationToolbox.jsx'
+
+vi.mock('reactflow', () => ({
+ Panel: ({ children, className, style }) => (
+
+ {children}
+
+ ),
+}))
+
+afterEach(() => {
+ cleanup()
+})
+
+const baseProps = {
+ activeTool: 'pointer',
+ onSetTool: vi.fn(),
+ penSettings: { color: '#ef4444', thickness: 2 },
+ onPenSettingsChange: vi.fn(),
+ markerSettings: { color: '#facc15', thickness: 12, opacity: 0.35 },
+ onMarkerSettingsChange: vi.fn(),
+ textSettings: { color: '#1e293b', fontSize: 14 },
+ onTextSettingsChange: vi.fn(),
+ eraserSettings: { size: 20, mode: 'whole' },
+ onEraserSettingsChange: vi.fn(),
+ onClearView: vi.fn(),
+}
+
+function renderToolbox(props = {}) {
+ const mergedProps = {
+ ...baseProps,
+ onSetTool: vi.fn(),
+ onPenSettingsChange: vi.fn(),
+ onMarkerSettingsChange: vi.fn(),
+ onTextSettingsChange: vi.fn(),
+ onEraserSettingsChange: vi.fn(),
+ onClearView: vi.fn(),
+ ...props,
+ }
+
+ return {
+ props: mergedProps,
+ ...render(),
+ }
+}
+
+describe('AnnotationToolbox', () => {
+ it('shows annotation tool shortcuts in toolbar button labels', () => {
+ renderToolbox()
+
+ expect(screen.getByRole('button', { name: 'Pointer (V)' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Pen (P)' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Marker (M)' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Text (T)' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Eraser (E)' })).toBeInTheDocument()
+ })
+
+ it('keeps tool options out of the inline toolbar until the active tool swatch is opened', () => {
+ renderToolbox({ activeTool: 'pen' })
+
+ expect(screen.getByTestId('annotation-toolbar')).toBeInTheDocument()
+ const penOptions = screen.getByRole('button', { name: 'Pen options' })
+ expect(penOptions).toBeInTheDocument()
+ expect(penOptions.parentElement).toHaveClass('transition-[max-height,opacity,transform,padding-top]')
+ expect(penOptions.parentElement).toHaveClass('max-h-10')
+ expect(screen.queryByText('Thickness')).not.toBeInTheDocument()
+
+ fireEvent.click(penOptions)
+
+ expect(screen.getByText('Thickness')).toBeInTheDocument()
+ })
+
+ it('shows a color swatch for pen and applies colors from its popover', () => {
+ const onPenSettingsChange = vi.fn()
+ renderToolbox({ activeTool: 'pen', onPenSettingsChange })
+
+ const trigger = screen.getByRole('button', { name: 'Pen options' })
+ expect(trigger.querySelector('span')).toHaveStyle({ backgroundColor: '#ef4444' })
+
+ fireEvent.click(trigger)
+ fireEvent.click(screen.getByRole('button', { name: '#f97316' }))
+
+ expect(onPenSettingsChange).toHaveBeenCalledWith({ color: '#f97316' })
+ })
+
+ it('shows marker options in a popover and updates opacity', () => {
+ const onMarkerSettingsChange = vi.fn()
+ renderToolbox({ activeTool: 'marker', onMarkerSettingsChange })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Marker options' }))
+
+ const opacity = screen.getByDisplayValue('35')
+ fireEvent.change(opacity, { target: { value: '50' } })
+
+ expect(onMarkerSettingsChange).toHaveBeenCalledWith({ opacity: 0.5 })
+ })
+
+ it('uses the current text color as the text options swatch and updates font size', () => {
+ const onTextSettingsChange = vi.fn()
+ renderToolbox({
+ activeTool: 'text',
+ textSettings: { color: '#3b82f6', fontSize: 18 },
+ onTextSettingsChange,
+ })
+
+ const trigger = screen.getByRole('button', { name: 'Text options' })
+ expect(trigger.querySelector('span')).toHaveStyle({ backgroundColor: '#3b82f6' })
+
+ fireEvent.click(trigger)
+ const fontSize = screen.getByDisplayValue('18')
+ fireEvent.change(fontSize, { target: { value: '22' } })
+
+ expect(onTextSettingsChange).toHaveBeenCalledWith({ fontSize: 22 })
+ })
+
+ it('shows an eraser size trigger and opens size/mode options', () => {
+ const onEraserSettingsChange = vi.fn()
+ renderToolbox({ activeTool: 'eraser', onEraserSettingsChange })
+
+ const trigger = screen.getByRole('button', { name: 'Eraser options' })
+ expect(trigger.querySelector('span')).toHaveClass('rounded-full')
+
+ fireEvent.click(trigger)
+ expect(screen.getByText('Size')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Partial' }))
+
+ expect(onEraserSettingsChange).toHaveBeenCalledWith({ mode: 'partial' })
+ })
+
+ it('opens a clear annotations confirmation dialog from the trash button', () => {
+ const onClearView = vi.fn()
+ renderToolbox({ onClearView })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Clear annotations in this view' }))
+
+ expect(screen.getByText('Clear annotations?')).toBeInTheDocument()
+ expect(
+ screen.getByText('This removes all annotations in the current view. You can undo this action.'),
+ ).toBeInTheDocument()
+ expect(onClearView).not.toHaveBeenCalled()
+ })
+
+ it('does not clear annotations when the confirmation dialog is cancelled', () => {
+ const onClearView = vi.fn()
+ renderToolbox({ onClearView })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Clear annotations in this view' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
+
+ expect(onClearView).not.toHaveBeenCalled()
+ expect(screen.queryByText('Clear annotations?')).not.toBeInTheDocument()
+ })
+
+ it('clears annotations once when the confirmation dialog is confirmed', () => {
+ const onClearView = vi.fn()
+ renderToolbox({ onClearView })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Clear annotations in this view' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Clear' }))
+
+ expect(onClearView).toHaveBeenCalledTimes(1)
+ expect(screen.queryByText('Clear annotations?')).not.toBeInTheDocument()
+ })
+})
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/content/help.md b/src/content/help.md
index ab587c1..12cbe0c 100644
--- a/src/content/help.md
+++ b/src/content/help.md
@@ -173,6 +173,9 @@ MySQL importer notes:
- Add class: `Ctrl+Alt+C` / `Ctrl+Opt+C`
- Add attribute to selected class: `Ctrl+Alt+A` / `Ctrl+Opt+A`
- Delete selection: `Delete` / `Backspace`
+- Annotation tools when annotations are enabled: `V` pointer, `P` pen, `M` marker, `T` text, `E` eraser
+- Annotation mode: `Esc` clears active annotation text selection/editing first, then returns to pointer
+- Annotation mode: hold `Space` and drag to temporarily pan the diagram
## Unsaved changes and persistence
diff --git a/src/hooks/__tests__/useAnnotations.test.jsx b/src/hooks/__tests__/useAnnotations.test.jsx
new file mode 100644
index 0000000..39efcc3
--- /dev/null
+++ b/src/hooks/__tests__/useAnnotations.test.jsx
@@ -0,0 +1,417 @@
+import { act, fireEvent, renderHook } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { useAnnotations } from '../useAnnotations.js'
+
+const reactFlowInstance = {
+ screenToFlowPosition: ({ x, y }) => ({ x, y }),
+}
+
+const pointerEvent = (x, y) => ({
+ clientX: x,
+ clientY: y,
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn(),
+})
+
+function renderAnnotations(pushHistorySnapshot = vi.fn(), options = {}) {
+ return renderHook(({ activeView = 'conceptual', enabled = true, instance = reactFlowInstance }) =>
+ useAnnotations({
+ activeView,
+ reactFlowInstance: instance,
+ pushHistorySnapshot,
+ enabled,
+ }),
+ {
+ initialProps: {
+ activeView: 'conceptual',
+ enabled: options.enabled ?? true,
+ instance: options.reactFlowInstance ?? reactFlowInstance,
+ },
+ },
+ )
+}
+
+describe('useAnnotations text interactions', () => {
+ beforeEach(() => {
+ let nextId = 1
+ vi.stubGlobal('crypto', {
+ randomUUID: vi.fn(() => `annotation-${nextId++}`),
+ })
+ window.localStorage.clear()
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('keeps newly created text selected and applies toolbox changes to it', () => {
+ const pushHistorySnapshot = vi.fn()
+ const { result } = renderAnnotations(pushHistorySnapshot)
+
+ act(() => result.current.setTool('text'))
+ act(() => result.current.onPointerDown(pointerEvent(10, 20)))
+ act(() => result.current.onCommitText('Hello'))
+
+ expect(result.current.selectedTextId).toBe('annotation-1')
+ expect(result.current.annotations.conceptual.items[0]).toMatchObject({
+ id: 'annotation-1',
+ text: 'Hello',
+ color: '#1e293b',
+ fontSize: 14,
+ })
+
+ act(() => result.current.updateTextSettings({ color: '#ef4444', fontSize: 20 }))
+
+ expect(result.current.annotations.conceptual.items[0]).toMatchObject({
+ color: '#ef4444',
+ fontSize: 20,
+ })
+ expect(result.current.textSettings).toEqual({ color: '#ef4444', fontSize: 20 })
+ expect(pushHistorySnapshot).toHaveBeenCalledTimes(2)
+ })
+
+ it('uses toolbox text settings as defaults when no text is selected', () => {
+ const { result } = renderAnnotations()
+
+ act(() => result.current.setTool('text'))
+ act(() => result.current.updateTextSettings({ color: '#f97316', fontSize: 18 }))
+ act(() => result.current.onPointerDown(pointerEvent(30, 40)))
+ act(() => result.current.onCommitText('Default styled'))
+
+ expect(result.current.annotations.conceptual.items[0]).toMatchObject({
+ color: '#f97316',
+ fontSize: 18,
+ })
+ })
+
+ it('clears transient text selection when switching views', () => {
+ const { result, rerender } = renderAnnotations()
+
+ act(() => result.current.setTool('text'))
+ act(() => result.current.onPointerDown(pointerEvent(10, 20)))
+ act(() => result.current.onCommitText('Conceptual text'))
+
+ expect(result.current.selectedTextId).toBe('annotation-1')
+
+ rerender({ activeView: 'logical' })
+
+ expect(result.current.selectedTextId).toBeNull()
+
+ act(() => result.current.updateTextSettings({ color: '#f97316', fontSize: 18 }))
+ act(() => result.current.onPointerDown(pointerEvent(30, 40)))
+
+ expect(result.current.pendingText).toMatchObject({
+ x: 30,
+ y: 40,
+ color: '#f97316',
+ fontSize: 18,
+ })
+ })
+
+ it('normalizes loaded annotations so malformed items cannot break text or stroke handling', () => {
+ const { result } = renderAnnotations()
+
+ act(() => {
+ result.current.onLoadAnnotations({
+ conceptual: {
+ items: [
+ {
+ id: 'bad-text',
+ kind: 'text',
+ x: 10,
+ y: 20,
+ text: null,
+ color: null,
+ fontSize: 'large',
+ },
+ {
+ id: 'bad-stroke',
+ kind: 'stroke',
+ points: [{ x: 1, y: 2 }, { x: 'bad', y: 4 }, { x: 5, y: 6 }],
+ color: null,
+ thickness: 0,
+ opacity: null,
+ },
+ {
+ id: 'empty-stroke',
+ kind: 'stroke',
+ points: [{ x: Number.NaN, y: 1 }],
+ },
+ {
+ id: 'bad-position',
+ kind: 'text',
+ x: 'bad',
+ y: 20,
+ text: 'No position',
+ },
+ ],
+ },
+ })
+ })
+
+ expect(result.current.annotations.conceptual.items).toEqual([
+ {
+ id: 'bad-text',
+ kind: 'text',
+ x: 10,
+ y: 20,
+ text: '',
+ color: '#1e293b',
+ fontSize: 14,
+ },
+ {
+ id: 'bad-stroke',
+ kind: 'stroke',
+ tool: 'pen',
+ points: [{ x: 1, y: 2 }, { x: 5, y: 6 }],
+ color: '#ef4444',
+ thickness: 2,
+ opacity: 1,
+ },
+ ])
+ })
+
+ it('moves selected text in flow coordinates with one history snapshot', () => {
+ const pushHistorySnapshot = vi.fn()
+ const { result } = renderAnnotations(pushHistorySnapshot)
+
+ act(() => result.current.setTool('text'))
+ act(() => result.current.onPointerDown(pointerEvent(10, 20)))
+ act(() => result.current.onCommitText('Move me'))
+ pushHistorySnapshot.mockClear()
+
+ act(() => result.current.onTextPointerDown('annotation-1', pointerEvent(10, 20)))
+ act(() => result.current.onPointerMove(pointerEvent(25, 35)))
+ act(() => result.current.onPointerUp())
+
+ expect(result.current.annotations.conceptual.items[0]).toMatchObject({
+ x: 25,
+ y: 35,
+ })
+ expect(pushHistorySnapshot).toHaveBeenCalledTimes(1)
+ })
+
+ it('supports Enter editing, blank edit deletion, and Escape deselection', () => {
+ const pushHistorySnapshot = vi.fn()
+ const { result } = renderAnnotations(pushHistorySnapshot)
+
+ act(() => result.current.setTool('text'))
+ act(() => result.current.onPointerDown(pointerEvent(10, 20)))
+ act(() => result.current.onCommitText('Editable'))
+
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
+ })
+ expect(result.current.editingTextId).toBe('annotation-1')
+
+ act(() => result.current.onCommitTextEdit('annotation-1', ''))
+ expect(result.current.annotations.conceptual.items).toHaveLength(0)
+ expect(result.current.selectedTextId).toBeNull()
+
+ act(() => result.current.onPointerDown(pointerEvent(40, 50)))
+ act(() => result.current.onCommitText('Selected'))
+ expect(result.current.selectedTextId).toBe('annotation-2')
+
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
+ })
+ expect(result.current.selectedTextId).toBeNull()
+ })
+
+ it('uses annotation hotkeys only when annotations are enabled and focus is not editable', () => {
+ const { result, rerender } = renderAnnotations()
+
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'p', bubbles: true }))
+ })
+ expect(result.current.activeTool).toBe('pen')
+
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'M', bubbles: true }))
+ })
+ expect(result.current.activeTool).toBe('marker')
+
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 't', bubbles: true }))
+ })
+ expect(result.current.activeTool).toBe('text')
+
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'e', bubbles: true }))
+ })
+ expect(result.current.activeTool).toBe('eraser')
+
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'v', bubbles: true }))
+ })
+ expect(result.current.activeTool).toBe('pointer')
+
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'p', ctrlKey: true, bubbles: true }))
+ })
+ expect(result.current.activeTool).toBe('pointer')
+
+ const input = document.createElement('input')
+ document.body.appendChild(input)
+ try {
+ act(() => {
+ fireEvent.keyDown(input, { key: 'p' })
+ })
+ } finally {
+ input.remove()
+ }
+ expect(result.current.activeTool).toBe('pointer')
+
+ rerender({ enabled: false })
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'p', bubbles: true }))
+ })
+ expect(result.current.activeTool).toBe('pointer')
+ })
+
+ it('uses Escape to clear transient text state before returning to pointer mode', () => {
+ const { result } = renderAnnotations()
+
+ act(() => result.current.setTool('text'))
+ act(() => result.current.onPointerDown(pointerEvent(10, 20)))
+ act(() => result.current.onCommitText('Selected'))
+
+ expect(result.current.activeTool).toBe('text')
+ expect(result.current.selectedTextId).toBe('annotation-1')
+
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
+ })
+
+ expect(result.current.activeTool).toBe('text')
+ expect(result.current.selectedTextId).toBeNull()
+
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
+ })
+
+ expect(result.current.activeTool).toBe('pointer')
+ })
+
+ it('returns non-text annotation tools to pointer mode with Escape', () => {
+ const { result } = renderAnnotations()
+
+ act(() => result.current.setTool('marker'))
+
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
+ })
+
+ expect(result.current.activeTool).toBe('pointer')
+ })
+
+ it('temporarily pans the viewport while Space is held with an annotation tool', () => {
+ const setViewport = vi.fn()
+ const getViewport = vi.fn(() => ({ x: 10, y: 20, zoom: 2 }))
+ const instance = {
+ screenToFlowPosition: ({ x, y }) => ({ x, y }),
+ getViewport,
+ setViewport,
+ }
+ const { result } = renderAnnotations(vi.fn(), { reactFlowInstance: instance })
+
+ act(() => result.current.setTool('pen'))
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }))
+ })
+
+ expect(result.current.isTemporaryPanMode).toBe(true)
+
+ act(() => result.current.onPointerDown(pointerEvent(100, 100)))
+ act(() => result.current.onPointerMove(pointerEvent(130, 115)))
+
+ expect(result.current.currentStroke).toBeNull()
+ expect(setViewport).toHaveBeenCalledWith(
+ { x: 40, y: 35, zoom: 2 },
+ { duration: 0 },
+ )
+
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent('keyup', { key: ' ', bubbles: true }))
+ })
+
+ expect(result.current.isTemporaryPanMode).toBe(false)
+ })
+
+ it('starts temporary pan drag immediately after Space keydown', () => {
+ const setViewport = vi.fn()
+ const instance = {
+ screenToFlowPosition: ({ x, y }) => ({ x, y }),
+ getViewport: vi.fn(() => ({ x: 0, y: 0, zoom: 1 })),
+ setViewport,
+ }
+ const { result } = renderAnnotations(vi.fn(), { reactFlowInstance: instance })
+
+ act(() => result.current.setTool('marker'))
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space', bubbles: true }))
+ result.current.onPointerDown(pointerEvent(20, 20))
+ result.current.onPointerMove(pointerEvent(35, 45))
+ })
+
+ expect(result.current.currentStroke).toBeNull()
+ expect(setViewport).toHaveBeenCalledWith(
+ { x: 15, y: 25, zoom: 1 },
+ { duration: 0 },
+ )
+ })
+
+ it('does not enter temporary pan mode for pointer tool and exposes pan mode as off when annotations are disabled', () => {
+ const { result, rerender } = renderAnnotations()
+
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }))
+ })
+ expect(result.current.isTemporaryPanMode).toBe(false)
+
+ act(() => result.current.setTool('eraser'))
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }))
+ })
+ expect(result.current.isTemporaryPanMode).toBe(true)
+
+ rerender({ enabled: false })
+ expect(result.current.isTemporaryPanMode).toBe(false)
+ })
+
+ it('recognizes Space key variants for temporary pan mode', () => {
+ const { result } = renderAnnotations()
+
+ act(() => result.current.setTool('marker'))
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Space', bubbles: true }))
+ })
+
+ expect(result.current.isTemporaryPanMode).toBe(true)
+
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space', bubbles: true }))
+ })
+
+ expect(result.current.isTemporaryPanMode).toBe(false)
+ })
+
+ it('deletes selected text with Delete', () => {
+ const pushHistorySnapshot = vi.fn()
+ const { result } = renderAnnotations(pushHistorySnapshot)
+
+ act(() => result.current.setTool('text'))
+ act(() => result.current.onPointerDown(pointerEvent(10, 20)))
+ act(() => result.current.onCommitText('Delete me'))
+ pushHistorySnapshot.mockClear()
+
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }))
+ })
+
+ expect(result.current.annotations.conceptual.items).toHaveLength(0)
+ expect(result.current.selectedTextId).toBeNull()
+ expect(pushHistorySnapshot).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/src/hooks/useAnnotations.js b/src/hooks/useAnnotations.js
new file mode 100644
index 0000000..c96f03a
--- /dev/null
+++ b/src/hooks/useAnnotations.js
@@ -0,0 +1,787 @@
+import { useCallback, useEffect, useMemo, 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',
+}
+
+const TOOL_HOTKEYS = {
+ v: 'pointer',
+ p: 'pen',
+ m: 'marker',
+ t: 'text',
+ e: 'eraser',
+}
+
+function isEditableTarget(target) {
+ return (
+ target instanceof HTMLElement &&
+ (target.isContentEditable ||
+ target.tagName === 'INPUT' ||
+ target.tagName === 'TEXTAREA' ||
+ target.tagName === 'SELECT')
+ )
+}
+
+function isSpaceKey(event) {
+ const key = event.key?.toLowerCase()
+ return key === ' ' || key === 'spacebar' || key === 'space' || event.code === 'Space'
+}
+
+function isAnnotationTool(tool) {
+ return tool === 'pen' || tool === 'marker' || tool === 'text' || tool === 'eraser'
+}
+
+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 isFiniteNumber(value) {
+ return Number.isFinite(value)
+}
+
+function normalizePoint(point) {
+ if (!point || typeof point !== 'object') return null
+ if (!isFiniteNumber(point.x) || !isFiniteNumber(point.y)) return null
+ return { x: point.x, y: point.y }
+}
+
+function normalizeStrokeItem(item) {
+ const points = Array.isArray(item.points)
+ ? item.points.map(normalizePoint).filter(Boolean)
+ : []
+ if (points.length === 0) return null
+
+ const tool = item.tool === 'marker' ? 'marker' : 'pen'
+ const defaults = tool === 'marker' ? DEFAULT_TOOL_SETTINGS.marker : DEFAULT_TOOL_SETTINGS.pen
+
+ return {
+ id: item.id,
+ kind: 'stroke',
+ tool,
+ points,
+ color: typeof item.color === 'string' && item.color ? item.color : defaults.color,
+ thickness: isFiniteNumber(item.thickness) && item.thickness > 0 ? item.thickness : defaults.thickness,
+ opacity: isFiniteNumber(item.opacity) ? item.opacity : tool === 'marker' ? DEFAULT_TOOL_SETTINGS.marker.opacity : 1,
+ }
+}
+
+function normalizeTextItem(item) {
+ if (!isFiniteNumber(item.x) || !isFiniteNumber(item.y)) return null
+
+ return {
+ id: item.id,
+ kind: 'text',
+ x: item.x,
+ y: item.y,
+ text: typeof item.text === 'string' ? item.text : '',
+ color: typeof item.color === 'string' && item.color ? item.color : DEFAULT_TOOL_SETTINGS.text.color,
+ fontSize: isFiniteNumber(item.fontSize) && item.fontSize > 0 ? item.fontSize : DEFAULT_TOOL_SETTINGS.text.fontSize,
+ }
+}
+
+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.flatMap((item) => {
+ if (!item || typeof item !== 'object' || typeof item.id !== 'string') return []
+ if (item.kind === 'stroke') {
+ const stroke = normalizeStrokeItem(item)
+ return stroke ? [stroke] : []
+ }
+ if (item.kind === 'text') {
+ const text = normalizeTextItem(item)
+ return text ? [text] : []
+ }
+ return []
+ })
+ }
+ 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, enabled = true }) {
+ const [initialSettings] = useState(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 [selectedTextId, setSelectedTextId] = useState(null)
+ const [editingTextId, setEditingTextId] = useState(null)
+ const [isTemporaryPanMode, setTemporaryPanMode] = useState(false)
+ const [dirtySignal, setDirtySignal] = useState(0)
+
+ const annotationsRef = useRef(annotations)
+ const isDrawingRef = useRef(false)
+ const textDragRef = useRef(null)
+ const panDragRef = useRef(null)
+ const isTemporaryPanModeRef = useRef(false)
+
+ useEffect(() => {
+ annotationsRef.current = annotations
+ }, [annotations])
+
+ const bumpDirty = useCallback(() => setDirtySignal((n) => n + 1), [])
+
+ const getAnnotationsSnapshot = useCallback(() => annotationsRef.current, [])
+
+ const setTemporaryPanModeActive = useCallback((active) => {
+ isTemporaryPanModeRef.current = active
+ setTemporaryPanMode(active)
+ }, [])
+
+ const selectedTextItem = useMemo(() => {
+ if (!selectedTextId) return null
+ return annotations[activeView]?.items.find(
+ (item) => item.kind === 'text' && item.id === selectedTextId,
+ ) ?? null
+ }, [activeView, annotations, selectedTextId])
+
+ const editingTextItem = useMemo(() => {
+ if (!editingTextId) return null
+ return annotations[activeView]?.items.find(
+ (item) => item.kind === 'text' && item.id === editingTextId,
+ ) ?? null
+ }, [activeView, annotations, editingTextId])
+
+ const setTool = useCallback((tool) => {
+ setActiveTool(tool)
+ const settings = readToolSettings()
+ writeToolSettings({ ...settings, activeTool: tool })
+ setPendingText(null)
+ setCurrentStroke(null)
+ setEditingTextId(null)
+ if (tool !== 'text') {
+ setSelectedTextId(null)
+ }
+ isDrawingRef.current = false
+ textDragRef.current = null
+ panDragRef.current = null
+ setTemporaryPanModeActive(false)
+ }, [setTemporaryPanModeActive])
+
+ 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 updateSelectedText = useCallback(
+ (id, updates, { pushHistory = true } = {}) => {
+ const currentItem = annotationsRef.current[activeView].items.find(
+ (item) => item.kind === 'text' && item.id === id,
+ )
+ if (!currentItem) {
+ return false
+ }
+ const changed = Object.keys(updates).some((key) => updates[key] !== currentItem[key])
+ if (!changed) {
+ return false
+ }
+ if (pushHistory) {
+ pushHistorySnapshot?.()
+ }
+ setAnnotations((cur) => {
+ const nextItems = cur[activeView].items.map((item) =>
+ item.kind === 'text' && item.id === id ? { ...item, ...updates } : item,
+ )
+ return { ...cur, [activeView]: { items: nextItems } }
+ })
+ bumpDirty()
+ return true
+ },
+ [activeView, bumpDirty, pushHistorySnapshot],
+ )
+
+ const updateTextSettings = useCallback(
+ (updates) => {
+ if (activeTool === 'text' && selectedTextItem) {
+ updateSelectedText(selectedTextItem.id, updates)
+ return
+ }
+
+ setTextSettings((cur) => {
+ const next = { ...cur, ...updates }
+ const settings = readToolSettings()
+ writeToolSettings({ ...settings, text: next })
+ return next
+ })
+ },
+ [activeTool, selectedTextItem, updateSelectedText],
+ )
+
+ 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 startTemporaryPanDrag = useCallback(
+ (e) => {
+ if (
+ !enabled ||
+ !isTemporaryPanModeRef.current ||
+ !reactFlowInstance?.getViewport ||
+ !reactFlowInstance?.setViewport
+ ) {
+ return false
+ }
+ const viewport = reactFlowInstance.getViewport()
+ if (!viewport) return false
+
+ e.preventDefault()
+ e.stopPropagation()
+ setSelectedTextId(null)
+ setEditingTextId(null)
+ setPendingText(null)
+ setCurrentStroke(null)
+ isDrawingRef.current = false
+ textDragRef.current = null
+ panDragRef.current = {
+ startClient: { x: e.clientX, y: e.clientY },
+ startViewport: viewport,
+ }
+ return true
+ },
+ [enabled, 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
+ if (startTemporaryPanDrag(e)) return
+ e.preventDefault()
+ e.stopPropagation()
+ const flowPt = screenToFlow(e.clientX, e.clientY)
+
+ if (activeTool === 'pen' || activeTool === 'marker') {
+ setSelectedTextId(null)
+ setEditingTextId(null)
+ 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') {
+ setSelectedTextId(null)
+ setEditingTextId(null)
+ pushHistorySnapshot?.()
+ onEraseAt(flowPt)
+ isDrawingRef.current = true
+ } else if (activeTool === 'text') {
+ if (selectedTextItem || editingTextItem) {
+ setSelectedTextId(null)
+ setEditingTextId(null)
+ return
+ }
+ // Don't create a new input while one is already open - let blur commit it first.
+ if (!pendingText) {
+ setPendingText({ x: flowPt.x, y: flowPt.y, text: '', color: textSettings.color, fontSize: textSettings.fontSize })
+ }
+ }
+ },
+ [activeTool, editingTextItem, penSettings, markerSettings, textSettings, pendingText, screenToFlow, selectedTextItem, pushHistorySnapshot, onEraseAt, startTemporaryPanDrag],
+ )
+
+ const onPointerMove = useCallback(
+ (e) => {
+ const panDrag = panDragRef.current
+ if (panDrag && reactFlowInstance?.setViewport) {
+ e.preventDefault()
+ e.stopPropagation()
+ const dx = e.clientX - panDrag.startClient.x
+ const dy = e.clientY - panDrag.startClient.y
+ reactFlowInstance.setViewport(
+ {
+ x: panDrag.startViewport.x + dx,
+ y: panDrag.startViewport.y + dy,
+ zoom: panDrag.startViewport.zoom,
+ },
+ { duration: 0 },
+ )
+ return
+ }
+
+ const textDrag = textDragRef.current
+ if (textDrag) {
+ e.preventDefault()
+ const flowPt = screenToFlow(e.clientX, e.clientY)
+ const dx = flowPt.x - textDrag.start.x
+ const dy = flowPt.y - textDrag.start.y
+ if (!textDrag.historyPushed && (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5)) {
+ pushHistorySnapshot?.()
+ textDrag.historyPushed = true
+ }
+ if (textDrag.historyPushed) {
+ textDrag.moved = true
+ setAnnotations((cur) => ({
+ ...cur,
+ [activeView]: {
+ items: cur[activeView].items.map((item) =>
+ item.kind === 'text' && item.id === textDrag.id
+ ? {
+ ...item,
+ x: textDrag.origin.x + dx,
+ y: textDrag.origin.y + dy,
+ }
+ : item,
+ ),
+ },
+ }))
+ }
+ return
+ }
+
+ 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, activeView, pushHistorySnapshot, reactFlowInstance, screenToFlow, onEraseAt],
+ )
+
+ const onPointerUp = useCallback(
+ () => {
+ panDragRef.current = null
+ if (textDragRef.current) {
+ if (textDragRef.current.moved) {
+ bumpDirty()
+ }
+ textDragRef.current = null
+ }
+ 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 id = crypto.randomUUID()
+ const item = {
+ id,
+ 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] },
+ }))
+ setSelectedTextId(id)
+ setEditingTextId(null)
+ bumpDirty()
+ } else {
+ setSelectedTextId(null)
+ setEditingTextId(null)
+ }
+ setPendingText(null)
+ },
+ [activeView, pendingText, pushHistorySnapshot, bumpDirty],
+ )
+
+ const onCommitTextEdit = useCallback(
+ (id, text) => {
+ setEditingTextId(null)
+ if (text === null) {
+ return
+ }
+ const nextText = text.trim()
+ const currentItem = annotationsRef.current[activeView].items.find(
+ (item) => item.kind === 'text' && item.id === id,
+ )
+ if (!currentItem) {
+ return
+ }
+ if (!nextText) {
+ pushHistorySnapshot?.()
+ setAnnotations((cur) => ({
+ ...cur,
+ [activeView]: {
+ items: cur[activeView].items.filter((i) => i.id !== id),
+ },
+ }))
+ setSelectedTextId(null)
+ bumpDirty()
+ return
+ }
+ if (nextText === currentItem.text) {
+ return
+ }
+ pushHistorySnapshot?.()
+ setAnnotations((cur) => ({
+ ...cur,
+ [activeView]: {
+ items: cur[activeView].items.map((i) =>
+ i.id === id ? { ...i, text: nextText } : i,
+ ),
+ },
+ }))
+ setSelectedTextId(id)
+ bumpDirty()
+ },
+ [activeView, pushHistorySnapshot, bumpDirty],
+ )
+
+ const onStartTextEdit = useCallback(
+ (id) => {
+ if (activeTool !== 'text') return
+ setPendingText(null)
+ setSelectedTextId(id)
+ setEditingTextId(id)
+ textDragRef.current = null
+ },
+ [activeTool],
+ )
+
+ const onTextPointerDown = useCallback(
+ (id, e) => {
+ if (activeTool !== 'text') return
+ if (startTemporaryPanDrag(e)) return
+ e.preventDefault()
+ e.stopPropagation()
+ const item = annotationsRef.current[activeView].items.find(
+ (entry) => entry.kind === 'text' && entry.id === id,
+ )
+ if (!item) return
+ const flowPt = screenToFlow(e.clientX, e.clientY)
+ setPendingText(null)
+ setEditingTextId(null)
+ setSelectedTextId(id)
+ textDragRef.current =
+ selectedTextId === id
+ ? {
+ id,
+ start: flowPt,
+ origin: { x: item.x, y: item.y },
+ moved: false,
+ historyPushed: false,
+ }
+ : null
+ },
+ [activeTool, activeView, screenToFlow, selectedTextId, startTemporaryPanDrag],
+ )
+
+ const onTextDoubleClick = useCallback(
+ (id, e) => {
+ if (activeTool !== 'text') return
+ e.preventDefault()
+ e.stopPropagation()
+ onStartTextEdit(id)
+ },
+ [activeTool, onStartTextEdit],
+ )
+
+ const onDeleteSelectedText = useCallback(() => {
+ if (!selectedTextItem) return false
+ pushHistorySnapshot?.()
+ setAnnotations((cur) => ({
+ ...cur,
+ [activeView]: {
+ items: cur[activeView].items.filter(
+ (item) => item.kind !== 'text' || item.id !== selectedTextItem.id,
+ ),
+ },
+ }))
+ setSelectedTextId(null)
+ setEditingTextId(null)
+ textDragRef.current = null
+ panDragRef.current = null
+ bumpDirty()
+ return true
+ }, [activeView, bumpDirty, pushHistorySnapshot, selectedTextItem])
+
+ const onClearAnnotations = useCallback(
+ (view) => {
+ pushHistorySnapshot?.()
+ setAnnotations((cur) => ({ ...cur, [view ?? activeView]: { items: [] } }))
+ setPendingText(null)
+ setSelectedTextId(null)
+ setEditingTextId(null)
+ textDragRef.current = null
+ bumpDirty()
+ },
+ [activeView, pushHistorySnapshot, bumpDirty],
+ )
+
+ const onLoadAnnotations = useCallback((raw) => {
+ setAnnotations(normalizeAnnotations(raw))
+ setDirtySignal(0)
+ setPendingText(null)
+ setSelectedTextId(null)
+ setEditingTextId(null)
+ textDragRef.current = null
+ panDragRef.current = null
+ setTemporaryPanModeActive(false)
+ }, [setTemporaryPanModeActive])
+
+ useEffect(() => {
+ const handleKeyDown = (event) => {
+ if (!enabled || event.defaultPrevented || isEditableTarget(event.target)) return
+
+ const key = event.key?.toLowerCase()
+ if (isSpaceKey(event)) {
+ if (isAnnotationTool(activeTool) && !event.ctrlKey && !event.metaKey && !event.altKey) {
+ event.preventDefault()
+ if (!event.repeat) {
+ setTemporaryPanModeActive(true)
+ }
+ }
+ return
+ }
+
+ if (!event.ctrlKey && !event.metaKey && !event.altKey && TOOL_HOTKEYS[key]) {
+ event.preventDefault()
+ setTool(TOOL_HOTKEYS[key])
+ return
+ }
+
+ if (key === 'delete' || key === 'backspace') {
+ if (activeTool === 'text' && onDeleteSelectedText()) {
+ event.preventDefault()
+ }
+ return
+ }
+ if (key === 'escape') {
+ if (pendingText || selectedTextItem || editingTextItem) {
+ event.preventDefault()
+ setPendingText(null)
+ setSelectedTextId(null)
+ setEditingTextId(null)
+ textDragRef.current = null
+ panDragRef.current = null
+ setTemporaryPanModeActive(false)
+ return
+ }
+ if (activeTool !== 'pointer') {
+ event.preventDefault()
+ setTool('pointer')
+ }
+ return
+ }
+ if (activeTool === 'text' && key === 'enter' && selectedTextItem && !editingTextItem) {
+ event.preventDefault()
+ onStartTextEdit(selectedTextItem.id)
+ }
+ }
+
+ const handleKeyUp = (event) => {
+ if (isSpaceKey(event)) {
+ panDragRef.current = null
+ setTemporaryPanModeActive(false)
+ }
+ }
+
+ window.addEventListener('keydown', handleKeyDown, true)
+ window.addEventListener('keyup', handleKeyUp, true)
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown, true)
+ window.removeEventListener('keyup', handleKeyUp, true)
+ }
+ }, [activeTool, editingTextItem, enabled, onDeleteSelectedText, onStartTextEdit, pendingText, selectedTextItem, setTemporaryPanModeActive, setTool])
+
+ const effectiveTextSettings = selectedTextItem
+ ? { color: selectedTextItem.color, fontSize: selectedTextItem.fontSize }
+ : textSettings
+
+ return {
+ annotations,
+ activeTool,
+ penSettings,
+ markerSettings,
+ textSettings: effectiveTextSettings,
+ eraserSettings,
+ currentStroke,
+ pendingText,
+ selectedTextId: selectedTextItem ? selectedTextItem.id : null,
+ editingTextId: editingTextItem ? editingTextItem.id : null,
+ isTemporaryPanMode: enabled && isTemporaryPanMode,
+ dirtySignal,
+ getAnnotationsSnapshot,
+ setTool,
+ updatePenSettings,
+ updateMarkerSettings,
+ updateTextSettings,
+ updateEraserSettings,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+ onCommitText,
+ onCommitTextEdit,
+ onTextPointerDown,
+ onTextDoubleClick,
+ onDeleteSelectedText,
+ 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/useKeyboardShortcuts.js b/src/hooks/useKeyboardShortcuts.js
index 3b52520..e04bb2e 100644
--- a/src/hooks/useKeyboardShortcuts.js
+++ b/src/hooks/useKeyboardShortcuts.js
@@ -28,6 +28,9 @@ export function useKeyboardShortcuts({
/Mac|iPhone|iPad|iPod/.test(navigator.platform)
const handleKeyDown = (event) => {
+ if (event.defaultPrevented) {
+ return
+ }
const key = event.key?.toLowerCase()
const isAddAttributeShortcut =
key === 'a' && event.ctrlKey && event.altKey && !event.shiftKey
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,
}
diff --git a/src/test/setup.js b/src/test/setup.js
index f149f27..c993724 100644
--- a/src/test/setup.js
+++ b/src/test/setup.js
@@ -1 +1,9 @@
import "@testing-library/jest-dom/vitest";
+
+if (!globalThis.ResizeObserver) {
+ globalThis.ResizeObserver = class ResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ };
+}