From 3b2b05c99f819333c96dff91a04d4ac0ec589730 Mon Sep 17 00:00:00 2001 From: meetzaveri Date: Sun, 12 Jul 2026 13:20:38 +0530 Subject: [PATCH 1/6] update: add export craftbase data option --- src/components/sidebar/menuDrawer.tsx | 79 ++++++++++++++++++++++++++- src/utils/exportBoard.ts | 53 ++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 src/utils/exportBoard.ts diff --git a/src/components/sidebar/menuDrawer.tsx b/src/components/sidebar/menuDrawer.tsx index 18244d0..3c1e2f2 100644 --- a/src/components/sidebar/menuDrawer.tsx +++ b/src/components/sidebar/menuDrawer.tsx @@ -4,12 +4,14 @@ import { Link } from 'react-router-dom' import routes from '../../routes' import { useBoardContext } from '../../views/Board/boardContext' import { downloadViewportAsImage } from '../../utils/exportViewport' +import { exportBoardAsJson } from '../../utils/exportBoard' import Modal from '../common/modal' import Button from '../common/button' import SettingsModal from './settingsModal' import ShortcutsModal from './shortcutsModal' import SettingsIcon from '../../assets/settings.svg?react' import HelpIcon from '../../assets/help.svg?react' +import ChevronRightIcon from '../../assets/chevron-right.svg?react' const HamburgerIcon = (): ReactElement => ( { const [showSettings, setShowSettings] = useState(false) const [showShortcuts, setShowShortcuts] = useState(false) const [isExporting, setIsExporting] = useState(false) - const { clearBoard } = useBoardContext() + const [showExportSubmenu, setShowExportSubmenu] = useState(false) + const { clearBoard, stateRefForComponentStore, twoJSInstance } = + useBoardContext() useEffect(() => { const handleClick = (e: MouseEvent): void => { @@ -113,6 +117,12 @@ const MenuDrawer = (): ReactElement => { } }, []) + // Collapse the export flyout whenever the menu itself closes, so it never + // lingers open on the next menu open (covers outside-click + toggle paths). + useEffect(() => { + if (!showMenu) setShowExportSubmenu(false) + }, [showMenu]) + const handleClearClick = (): void => { setShowMenu(false) setShowConfirm(true) @@ -140,6 +150,22 @@ const MenuDrawer = (): ReactElement => { } } + const handleExportJson = (): void => { + const scene = twoJSInstance?.scene + const viewport = scene + ? { + // scene.scale is uniform (number) in this app; guard the + // Two.js Vector union rather than cast. + scale: typeof scene.scale === 'number' ? scene.scale : 1, + tx: scene.translation.x, + ty: scene.translation.y, + } + : { scale: 1, tx: 0, ty: 0 } + exportBoardAsJson(stateRefForComponentStore.current, viewport) + setShowExportSubmenu(false) + setShowMenu(false) + } + const handleConfirmClear = (): void => { clearBoard() setShowConfirm(false) @@ -336,6 +362,57 @@ const MenuDrawer = (): ReactElement => {
+
+ + + {showExportSubmenu && ( +
+ + +
+ )} +
+ +
+ + ) +} + +export default PerfOverlay diff --git a/src/components/elements/arrowLine.tsx b/src/components/elements/arrowLine.tsx index a254eec..62fb6cf 100644 --- a/src/components/elements/arrowLine.tsx +++ b/src/components/elements/arrowLine.tsx @@ -17,6 +17,7 @@ import { attachStrokeCounterScale, } from '../../utils/handleScale' import { useMediaQueryUtils } from '../../constants/exportHooks' +import { scheduleRender } from '../../utils/renderScheduler' // Clickable band width (screen px) around the line, held constant at any zoom so // the line stays easy to select even at 10%. @@ -87,6 +88,7 @@ function ArrowLine(props: ElementProps): ReactElement { } useEffect(() => { + let mountCancelled = false let hitAreaObserver: MutationObserver | null = null let detachHandleScale: (() => void) | null = null let detachHitScale: (() => void) | null = null @@ -102,12 +104,8 @@ function ArrowLine(props: ElementProps): ReactElement { linewidth: props.linewidth, isMobile, }) - const { - group, - pointCircle1Group, - pointCircle2Group, - line, - } = elementFactory.createElement() + const { group, pointCircle1Group, pointCircle2Group, line } = + elementFactory.createElement() group.elementData = { ...props.itemData, ...props } line.opacity = readOpacity(props) if (props.stroke) line.stroke = props.stroke @@ -116,7 +114,7 @@ function ArrowLine(props: ElementProps): ReactElement { if (props.parentGroup) { const parentGroup = props.parentGroup parentGroup.add(group) - two.update() + scheduleRender(two) } else { groupObject = group stateRefForGroup.current = group @@ -125,8 +123,6 @@ function ArrowLine(props: ElementProps): ReactElement { pointCircle2Group, } - two.update() - // Hold the endpoint circles at a constant on-screen size so they // stay grabbable when zoomed far out (else ~0.8px at 20%). detachHandleScale = attachHandleCounterScale( @@ -135,79 +131,97 @@ function ArrowLine(props: ElementProps): ReactElement { two?.scene?.scale ?? 1 ) - const lineDomElem = document.getElementById(line.id) - if (lineDomElem) { - const HIT_INSET = 20 - const hitElem = document.createElementNS( - 'http://www.w3.org/2000/svg', - 'line' - ) - hitElem.setAttribute('stroke', 'transparent') - hitElem.setAttribute('pointer-events', 'stroke') - // Match the selection controller's body cursor (a drag zone). - hitElem.style.cursor = 'move' + // Every DOM read below needs the rendered SVG nodes, which only + // exist after a render. Batch that render with every other element + // mounting this frame — a synchronous two.update() per element made + // mounting a board O(N²). `mountCancelled` guards an unmount before + // the frame fires, so we never observe/bind a node already gone. + scheduleRender(two, () => { + if (mountCancelled) return + + const lineDomElem = document.getElementById(line.id) + if (lineDomElem) { + const HIT_INSET = 20 + const hitElem = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'line' + ) + hitElem.setAttribute('stroke', 'transparent') + hitElem.setAttribute('pointer-events', 'stroke') + // Match the selection controller's body cursor (a drag zone). + hitElem.style.cursor = 'move' - const syncHitLine = (): void => { - const v0 = line.vertices[0] - const v1 = line.vertices[1] - const dx = v1.x - v0.x - const dy = v1.y - v0.y - const len = Math.sqrt(dx * dx + dy * dy) - if (len < HIT_INSET * 2 + 5) return - const r = HIT_INSET / len - hitElem.setAttribute('x1', String(v0.x + dx * r)) - hitElem.setAttribute('y1', String(v0.y + dy * r)) - hitElem.setAttribute('x2', String(v1.x - dx * r)) - hitElem.setAttribute('y2', String(v1.y - dy * r)) + const syncHitLine = (): void => { + const v0 = line.vertices[0] + const v1 = line.vertices[1] + const dx = v1.x - v0.x + const dy = v1.y - v0.y + const len = Math.sqrt(dx * dx + dy * dy) + if (len < HIT_INSET * 2 + 5) return + const r = HIT_INSET / len + hitElem.setAttribute('x1', String(v0.x + dx * r)) + hitElem.setAttribute('y1', String(v0.y + dy * r)) + hitElem.setAttribute('x2', String(v1.x - dx * r)) + hitElem.setAttribute('y2', String(v1.y - dy * r)) + } + + syncHitLine() + lineDomElem.parentNode?.insertBefore(hitElem, lineDomElem) + hitAreaObserver = new MutationObserver(syncHitLine) + hitAreaObserver.observe(lineDomElem, { + attributes: true, + attributeFilter: ['d'], + }) + // Keep the clickable band a constant screen width at any zoom. + detachHitScale = attachStrokeCounterScale( + (w) => hitElem.setAttribute('stroke-width', String(w)), + HIT_BAND_PX, + two, + two?.scene?.scale ?? 1 + ) } - syncHitLine() - lineDomElem.parentNode?.insertBefore(hitElem, lineDomElem) - hitAreaObserver = new MutationObserver(syncHitLine) - hitAreaObserver.observe(lineDomElem, { - attributes: true, - attributeFilter: ['d'], - }) - // Keep the clickable band a constant screen width at any zoom. - detachHitScale = attachStrokeCounterScale( - (w) => hitElem.setAttribute('stroke-width', String(w)), - HIT_BAND_PX, - two, - two?.scene?.scale ?? 1 - ) - } + // Show the `move` cursor over the line + endpoint circles, matching + // the selection controller's body cursor for other shapes. Inline so + // it beats the base `.dragger-picker { cursor: pointer }` rule, but + // still yields to the `!important` draw/pan-mode cursor overrides. + const lineEl = document.getElementById(line.id) + if (lineEl) lineEl.style.cursor = 'move' + const p1El = document.getElementById(pointCircle1Group.id) + if (p1El) { + p1El.style.cursor = 'move' + p1El.setAttribute('class', 'dragger-picker is-line-circle') + p1El.setAttribute('data-parent-id', group.id) + p1El.setAttribute('data-line-id', line.id) + p1El.setAttribute('data-direction', 'left') + } + const p2El = document.getElementById(pointCircle2Group.id) + if (p2El) { + p2El.style.cursor = 'move' + p2El.setAttribute('class', 'dragger-picker is-line-circle') + p2El.setAttribute('data-parent-id', group.id) + p2El.setAttribute('data-line-id', line.id) + p2El.setAttribute('data-direction', 'right') + } + const groupEl = document.getElementById(group.id) + if (groupEl) { + groupEl.setAttribute('class', 'dragger-picker') + groupEl.setAttribute('data-component-id', props.id) + groupEl.setAttribute( + 'data-linewidth', + String(props.linewidth ?? '') + ) + } - // Show the `move` cursor over the line + endpoint circles, matching - // the selection controller's body cursor for other shapes. Inline so - // it beats the base `.dragger-picker { cursor: pointer }` rule, but - // still yields to the `!important` draw/pan-mode cursor overrides. - const lineEl = document.getElementById(line.id) - if (lineEl) lineEl.style.cursor = 'move' - const p1El = document.getElementById(pointCircle1Group.id) - if (p1El) { - p1El.style.cursor = 'move' - p1El.setAttribute('class', 'dragger-picker is-line-circle') - p1El.setAttribute('data-parent-id', group.id) - p1El.setAttribute('data-line-id', line.id) - p1El.setAttribute('data-direction', 'left') - } - const p2El = document.getElementById(pointCircle2Group.id) - if (p2El) { - p2El.style.cursor = 'move' - p2El.setAttribute('class', 'dragger-picker is-line-circle') - p2El.setAttribute('data-parent-id', group.id) - p2El.setAttribute('data-line-id', line.id) - p2El.setAttribute('data-direction', 'right') - } - const groupEl = document.getElementById(group.id) - if (groupEl) { - groupEl.setAttribute('class', 'dragger-picker') - groupEl.setAttribute('data-component-id', props.id) - groupEl.setAttribute( - 'data-linewidth', - String(props.linewidth ?? '') + const getGroupElementFromDOM = document.getElementById( + `${group.id}` ) - } + getGroupElementFromDOM?.addEventListener( + 'focus', + onFocusHandler + ) + getGroupElementFromDOM?.addEventListener('blur', onBlurHandler) + }) setInternalState((draft) => { draft.element = { @@ -228,15 +242,10 @@ function ArrowLine(props: ElementProps): ReactElement { draft.text = { data: {} } draft.icon = { data: {} } }) - - const getGroupElementFromDOM = document.getElementById( - `${group.id}` - ) - getGroupElementFromDOM?.addEventListener('focus', onFocusHandler) - getGroupElementFromDOM?.addEventListener('blur', onBlurHandler) } return (): void => { + mountCancelled = true hitAreaObserver?.disconnect() detachHandleScale?.() detachHitScale?.() @@ -252,7 +261,7 @@ function ArrowLine(props: ElementProps): ReactElement { groupInstance.translation.x = props.x groupInstance.translation.y = props.y lineInstance.opacity = readOpacity(props) - two.update() + scheduleRender(two) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.x, props.y, props.metadata]) @@ -263,7 +272,7 @@ function ArrowLine(props: ElementProps): ReactElement { if (props.stroke) lineInstance.stroke = props.stroke if (props.linewidth) lineInstance.linewidth = props.linewidth lineInstance.dashes = strokeTypeToDashes(props.strokeType) - two.update() + scheduleRender(two) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.stroke, props.linewidth, props.strokeType]) diff --git a/src/components/elements/circle.tsx b/src/components/elements/circle.tsx index 8487c18..9033d2c 100644 --- a/src/components/elements/circle.tsx +++ b/src/components/elements/circle.tsx @@ -5,6 +5,7 @@ import { useBoardContext } from '../../views/Board/boardContext' import CircleFactory from '../../factory/circle' import { strokeTypeToDashes } from '../../utils/misc' import { applyShapeText, readOpacity } from '../../utils/canvasUtils' +import { scheduleRender } from '../../utils/renderScheduler' import { componentTypes } from '../../constants/misc' // Element components receive a fluid prop bag composed of the ComponentRecord @@ -42,7 +43,7 @@ function Circle(props: ElementProps): ReactElement { circle.translation.x = props.properties.x circle.translation.y = props.properties.y parentGroup.add(circle) - two.update() + scheduleRender(two) } else { groupRef.current = group shapeRef.current = circle @@ -62,17 +63,19 @@ function Circle(props: ElementProps): ReactElement { // actually repaint (see rectangle.tsx for the unshift rationale). group.opacity = opacityValue - two.update() - - const groupEl = document.getElementById(group.id) - if (groupEl) { - groupEl.setAttribute('class', 'dragger-picker') - groupEl.setAttribute('data-component-id', props.id) - groupEl.setAttribute( - 'data-linewidth', - String(props.linewidth ?? '') - ) - } + // The SVG node only exists after a render. Batch the render + // with every other element mounting this frame, then tag it. + scheduleRender(two, () => { + const groupEl = document.getElementById(group.id) + if (groupEl) { + groupEl.setAttribute('class', 'dragger-picker') + groupEl.setAttribute('data-component-id', props.id) + groupEl.setAttribute( + 'data-linewidth', + String(props.linewidth ?? '') + ) + } + }) } return (): void => { @@ -91,7 +94,7 @@ function Circle(props: ElementProps): ReactElement { shapeInstance.width = props.width || shapeInstance.width shapeInstance.height = props.height || shapeInstance.height shapeInstance.fill = props.fill || shapeInstance.fill - two.update() + scheduleRender(two) }, [props.x, props.y, props.fill, props.width, props.height, two]) // Re-wrap the embedded text whenever the box width or text metadata @@ -107,13 +110,13 @@ function Circle(props: ElementProps): ReactElement { props.width || shapeInstance.width || 100, props.metadata || {} ) - two.update() + scheduleRender(two) }, [props.width, props.metadata, two]) useEffect(() => { if (shapeRef.current) { shapeRef.current.dashes = strokeTypeToDashes(props.strokeType) - two.update() + scheduleRender(two) } }, [props.strokeType, two]) diff --git a/src/components/elements/curvedLine.tsx b/src/components/elements/curvedLine.tsx index c0650f7..09260ee 100644 --- a/src/components/elements/curvedLine.tsx +++ b/src/components/elements/curvedLine.tsx @@ -8,6 +8,7 @@ import { attachHandleCounterScale, attachStrokeCounterScale, } from '../../utils/handleScale' +import { scheduleRender } from '../../utils/renderScheduler' // A multi-point curved line. Renders as an open, curved Two.Path (see the // factory). Unlike the geo route it does NOT counter-scale on zoom — a plain @@ -61,6 +62,7 @@ function CurvedLine(props: ElementProps): ReactElement { }, [props.id]) useEffect(() => { + let mountCancelled = false const prevX = props.x const prevY = props.y @@ -77,7 +79,7 @@ function CurvedLine(props: ElementProps): ReactElement { path.translation.x = props.properties.x path.translation.y = props.properties.y parentGroup.add(path) - two.update() + scheduleRender(two) return (): void => { two.remove(group) } @@ -85,57 +87,6 @@ function CurvedLine(props: ElementProps): ReactElement { groupRef.current = group pathRef.current = path - two.update() - - const groupEl = document.getElementById(group.id) - if (groupEl) { - groupEl.setAttribute('class', 'dragger-picker') - groupEl.setAttribute('data-component-id', props.id) - groupEl.setAttribute('data-linewidth', String(props.linewidth ?? '')) - } - // Show the `move` cursor over the line body, matching the selection - // controller's body cursor for other shapes (the curve is a drag zone). - // Inline beats the base `.dragger-picker` rule but yields to the - // `!important` draw/pan-mode overrides. - const pathEl = document.getElementById(path.id) - if (pathEl) pathEl.style.cursor = 'move' - - // Fat transparent hit path so the (thin) curve is easy to click — its - // `d` mirrors the visible path (a MutationObserver tracks vertex drags - // for free), and its stroke width counter-scales so the clickable band - // stays a constant ~22px on screen at any zoom (else ~0.25px at 10%). - // A raw SVG node (not a Two child) so it never gets recolored/exported. - let hitObserver: MutationObserver | null = null - let detachHitScale: (() => void) | null = null - if (pathEl) { - const hitEl = document.createElementNS( - 'http://www.w3.org/2000/svg', - 'path' - ) - hitEl.setAttribute('stroke', 'transparent') - hitEl.setAttribute('fill', 'none') - hitEl.setAttribute('pointer-events', 'stroke') - hitEl.setAttribute('stroke-linecap', 'round') - hitEl.setAttribute('stroke-linejoin', 'round') - hitEl.style.cursor = 'move' - const syncD = (): void => { - const d = pathEl.getAttribute('d') - if (d) hitEl.setAttribute('d', d) - } - syncD() - pathEl.parentNode?.insertBefore(hitEl, pathEl) - hitObserver = new MutationObserver(syncD) - hitObserver.observe(pathEl, { - attributes: true, - attributeFilter: ['d'], - }) - detachHitScale = attachStrokeCounterScale( - (w) => hitEl.setAttribute('stroke-width', String(w)), - HIT_BAND_PX, - two, - zuiRef.current?.zui?.scale ?? two?.scene?.scale ?? 1 - ) - } // Build one draggable handle per vertex (children of the group, so they // move/scale with it). Hidden until the line is selected. @@ -151,7 +102,6 @@ function CurvedLine(props: ElementProps): ReactElement { handles.push(handle) } handlesRef.current = handles - two.update() // Hold the vertex dots at a constant on-screen size so they stay // grabbable when zoomed far out (else ~1px at 20%). @@ -163,70 +113,144 @@ function CurvedLine(props: ElementProps): ReactElement { initialScale ) - // Per-vertex drag, wired directly on each handle's SVG node. stopPropagation - // keeps the canvas from also starting a body-drag / reselect. + let hitObserver: MutationObserver | null = null + let detachHitScale: (() => void) | null = null const cleanups: Array<() => void> = [] - handles.forEach((handle, index) => { - const el = document.getElementById(handle.id) - if (!el) return - // `move` cursor on the vertex handles too, matching the body + the - // selection controller's drag-zone cursor. - el.style.cursor = 'move' - el.setAttribute('class', 'dragger-picker is-vertex-handle') - el.setAttribute('data-vertex-index', String(index)) - // Hidden handles must not eat clicks (opacity-0 SVG still hit-tests). - el.style.pointerEvents = 'none' - const onMouseDown = (e: MouseEvent): void => { - e.preventDefault() - e.stopPropagation() + // Every DOM read below needs the rendered SVG nodes, which only exist + // after a render. Batch that render with every other element mounting + // this frame — a synchronous two.update() per element made mounting a + // board O(N²). `mountCancelled` guards an unmount before the frame + // fires, so we never observe/bind a node that is already gone. + scheduleRender(two, () => { + if (mountCancelled) return - const onMove = (me: MouseEvent): void => { - const z = zuiRef.current - const grp = groupRef.current - const pth = pathRef.current - if (!z?.zui || !grp || !pth) return - const surface = z.zui.clientToSurface(me.clientX, me.clientY) - const vertex = pth.vertices[index] - vertex.x = surface.x - grp.translation.x - vertex.y = surface.y - grp.translation.y - handle.translation.x = vertex.x - handle.translation.y = vertex.y - // Curved, non-manual Path recomputes its control points from - // the anchors on render — flag the vertices so it re-flows. - pth._flagVertices = true - two.update() - } + const groupEl = document.getElementById(group.id) + if (groupEl) { + groupEl.setAttribute('class', 'dragger-picker') + groupEl.setAttribute('data-component-id', props.id) + groupEl.setAttribute( + 'data-linewidth', + String(props.linewidth ?? '') + ) + } + // Show the `move` cursor over the line body, matching the selection + // controller's body cursor for other shapes (the curve is a drag zone). + // Inline beats the base `.dragger-picker` rule but yields to the + // `!important` draw/pan-mode overrides. + const pathEl = document.getElementById(path.id) + if (pathEl) pathEl.style.cursor = 'move' - const onUp = (): void => { - window.removeEventListener('mousemove', onMove) - window.removeEventListener('mouseup', onUp) - const grp = groupRef.current - const pth = pathRef.current - if (!grp || !pth) return - // Persist absolute vertex coords (same convention as route). - const newVerts = pth.vertices.map((vx: ShapeLike) => ({ - x: Math.round(grp.translation.x + vx.x), - y: Math.round(grp.translation.y + vx.y), - })) - grp.elementData = { ...grp.elementData, metadata: newVerts } - // Records an UPDATE_BULK so the vertex edit is undoable. The - // component snapshots props at mount (frozen props), so the - // history hook can't revert us via a re-render — it fires a - // `curvedLineVertsReverted` event instead, which the effect - // below re-flows (path + handles) from the reverted metadata. - persistRef.current?.(idRef.current, { metadata: newVerts }) + // Fat transparent hit path so the (thin) curve is easy to click — its + // `d` mirrors the visible path (a MutationObserver tracks vertex drags + // for free), and its stroke width counter-scales so the clickable band + // stays a constant ~22px on screen at any zoom (else ~0.25px at 10%). + // A raw SVG node (not a Two child) so it never gets recolored/exported. + if (pathEl) { + const hitEl = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'path' + ) + hitEl.setAttribute('stroke', 'transparent') + hitEl.setAttribute('fill', 'none') + hitEl.setAttribute('pointer-events', 'stroke') + hitEl.setAttribute('stroke-linecap', 'round') + hitEl.setAttribute('stroke-linejoin', 'round') + hitEl.style.cursor = 'move' + const syncD = (): void => { + const d = pathEl.getAttribute('d') + if (d) hitEl.setAttribute('d', d) } - - window.addEventListener('mousemove', onMove) - window.addEventListener('mouseup', onUp) + syncD() + pathEl.parentNode?.insertBefore(hitEl, pathEl) + hitObserver = new MutationObserver(syncD) + hitObserver.observe(pathEl, { + attributes: true, + attributeFilter: ['d'], + }) + detachHitScale = attachStrokeCounterScale( + (w) => hitEl.setAttribute('stroke-width', String(w)), + HIT_BAND_PX, + two, + zuiRef.current?.zui?.scale ?? two?.scene?.scale ?? 1 + ) } - el.addEventListener('mousedown', onMouseDown) - cleanups.push(() => el.removeEventListener('mousedown', onMouseDown)) + // Per-vertex drag, wired directly on each handle's SVG node. stopPropagation + // keeps the canvas from also starting a body-drag / reselect. + handles.forEach((handle, index) => { + const el = document.getElementById(handle.id) + if (!el) return + // `move` cursor on the vertex handles too, matching the body + the + // selection controller's drag-zone cursor. + el.style.cursor = 'move' + el.setAttribute('class', 'dragger-picker is-vertex-handle') + el.setAttribute('data-vertex-index', String(index)) + // Hidden handles must not eat clicks (opacity-0 SVG still hit-tests). + el.style.pointerEvents = 'none' + + const onMouseDown = (e: MouseEvent): void => { + e.preventDefault() + e.stopPropagation() + + const onMove = (me: MouseEvent): void => { + const z = zuiRef.current + const grp = groupRef.current + const pth = pathRef.current + if (!z?.zui || !grp || !pth) return + const surface = z.zui.clientToSurface( + me.clientX, + me.clientY + ) + const vertex = pth.vertices[index] + vertex.x = surface.x - grp.translation.x + vertex.y = surface.y - grp.translation.y + handle.translation.x = vertex.x + handle.translation.y = vertex.y + // Curved, non-manual Path recomputes its control points from + // the anchors on render — flag the vertices so it re-flows. + pth._flagVertices = true + two.update() + } + + const onUp = (): void => { + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', onUp) + const grp = groupRef.current + const pth = pathRef.current + if (!grp || !pth) return + // Persist absolute vertex coords (same convention as route). + const newVerts = pth.vertices.map((vx: ShapeLike) => ({ + x: Math.round(grp.translation.x + vx.x), + y: Math.round(grp.translation.y + vx.y), + })) + grp.elementData = { + ...grp.elementData, + metadata: newVerts, + } + // Records an UPDATE_BULK so the vertex edit is undoable. The + // component snapshots props at mount (frozen props), so the + // history hook can't revert us via a re-render — it fires a + // `curvedLineVertsReverted` event instead, which the effect + // below re-flows (path + handles) from the reverted metadata. + persistRef.current?.(idRef.current, { + metadata: newVerts, + }) + } + + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', onUp) + } + + el.addEventListener('mousedown', onMouseDown) + cleanups.push(() => + el.removeEventListener('mousedown', onMouseDown) + ) + }) }) return (): void => { + mountCancelled = true hitObserver?.disconnect() detachHitScale?.() detachHandleScale() @@ -248,7 +272,7 @@ function CurvedLine(props: ElementProps): ReactElement { const el = document.getElementById(handle.id) if (el) el.style.pointerEvents = isSelected ? 'auto' : 'none' }) - two.update() + scheduleRender(two) }, [selectedComponent, two]) // Undo/redo of a vertex edit reverts our `metadata` in the store, but @@ -299,7 +323,7 @@ function CurvedLine(props: ElementProps): ReactElement { if (props.stroke) path.stroke = props.stroke if (props.linewidth) path.linewidth = props.linewidth path.dashes = strokeTypeToDashes(props.strokeType) - two.update() + scheduleRender(two) // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.stroke, props.linewidth, props.strokeType]) diff --git a/src/components/elements/diamond.tsx b/src/components/elements/diamond.tsx index aa025cc..3505c4e 100644 --- a/src/components/elements/diamond.tsx +++ b/src/components/elements/diamond.tsx @@ -5,6 +5,7 @@ import { useBoardContext } from '../../views/Board/boardContext' import ElementFactory from '../../factory/diamond' import { strokeTypeToDashes } from '../../utils/misc' import { applyShapeText, readOpacity } from '../../utils/canvasUtils' +import { scheduleRender } from '../../utils/renderScheduler' import { componentTypes } from '../../constants/misc' // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -35,7 +36,7 @@ function Diamond(props: ElementProps): ReactElement { const parentGroup = props.parentGroup diamond.opacity = opacityValue parentGroup.add(diamond) - two.update() + scheduleRender(two) } else { groupRef.current = group shapeRef.current = diamond @@ -55,17 +56,19 @@ function Diamond(props: ElementProps): ReactElement { // actually repaint (see rectangle.tsx for the unshift rationale). group.opacity = opacityValue - two.update() - - const groupEl = document.getElementById(group.id) - if (groupEl) { - groupEl.setAttribute('class', 'dragger-picker') - groupEl.setAttribute('data-component-id', props.id) - groupEl.setAttribute( - 'data-linewidth', - String(props.linewidth ?? '') - ) - } + // The SVG node only exists after a render. Batch the render + // with every other element mounting this frame, then tag it. + scheduleRender(two, () => { + const groupEl = document.getElementById(group.id) + if (groupEl) { + groupEl.setAttribute('class', 'dragger-picker') + groupEl.setAttribute('data-component-id', props.id) + groupEl.setAttribute( + 'data-linewidth', + String(props.linewidth ?? '') + ) + } + }) } return (): void => { @@ -84,7 +87,7 @@ function Diamond(props: ElementProps): ReactElement { if (props.width) shapeInstance.width = props.width if (props.height) shapeInstance.height = props.height shapeInstance.fill = props.fill || shapeInstance.fill - two.update() + scheduleRender(two) }, [props.x, props.y, props.width, props.height, props.fill, two]) // Re-wrap the embedded text whenever the box width or text metadata @@ -100,13 +103,13 @@ function Diamond(props: ElementProps): ReactElement { props.width || shapeInstance.width || 120, props.metadata || {} ) - two.update() + scheduleRender(two) }, [props.width, props.metadata, two]) useEffect(() => { if (shapeRef.current) { shapeRef.current.dashes = strokeTypeToDashes(props.strokeType) - two.update() + scheduleRender(two) } }, [props.strokeType, two]) diff --git a/src/components/elements/divider.tsx b/src/components/elements/divider.tsx index d25d3a9..1dd2b42 100644 --- a/src/components/elements/divider.tsx +++ b/src/components/elements/divider.tsx @@ -7,6 +7,7 @@ import { strokeTypeToDashes } from '../../utils/misc' import ElementCreator from '../../factory/divider' import { readOpacity } from '../../utils/canvasUtils' +import { scheduleRender } from '../../utils/renderScheduler' // eslint-disable-next-line @typescript-eslint/no-explicit-any type ElementProps = any @@ -91,32 +92,39 @@ function Divider(props: ElementProps): ReactElement { if (props.parentGroup) { const parentGroup = props.parentGroup parentGroup.add(group) - two.update() + scheduleRender(two) } else { groupObject = group stateRefForGroup.current = group selectorInstance = { pointCircle1, pointCircle2 } - two.update() + // SVG nodes exist only after a render. Batch this render with every + // other element mounting in this frame, then tag the nodes and wire + // up focus/blur — all of which need the nodes to be in the DOM. + scheduleRender(two, () => { + const lineEl = document.getElementById(line.id) + if (lineEl) lineEl.style.cursor = 'pointer' + const p1El = document.getElementById(pointCircle1.id) + if (p1El) { + p1El.style.cursor = 'pointer' + p1El.setAttribute('class', 'avoid-dragging') + } + const p2El = document.getElementById(pointCircle2.id) + if (p2El) { + p2El.style.cursor = 'pointer' + p2El.setAttribute('class', 'avoid-dragging') + } + const groupEl = document.getElementById(group.id) + if (groupEl) { + groupEl.setAttribute('class', 'dragger-picker') + groupEl.setAttribute('data-component-id', props.id) + } - const lineEl = document.getElementById(line.id) - if (lineEl) lineEl.style.cursor = 'pointer' - const p1El = document.getElementById(pointCircle1.id) - if (p1El) { - p1El.style.cursor = 'pointer' - p1El.setAttribute('class', 'avoid-dragging') - } - const p2El = document.getElementById(pointCircle2.id) - if (p2El) { - p2El.style.cursor = 'pointer' - p2El.setAttribute('class', 'avoid-dragging') - } - const groupEl = document.getElementById(group.id) - if (groupEl) { - groupEl.setAttribute('class', 'dragger-picker') - groupEl.setAttribute('data-component-id', props.id) - } + const el = document.getElementById(`${group.id}`) + el?.addEventListener('focus', onFocusHandler) + el?.addEventListener('blur', onBlurHandler) + }) setInternalState((draft) => { draft.element = { @@ -131,10 +139,6 @@ function Divider(props: ElementProps): ReactElement { draft.text = { data: {} } draft.icon = { data: {} } }) - - const el = document.getElementById(`${group.id}`) - el?.addEventListener('focus', onFocusHandler) - el?.addEventListener('blur', onBlurHandler) } return (): void => { @@ -148,7 +152,7 @@ function Divider(props: ElementProps): ReactElement { internalState.line.data.dashes = strokeTypeToDashes( props.strokeType ) - two.update() + scheduleRender(two) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.strokeType]) @@ -306,7 +310,7 @@ function Divider(props: ElementProps): ReactElement { groupInstance.translation.x = props.x groupInstance.translation.y = props.y - two.update() + scheduleRender(two) updateX1Y1VerticesLocal( lineInstance, @@ -322,7 +326,15 @@ function Divider(props: ElementProps): ReactElement { ) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.x, props.y, props.metadata, props.x1, props.x2, props.y1, props.y2]) + }, [ + props.x, + props.y, + props.metadata, + props.x1, + props.x2, + props.y1, + props.y2, + ]) void toggleToolbar diff --git a/src/components/elements/line.tsx b/src/components/elements/line.tsx index 8416b81..eaa0116 100644 --- a/src/components/elements/line.tsx +++ b/src/components/elements/line.tsx @@ -17,6 +17,7 @@ import { attachStrokeCounterScale, } from '../../utils/handleScale' import { useMediaQueryUtils } from '../../constants/exportHooks' +import { scheduleRender } from '../../utils/renderScheduler' // Clickable band width (screen px) around the line, held constant at any zoom so // the line stays easy to select even at 10%. @@ -92,6 +93,7 @@ function Line(props: ElementProps): ReactElement { } useEffect(() => { + let mountCancelled = false let hitAreaObserver: MutationObserver | null = null let detachHandleScale: (() => void) | null = null let detachHitScale: (() => void) | null = null @@ -107,12 +109,8 @@ function Line(props: ElementProps): ReactElement { linewidth: props.linewidth, isMobile, }) - const { - group, - pointCircle1Group, - pointCircle2Group, - line, - } = elementFactory.createElement() + const { group, pointCircle1Group, pointCircle2Group, line } = + elementFactory.createElement() group.elementData = { ...props.itemData, ...props } line.opacity = readOpacity(props) if (props.stroke) line.stroke = props.stroke @@ -121,7 +119,7 @@ function Line(props: ElementProps): ReactElement { if (props.parentGroup) { const parentGroup = props.parentGroup parentGroup.add(group) - two.update() + scheduleRender(two) } else { groupObject = group stateRefForGroup.current = group @@ -130,8 +128,6 @@ function Line(props: ElementProps): ReactElement { pointCircle2Group, } - two.update() - // Hold the endpoint circles at a constant on-screen size so they // stay grabbable when zoomed far out (else ~0.8px at 20%). detachHandleScale = attachHandleCounterScale( @@ -140,79 +136,97 @@ function Line(props: ElementProps): ReactElement { two?.scene?.scale ?? 1 ) - const lineDomElem = document.getElementById(line.id) - if (lineDomElem) { - const HIT_INSET = 20 - const hitElem = document.createElementNS( - 'http://www.w3.org/2000/svg', - 'line' - ) - hitElem.setAttribute('stroke', 'transparent') - hitElem.setAttribute('pointer-events', 'stroke') - // Match the selection controller's body cursor (a drag zone). - hitElem.style.cursor = 'move' + // Every DOM read below needs the rendered SVG nodes, which only + // exist after a render. Batch that render with every other element + // mounting this frame — a synchronous two.update() per element made + // mounting a board O(N²). `mountCancelled` guards an unmount before + // the frame fires, so we never observe/bind a node already gone. + scheduleRender(two, () => { + if (mountCancelled) return + + const lineDomElem = document.getElementById(line.id) + if (lineDomElem) { + const HIT_INSET = 20 + const hitElem = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'line' + ) + hitElem.setAttribute('stroke', 'transparent') + hitElem.setAttribute('pointer-events', 'stroke') + // Match the selection controller's body cursor (a drag zone). + hitElem.style.cursor = 'move' - const syncHitLine = (): void => { - const v0 = line.vertices[0] - const v1 = line.vertices[1] - const dx = v1.x - v0.x - const dy = v1.y - v0.y - const len = Math.sqrt(dx * dx + dy * dy) - if (len < HIT_INSET * 2 + 5) return - const r = HIT_INSET / len - hitElem.setAttribute('x1', String(v0.x + dx * r)) - hitElem.setAttribute('y1', String(v0.y + dy * r)) - hitElem.setAttribute('x2', String(v1.x - dx * r)) - hitElem.setAttribute('y2', String(v1.y - dy * r)) + const syncHitLine = (): void => { + const v0 = line.vertices[0] + const v1 = line.vertices[1] + const dx = v1.x - v0.x + const dy = v1.y - v0.y + const len = Math.sqrt(dx * dx + dy * dy) + if (len < HIT_INSET * 2 + 5) return + const r = HIT_INSET / len + hitElem.setAttribute('x1', String(v0.x + dx * r)) + hitElem.setAttribute('y1', String(v0.y + dy * r)) + hitElem.setAttribute('x2', String(v1.x - dx * r)) + hitElem.setAttribute('y2', String(v1.y - dy * r)) + } + + syncHitLine() + lineDomElem.parentNode?.insertBefore(hitElem, lineDomElem) + hitAreaObserver = new MutationObserver(syncHitLine) + hitAreaObserver.observe(lineDomElem, { + attributes: true, + attributeFilter: ['d'], + }) + // Keep the clickable band a constant screen width at any zoom. + detachHitScale = attachStrokeCounterScale( + (w) => hitElem.setAttribute('stroke-width', String(w)), + HIT_BAND_PX, + two, + two?.scene?.scale ?? 1 + ) } - syncHitLine() - lineDomElem.parentNode?.insertBefore(hitElem, lineDomElem) - hitAreaObserver = new MutationObserver(syncHitLine) - hitAreaObserver.observe(lineDomElem, { - attributes: true, - attributeFilter: ['d'], - }) - // Keep the clickable band a constant screen width at any zoom. - detachHitScale = attachStrokeCounterScale( - (w) => hitElem.setAttribute('stroke-width', String(w)), - HIT_BAND_PX, - two, - two?.scene?.scale ?? 1 - ) - } + // Show the `move` cursor over the line + endpoint circles, matching + // the selection controller's body cursor for other shapes. Inline so + // it beats the base `.dragger-picker { cursor: pointer }` rule, but + // still yields to the `!important` draw/pan-mode cursor overrides. + const lineEl = document.getElementById(line.id) + if (lineEl) lineEl.style.cursor = 'move' + const p1El = document.getElementById(pointCircle1Group.id) + if (p1El) { + p1El.style.cursor = 'move' + p1El.setAttribute('class', 'dragger-picker is-line-circle') + p1El.setAttribute('data-parent-id', group.id) + p1El.setAttribute('data-line-id', line.id) + p1El.setAttribute('data-direction', 'left') + } + const p2El = document.getElementById(pointCircle2Group.id) + if (p2El) { + p2El.style.cursor = 'move' + p2El.setAttribute('class', 'dragger-picker is-line-circle') + p2El.setAttribute('data-parent-id', group.id) + p2El.setAttribute('data-line-id', line.id) + p2El.setAttribute('data-direction', 'right') + } + const groupEl = document.getElementById(group.id) + if (groupEl) { + groupEl.setAttribute('class', 'dragger-picker') + groupEl.setAttribute('data-component-id', props.id) + groupEl.setAttribute( + 'data-linewidth', + String(props.linewidth ?? '') + ) + } - // Show the `move` cursor over the line + endpoint circles, matching - // the selection controller's body cursor for other shapes. Inline so - // it beats the base `.dragger-picker { cursor: pointer }` rule, but - // still yields to the `!important` draw/pan-mode cursor overrides. - const lineEl = document.getElementById(line.id) - if (lineEl) lineEl.style.cursor = 'move' - const p1El = document.getElementById(pointCircle1Group.id) - if (p1El) { - p1El.style.cursor = 'move' - p1El.setAttribute('class', 'dragger-picker is-line-circle') - p1El.setAttribute('data-parent-id', group.id) - p1El.setAttribute('data-line-id', line.id) - p1El.setAttribute('data-direction', 'left') - } - const p2El = document.getElementById(pointCircle2Group.id) - if (p2El) { - p2El.style.cursor = 'move' - p2El.setAttribute('class', 'dragger-picker is-line-circle') - p2El.setAttribute('data-parent-id', group.id) - p2El.setAttribute('data-line-id', line.id) - p2El.setAttribute('data-direction', 'right') - } - const groupEl = document.getElementById(group.id) - if (groupEl) { - groupEl.setAttribute('class', 'dragger-picker') - groupEl.setAttribute('data-component-id', props.id) - groupEl.setAttribute( - 'data-linewidth', - String(props.linewidth ?? '') + const getGroupElementFromDOM = document.getElementById( + `${group.id}` ) - } + getGroupElementFromDOM?.addEventListener( + 'focus', + onFocusHandler + ) + getGroupElementFromDOM?.addEventListener('blur', onBlurHandler) + }) setInternalState((draft) => { draft.element = { @@ -233,15 +247,10 @@ function Line(props: ElementProps): ReactElement { draft.text = { data: {} } draft.icon = { data: {} } }) - - const getGroupElementFromDOM = document.getElementById( - `${group.id}` - ) - getGroupElementFromDOM?.addEventListener('focus', onFocusHandler) - getGroupElementFromDOM?.addEventListener('blur', onBlurHandler) } return (): void => { + mountCancelled = true hitAreaObserver?.disconnect() detachHandleScale?.() detachHitScale?.() @@ -257,7 +266,7 @@ function Line(props: ElementProps): ReactElement { groupInstance.translation.x = props.x groupInstance.translation.y = props.y lineInstance.opacity = readOpacity(props) - two.update() + scheduleRender(two) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.x, props.y, props.metadata]) @@ -268,7 +277,7 @@ function Line(props: ElementProps): ReactElement { if (props.stroke) lineInstance.stroke = props.stroke if (props.linewidth) lineInstance.linewidth = props.linewidth lineInstance.dashes = strokeTypeToDashes(props.strokeType) - two.update() + scheduleRender(two) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.stroke, props.linewidth, props.strokeType]) diff --git a/src/components/elements/newText.tsx b/src/components/elements/newText.tsx index 3d96c0e..9b189da 100644 --- a/src/components/elements/newText.tsx +++ b/src/components/elements/newText.tsx @@ -8,6 +8,7 @@ import { syncTextHitRect, readOpacity } from '../../utils/canvasUtils' import { lineHeightFor } from '../../utils/textLayout' import { htmlToBulletText } from '../../utils/htmlToBulletText' import { DEFAULT_TEXT_FONT_FAMILY } from '../../constants/misc' +import { scheduleRender } from '../../utils/renderScheduler' // eslint-disable-next-line @typescript-eslint/no-explicit-any type ElementProps = any @@ -43,6 +44,7 @@ function NewText(props: ElementProps): ReactElement { const two = props.twoJSInstance useEffect(() => { + let mountCancelled = false const prevX = props.x const prevY = props.y @@ -92,7 +94,7 @@ function NewText(props: ElementProps): ReactElement { // Keep the transparent hit area covering the whole block so clicks // in the gaps between lines still select the text (see canvasUtils). syncTextHitRect(two, group) - two.update() + scheduleRender(two) } syncMultilineRef.current = syncMultilineLayout @@ -136,13 +138,29 @@ function NewText(props: ElementProps): ReactElement { // Render any persisted multiline content as the stacked block. syncMultilineLayout() - two.update() - const groupEl = document.getElementById(group.id) - if (groupEl) { - groupEl.setAttribute('class', 'dragger-picker') - groupEl.setAttribute('data-component-id', props.id) - } + // Everything below that touches the DOM (node tagging, the dblclick + // listeners) needs the rendered SVG nodes, which only exist after a + // render. Batch that render with every other element mounting this + // frame — a synchronous two.update() per element made mounting a board + // O(N²). `mountCancelled` guards an unmount before the frame fires, so + // we never bind listeners to a node that is already gone. + scheduleRender(two, () => { + if (mountCancelled) return + + const groupEl = document.getElementById(group.id) + if (groupEl) { + groupEl.setAttribute('class', 'dragger-picker') + groupEl.setAttribute('data-component-id', props.id) + } + + twoText._renderer?.elem?.addEventListener('dblclick', () => { + showTextInput() + }) + groupEl?.addEventListener('dblclick', () => { + showTextInput() + }) + }) setInternalState((draft) => { draft.group = { id: group.id, data: group } @@ -152,8 +170,6 @@ function NewText(props: ElementProps): ReactElement { draft.icon = { data: {} } }) - const getGroupElementFromDOM = document.getElementById(`${group.id}`) - const showTextInput = (): void => { // A dblclick bubbles from the text node to the group, firing BOTH // dblclick listeners below. Without this guard the second call reads @@ -227,7 +243,10 @@ function NewText(props: ElementProps): ReactElement { // Calibrate the constant part (canvas page offset + glyph bearing) // from the real start position so there's no jump entering edit. const calibX = - startRect.left - 8 - two.scene.translation.x - surfaceLeft * scale0 + startRect.left - + 8 - + two.scene.translation.x - + surfaceLeft * scale0 const calibY = startRect.top + startRect.height / 2 - @@ -444,13 +463,6 @@ function NewText(props: ElementProps): ReactElement { }) } - twoText._renderer.elem.addEventListener('dblclick', () => { - showTextInput() - }) - getGroupElementFromDOM?.addEventListener('dblclick', () => { - showTextInput() - }) - const handleTriggerTextInput = (e: Event): void => { const detail = (e as CustomEvent<{ elementId: string }>).detail if (detail?.elementId === props.id) { @@ -460,6 +472,7 @@ function NewText(props: ElementProps): ReactElement { window.addEventListener('triggerTextInput', handleTriggerTextInput) return (): void => { + mountCancelled = true window.removeEventListener( 'triggerTextInput', handleTriggerTextInput @@ -472,7 +485,7 @@ function NewText(props: ElementProps): ReactElement { if (internalState?.group?.data) { internalState.group.data.translation.x = props.x internalState.group.data.translation.y = props.y - two.update() + scheduleRender(two) } if (internalState?.twoText?.data) { @@ -501,7 +514,7 @@ function NewText(props: ElementProps): ReactElement { // group-apply, plus external content updates). syncMultilineRef.current?.() - two.update() + scheduleRender(two) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.x, props.y, props.textColor, props.metadata]) diff --git a/src/components/elements/pencil.tsx b/src/components/elements/pencil.tsx index 9a96a7a..9db73f9 100644 --- a/src/components/elements/pencil.tsx +++ b/src/components/elements/pencil.tsx @@ -6,6 +6,7 @@ import { strokeTypeToDashes } from '../../utils/misc' import getEditComponents from '../utils/editWrapper' import PencilFactory from '../../factory/pencil' import { readOpacity } from '../../utils/canvasUtils' +import { scheduleRender } from '../../utils/renderScheduler' // eslint-disable-next-line @typescript-eslint/no-explicit-any type ElementProps = any @@ -39,7 +40,7 @@ function Pencil(props: ElementProps): ReactElement { shapeRef.translation.y = props.properties.y shapeRef.opacity = pencilOpacity parentGroup.add(shapeRef) - two.update() + scheduleRender(two) } else { group.opacity = pencilOpacity getEditComponents(two, group, 4) @@ -47,17 +48,19 @@ function Pencil(props: ElementProps): ReactElement { if (path) { group.children.unshift(path) } - two.update() - - const groupEl = document.getElementById(group.id) - if (groupEl) { - groupEl.setAttribute('class', 'avoid-dragging') - groupEl.setAttribute('data-component-id', props.id) - groupEl.setAttribute( - 'data-linewidth', - String(props.linewidth ?? '') - ) - } + // SVG node exists only after a render. Batch this render with + // every other element mounting in this frame, then tag the node. + scheduleRender(two, () => { + const groupEl = document.getElementById(group.id) + if (groupEl) { + groupEl.setAttribute('class', 'avoid-dragging') + groupEl.setAttribute('data-component-id', props.id) + groupEl.setAttribute( + 'data-linewidth', + String(props.linewidth ?? '') + ) + } + }) setInternalState((draft) => { draft.element = { @@ -88,7 +91,7 @@ function Pencil(props: ElementProps): ReactElement { const groupInstance = internalState.group.data groupInstance.translation.x = props.x groupInstance.translation.y = props.y - two.update() + scheduleRender(two) } if (internalState?.shape?.data) { const shapeInstance = internalState.shape.data @@ -99,7 +102,7 @@ function Pencil(props: ElementProps): ReactElement { ? props.height : shapeInstance.height shapeInstance.fill = props.fill ? props.fill : shapeInstance.fill - two.update() + scheduleRender(two) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.x, props.y, props.fill, props.width, props.height]) @@ -111,7 +114,7 @@ function Pencil(props: ElementProps): ReactElement { internalState.group.data.children.forEach((child: any) => { child.dashes = dashes }) - two.update() + scheduleRender(two) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.strokeType]) diff --git a/src/components/elements/rectangle.tsx b/src/components/elements/rectangle.tsx index 53bd9b0..70e47de 100644 --- a/src/components/elements/rectangle.tsx +++ b/src/components/elements/rectangle.tsx @@ -5,6 +5,7 @@ import { useBoardContext } from '../../views/Board/boardContext' import ElementFactory from '../../factory/rectangle' import { strokeTypeToDashes } from '../../utils/misc' import { applyShapeText, readOpacity } from '../../utils/canvasUtils' +import { scheduleRender } from '../../utils/renderScheduler' import { componentTypes } from '../../constants/misc' // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -35,7 +36,7 @@ function Rectangle(props: ElementProps): ReactElement { const parentGroup = props.parentGroup rectangle.opacity = opacityValue parentGroup.add(rectangle) - two.update() + scheduleRender(two) } else { groupRef.current = group shapeRef.current = rectangle @@ -57,17 +58,19 @@ function Rectangle(props: ElementProps): ReactElement { // which leaves leaf-level opacity flags unprocessed on render). group.opacity = opacityValue - two.update() - - const groupEl = document.getElementById(group.id) - if (groupEl) { - groupEl.setAttribute('class', 'dragger-picker') - groupEl.setAttribute('data-component-id', props.id) - groupEl.setAttribute( - 'data-linewidth', - String(props.linewidth ?? '') - ) - } + // The SVG node only exists after a render. Batch the render with + // every other element mounting this frame, then tag the node. + scheduleRender(two, () => { + const groupEl = document.getElementById(group.id) + if (groupEl) { + groupEl.setAttribute('class', 'dragger-picker') + groupEl.setAttribute('data-component-id', props.id) + groupEl.setAttribute( + 'data-linewidth', + String(props.linewidth ?? '') + ) + } + }) } return (): void => { @@ -86,7 +89,7 @@ function Rectangle(props: ElementProps): ReactElement { shapeInstance.width = props.width || shapeInstance.width shapeInstance.height = props.height || shapeInstance.height shapeInstance.fill = props.fill || shapeInstance.fill - two.update() + scheduleRender(two) }, [props.x, props.y, props.width, props.height, props.fill, two]) // Re-wrap the embedded text whenever the box width or text metadata @@ -102,13 +105,13 @@ function Rectangle(props: ElementProps): ReactElement { props.width || shapeInstance.width || 120, props.metadata || {} ) - two.update() + scheduleRender(two) }, [props.width, props.metadata, two]) useEffect(() => { if (shapeRef.current) { shapeRef.current.dashes = strokeTypeToDashes(props.strokeType) - two.update() + scheduleRender(two) } }, [props.strokeType, two]) diff --git a/src/components/modals/BoardSizeLimitModal.tsx b/src/components/modals/BoardSizeLimitModal.tsx new file mode 100644 index 0000000..def8aad --- /dev/null +++ b/src/components/modals/BoardSizeLimitModal.tsx @@ -0,0 +1,113 @@ +import type { ReactElement } from 'react' +import Modal from '../common/modal' +import Button from '../common/button' +import { + MAX_DRAFT_BYTES, + MAX_DRAFT_UTF8_BYTES, +} from '../../utils/boardSizeGuard' + +// Load-side ceiling is a UTF-16 storage-footprint estimate; the write-side +// budget is a real UTF-8 size (what the live readout shows). +const loadBudgetMb = Math.round(MAX_DRAFT_BYTES / (1024 * 1024)) +const writeBudgetMb = Math.round(MAX_DRAFT_UTF8_BYTES / (1024 * 1024)) + +interface BoardFullModalProps { + open: boolean + onClose: () => void + onExport: () => void +} + +/** + * Write-side notice: a paste/draw was rolled back because it would have pushed + * the board past the size budget. Non-destructive — the revert already ran, so + * this just explains it and offers an export as the way to keep growing. + */ +export function BoardFullModal({ + open, + onClose, + onExport, +}: BoardFullModalProps): ReactElement { + return ( + +
+

Board is full

+

+ This board reached the ~{writeBudgetMb} MB size limit, so your + last change was undone to keep it stable and loadable. + Export the board to a file if you want to keep building on + it elsewhere. +

+
+
+
+
+ ) +} + +interface BoardTooLargeModalProps { + open: boolean + onDownloadBackup: () => void + onStartFresh: () => void + onOpenAnyway: () => void +} + +/** + * Load-side rescue: the persisted draft is too large to render without + * freezing. Shown INSTEAD of loading it. "Download backup" reads the raw draft + * straight from localStorage (no rendering), so it works even though the board + * can't be opened normally. `locked` so it can't be dismissed into a frozen + * canvas — the user must pick a recovery path. + */ +export function BoardTooLargeModal({ + open, + onDownloadBackup, + onStartFresh, + onOpenAnyway, +}: BoardTooLargeModalProps): ReactElement { + return ( + {}} locked> +
+

+ This board is too large to open +

+

+ It exceeds the ~{loadBudgetMb} MB limit and opening it may + freeze the page. Download a backup of your work first, then + start with a fresh canvas. +

+
+
+ +
+
+ ) +} diff --git a/src/components/modals/ImportBoardModal.tsx b/src/components/modals/ImportBoardModal.tsx new file mode 100644 index 0000000..24a1917 --- /dev/null +++ b/src/components/modals/ImportBoardModal.tsx @@ -0,0 +1,91 @@ +import type { ReactElement } from 'react' +import Modal from '../common/modal' +import Button from '../common/button' + +interface ImportBoardModalProps { + open: boolean + onClose: () => void + /** When set, the modal shows an error state instead of the chooser. */ + error?: string | null + total?: number + skipped?: number + onOpenAsNew?: () => void + onMerge?: () => void +} + +/** + * Post-file-pick chooser for importing a board. On success it asks how to + * apply the file — replace the canvas or merge into it. On a hard failure + * (bad JSON / not a board / nothing valid) it surfaces the error with a single + * dismiss. + */ +export default function ImportBoardModal({ + open, + onClose, + error, + total = 0, + skipped = 0, + onOpenAsNew, + onMerge, +}: ImportBoardModalProps): ReactElement { + const valid = total - skipped + + return ( + +
+ {error ? ( + <> +

+ Couldn’t import board +

+

+ {error} +

+
+
+ + ) : ( + <> +

+ Import board +

+

+ Found {valid} element{valid === 1 ? '' : 's'} in + this file. +

+ {skipped > 0 && ( +

+ {skipped} unreadable item + {skipped === 1 ? '' : 's'} will be skipped. +

+ )} +

+ Open it as a new canvas (replaces your current + board) or merge it into what you have now? +

+
+
+ + )} +
+
+ ) +} diff --git a/src/components/sidebar/menuDrawer.tsx b/src/components/sidebar/menuDrawer.tsx index 3c1e2f2..ab20b86 100644 --- a/src/components/sidebar/menuDrawer.tsx +++ b/src/components/sidebar/menuDrawer.tsx @@ -95,6 +95,31 @@ const DownloadIcon = (): ReactElement => ( ) +const UploadIcon = (): ReactElement => ( + + + + +) + const MenuDrawer = (): ReactElement => { const refNode = useRef(null) const [showMenu, setShowMenu] = useState(false) @@ -103,8 +128,13 @@ const MenuDrawer = (): ReactElement => { const [showShortcuts, setShowShortcuts] = useState(false) const [isExporting, setIsExporting] = useState(false) const [showExportSubmenu, setShowExportSubmenu] = useState(false) - const { clearBoard, stateRefForComponentStore, twoJSInstance } = - useBoardContext() + const [showImportSubmenu, setShowImportSubmenu] = useState(false) + const { + clearBoard, + stateRefForComponentStore, + twoJSInstance, + beginBoardImport, + } = useBoardContext() useEffect(() => { const handleClick = (e: MouseEvent): void => { @@ -117,10 +147,13 @@ const MenuDrawer = (): ReactElement => { } }, []) - // Collapse the export flyout whenever the menu itself closes, so it never - // lingers open on the next menu open (covers outside-click + toggle paths). + // Collapse the export/import flyouts whenever the menu itself closes, so + // neither lingers open on the next menu open (covers outside-click + toggle). useEffect(() => { - if (!showMenu) setShowExportSubmenu(false) + if (!showMenu) { + setShowExportSubmenu(false) + setShowImportSubmenu(false) + } }, [showMenu]) const handleClearClick = (): void => { @@ -166,6 +199,13 @@ const MenuDrawer = (): ReactElement => { setShowMenu(false) } + const handleImportJson = (): void => { + setShowImportSubmenu(false) + setShowMenu(false) + // board.tsx owns the file-pick → parse → chooser flow. + beginBoardImport() + } + const handleConfirmClear = (): void => { clearBoard() setShowConfirm(false) @@ -368,9 +408,10 @@ const MenuDrawer = (): ReactElement => { hover:bg-accent/50 rounded cursor-pointer transition-colors ease-in-out duration-150" style={{ width: 'calc(100% - 8px)' }} - onClick={(): void => + onClick={(): void => { + setShowImportSubmenu(false) setShowExportSubmenu((prev) => !prev) - } + }} > @@ -413,6 +454,58 @@ const MenuDrawer = (): ReactElement => { )} +
+ + + {showImportSubmenu && ( +
+ + +
+ )} +
+