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 ( + +