From e2d90b018552b08699a38f7a4d24c5eb47cf499f Mon Sep 17 00:00:00 2001 From: meetzaveri Date: Sat, 13 Jun 2026 12:32:43 +0530 Subject: [PATCH 1/9] update: add perf logs and preload elements module chunks (shapes only) --- src/components/elements/circle.tsx | 13 ++++++++ src/components/elements/diamond.tsx | 13 ++++++++ src/components/elements/rectangle.tsx | 16 ++++++++++ src/components/sidebar/primary.tsx | 19 +++++++++++ src/elementModules.ts | 46 +++++++++++++++++++++++++++ src/newCanvas.tsx | 34 +++++++++++++++++++- src/utils/perfLog.ts | 43 +++++++++++++++++++++++++ src/views/Board/board.tsx | 40 +++++++++++++++++++++++ 8 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 src/elementModules.ts create mode 100644 src/utils/perfLog.ts diff --git a/src/components/elements/circle.tsx b/src/components/elements/circle.tsx index aaa8d27..f07d40b 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 } from '../../utils/canvasUtils' +import { perfLog } from '../../utils/perfLog' import { componentTypes } from '../../constants/misc' // Element components receive a fluid prop bag composed of the ComponentRecord @@ -29,6 +30,13 @@ function Circle(props: ElementProps): ReactElement { const prevX = props.x const prevY = props.y + // [perf] Stage 7 — see rectangle.tsx for the lifecycle map. + if (!props.parentGroup) { + perfLog('Circle mount: building group (React render → Two.js)', { + id: props.id, + }) + } + const elementFactory = new CircleFactory(two, prevX, prevY, { ...props, }) @@ -73,6 +81,11 @@ function Circle(props: ElementProps): ReactElement { String(props.linewidth ?? '') ) } + + // [perf] Stage 7b — group in scene, painted at full opacity. + perfLog('Circle mount: group in scene, two.update() done', { + id: props.id, + }) } return (): void => { diff --git a/src/components/elements/diamond.tsx b/src/components/elements/diamond.tsx index 0a5529a..e6256aa 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 } from '../../utils/canvasUtils' +import { perfLog } from '../../utils/perfLog' import { componentTypes } from '../../constants/misc' // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -24,6 +25,13 @@ function Diamond(props: ElementProps): ReactElement { const prevX = props.x const prevY = props.y + // [perf] Stage 7 — see rectangle.tsx for the lifecycle map. + if (!props.parentGroup) { + perfLog('Diamond mount: building group (React render → Two.js)', { + id: props.id, + }) + } + const elementFactory = new ElementFactory(two, prevX, prevY, { ...props, }) @@ -66,6 +74,11 @@ function Diamond(props: ElementProps): ReactElement { String(props.linewidth ?? '') ) } + + // [perf] Stage 7b — group in scene, painted at full opacity. + perfLog('Diamond mount: group in scene, two.update() done', { + id: props.id, + }) } return (): void => { diff --git a/src/components/elements/rectangle.tsx b/src/components/elements/rectangle.tsx index d147005..5d49523 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 } from '../../utils/canvasUtils' +import { perfLog } from '../../utils/perfLog' import { componentTypes } from '../../constants/misc' // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -24,6 +25,15 @@ function Rectangle(props: ElementProps): ReactElement { const prevX = props.x const prevY = props.y + // [perf] Stage 7 — React mounted the Rectangle component and is now + // building the Two.js group. Only top-level draws (no parentGroup) are + // the freshly-drawn-shape path we're tracing. + if (!props.parentGroup) { + perfLog('Rectangle mount: building group (React render → Two.js)', { + id: props.id, + }) + } + const elementFactory = new ElementFactory(two, prevX, prevY, { ...props, }) @@ -68,6 +78,12 @@ function Rectangle(props: ElementProps): ReactElement { String(props.linewidth ?? '') ) } + + // [perf] Stage 7b — group is in two.scene and two.update() painted + // it at full opacity. The poll in mouseup will detect it next frame. + perfLog('Rectangle mount: group in scene, two.update() done', { + id: props.id, + }) } return (): void => { diff --git a/src/components/sidebar/primary.tsx b/src/components/sidebar/primary.tsx index 85c38b6..ebe4447 100644 --- a/src/components/sidebar/primary.tsx +++ b/src/components/sidebar/primary.tsx @@ -6,6 +6,8 @@ import ShapesToolbar from './shapesToolbar' import { GET_COMPONENT_TYPES } from '../../schema/queries' import SpinnerWithSize from '../common/spinnerWithSize' import { generateUUID } from '../../utils/misc' +import { perfStart, perfLog } from '../../utils/perfLog' +import { prefetchElementModule } from '../../elementModules' import { useBoardContext } from '../../views/Board/boardContext' import { useMediaQueryUtils } from '../../constants/exportHooks' import type { ComponentRecord } from '../../types/board' @@ -304,6 +306,16 @@ const PrimarySidebar = (): ReactElement => { } const addElement = (label: string, category?: string): void => { + // [perf] Stage 1 — the toolbar-select entry point. Reset the timeline + // for shape draws so every later milestone is measured from here. + if (DRAW_SHAPE_TYPES.includes(label)) { + perfStart(`addElement() toolbar select: ${label}`) + // Warm the shape's lazy chunk NOW, while the user moves to the + // canvas and drags (~700ms–1s per the perf traces). By mouseup the + // chunk is cached, so the component mounts instantly instead of the + // freshly-drawn shape sitting dimmed during a first-time fetch. + prefetchElementModule(label) + } cancelPendingElement() if (label !== 'rubber') setRubberModeInBoard(false) if (label !== 'pan') togglePanMode(false) @@ -481,6 +493,13 @@ const PrimarySidebar = (): ReactElement => { ) const root = document.getElementById('main-two-root') if (root) root.style.cursor = 'crosshair' + // [perf] Stage 2 — pending shape armed, cursor crosshair. + // Canvas now waits for mousedown/mouseup to draw it. (Note: + // the hint button above is shown via a 100ms setTimeout.) + perfLog('addElement() armed pendingShape + crosshair', { + label, + id: generateId, + }) } else { updateLastAddedElement(shapeData) localStorage.setItem('lastAddedElementId', generateId) diff --git a/src/elementModules.ts b/src/elementModules.ts new file mode 100644 index 0000000..8adba05 --- /dev/null +++ b/src/elementModules.ts @@ -0,0 +1,46 @@ +// Single source of truth for the lazily-loaded whiteboard element components. +// +// Each file under components/elements/*.tsx is its own dynamic chunk — Vite +// code-splits non-eager `import.meta.glob` — and is mounted via React.lazy in +// newCanvas.tsx. On a fresh page the FIRST draw of a given shape type pays a +// network fetch + parse of that chunk before React can mount it; that is the +// "freshly drawn shape sits dimmed for a couple seconds" cost on prod. +// +// Lives at the src root so the glob path (and therefore the produced keys, +// e.g. './components/elements/circle.tsx') matches newCanvas's original glob +// verbatim — newCanvas keys into this map with that exact string. + +import { perfLog } from './utils/perfLog' + +export const elementModules = import.meta.glob('./components/elements/*.tsx') + +// Idempotent prefetch: kicks off (and caches) the dynamic import for a shape +// type so its chunk is warm before React.lazy needs it. Calling it repeatedly +// reuses the in-flight/resolved promise, and the browser dedupes the import, +// so the real mount path (React.lazy) resolves instantly once warmed. +const inFlight = new Map>() + +export function prefetchElementModule(componentType: string): void { + const key = `./components/elements/${componentType}.tsx` + const loader = elementModules[key] + if (!loader) return + if (inFlight.has(key)) { + // [perf] Already warming/warm — the mount will be instant. + perfLog(`prefetch: ${componentType} chunk already warm`) + return + } + // [perf] Cold chunk — fetch starts now. On prod this is the network hit + // that, before prefetching, blocked the post-mouseup mount. + perfLog(`prefetch: ${componentType} chunk fetch START`) + const p = loader() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .then((m: any) => { + // [perf] Chunk is now cached; React.lazy will resolve immediately. + perfLog(`prefetch: ${componentType} chunk fetch DONE (warm)`) + return m + }) + // Best-effort warm-up — the real load path surfaces genuine failures + // via its own Suspense/error boundary, so swallow here. + .catch(() => undefined) + inFlight.set(key, p) +} diff --git a/src/newCanvas.tsx b/src/newCanvas.tsx index 693a7e1..6bd92eb 100644 --- a/src/newCanvas.tsx +++ b/src/newCanvas.tsx @@ -54,7 +54,9 @@ import { } from './constants/misc' import Spinner from './components/common/spinner' -const elementModules = import.meta.glob('./components/elements/*.tsx') +// Shared lazy-element glob (single source of truth) so the chunk warmed by +// prefetchElementModule is the exact one React.lazy mounts here. +import { elementModules } from './elementModules' import Loader from './components/utils/loader' import SelectionController, { @@ -87,6 +89,7 @@ import { shapeTextStyleFromMeta, } from './utils/canvasUtils' import { growShapeToFitText, usableTextWidth } from './utils/shapeTextFit' +import { perfLog } from './utils/perfLog' import { isSelectPanMode, isPanMode } from './utils/drawModeUtils' import { createDiamondPath } from './factory/diamond' import { useCanvasClipboard } from './hooks/useCanvasClipboard' @@ -1839,6 +1842,13 @@ function addZUI( two.update() } + // [perf] Stage 3 — mousedown: preview shape created at reduced + // opacity. This is the dimmed shape the user sees while drawing. + perfLog('mousedown: preview shape created (dimmed)', { + drawShapeType, + opacity: DEFAULT_PREVIEW_OPACITY, + }) + domElement.addEventListener('mousemove', mousemove, false) domElement.addEventListener('mouseup', mouseup, false) setRootCursor('crosshair') @@ -2722,23 +2732,45 @@ function addZUI( height: finalHeight, } + // [perf] Stage 4 — mouseup: gesture done, about to commit to + // the React store. Preview is still on screen at this point. + perfLog('mouseup: commit start (preview still visible)', { + finalId, + finalWidth, + finalHeight, + }) + addToLocalComponentStore( finalId, drawShapeType ?? '', finalShapeData as unknown as ComponentRecord ) + // [perf] Stage 6 — store updated; React render is now scheduled. + // The rAF poll below measures how long until the element mounts. + perfLog('mouseup: addToLocalComponentStore() returned') + // React renders the element asynchronously; poll until it appears in two.scene.children, // then remove the preview so there is no blank gap between preview removal and final render. // Once the real group exists, auto-select it so the resize box and the edit toolbar appear // immediately — a visual cue to new users that the freshly drawn shape is editable. // eslint-disable-next-line @typescript-eslint/no-explicit-any const finishPlacement = (el?: any) => { + // [perf] Stage 8 — element is in two.scene; remove the dimmed + // preview and select the real shape. Gap between Stage 4 and + // here is the visible dimmed-shape / flicker window. + perfLog( + 'finishPlacement: poll detected real shape in scene → remove preview + select', + { found: !!el } + ) if (capturedPreview) { two.remove(capturedPreview) two.update() } if (el) selectionController.attach(el) + perfLog( + 'finishPlacement: done (preview removed, selection attached)' + ) } pollUntilElement(two, finalId, finishPlacement, { // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/utils/perfLog.ts b/src/utils/perfLog.ts new file mode 100644 index 0000000..b287847 --- /dev/null +++ b/src/utils/perfLog.ts @@ -0,0 +1,43 @@ +// ── Shape-draw perf tracing ── +// Temporary instrumentation to diagnose the "freshly drawn shape sits at +// reduced opacity / flickers for a couple seconds before committing" issue. +// +// It traces the full lifecycle of a single shape placement: +// toolbar select → addElement() arms pending shape → mousedown preview → +// mouseup commit → addToLocalComponentStore() → setComponentStore() → +// React mounts the element component → group enters two.scene → two.update() +// → pollUntilElement finds it → finishPlacement (preview removed + selected) +// +// Every line is tagged `[perf]` so it's trivial to grep and strip later. +// Flip PERF_LOG to false (or delete this file + its imports) to silence. + +const PERF_LOG = true + +let startTs: number | null = null +let lastTs: number | null = null + +// Reset the timeline. Call this at the very first user action (toolbar select) +// so every subsequent perfLog reports both cumulative and per-step elapsed time. +export function perfStart(label: string): void { + if (!PERF_LOG) return + startTs = performance.now() + lastTs = startTs + // eslint-disable-next-line no-console + console.log( + `[perf] ▶ ${label} — timeline start @ ${startTs.toFixed(1)}ms` + ) +} + +// Log a milestone with elapsed-since-start and elapsed-since-previous deltas. +export function perfLog(label: string, data?: Record): void { + if (!PERF_LOG) return + const now = performance.now() + const sinceStart = startTs != null ? (now - startTs).toFixed(1) : '—' + const sinceLast = lastTs != null ? (now - lastTs).toFixed(1) : '—' + lastTs = now + // eslint-disable-next-line no-console + console.log( + `[perf] ${label} · +${sinceStart}ms total / +${sinceLast}ms step`, + data ?? '' + ) +} diff --git a/src/views/Board/board.tsx b/src/views/Board/board.tsx index f1708be..f012bf6 100644 --- a/src/views/Board/board.tsx +++ b/src/views/Board/board.tsx @@ -32,6 +32,8 @@ import controlsIcon from '../../assets/controls.svg' import PermissionErrorModal from '../../components/modals/PermissionErrorModal' import StorageLimitModal from '../../components/modals/StorageLimitModal' import { generateUUID, generateRandomUsernames } from '../../utils/misc' +import { perfLog } from '../../utils/perfLog' +import { prefetchElementModule } from '../../elementModules' import { pollUntilElement, getShapeTextNodes, @@ -440,6 +442,31 @@ const BoardViewPage: React.FC = (props) => { isPersistedRef.current = isPersisted }, [isPersisted]) + // Warm the shape element chunks once the board is idle after mount, so even + // the very first shape arm finds its chunk already cached (the per-arm + // prefetch in primary.tsx covers the rest). Best-effort: gated on idle so + // it never competes with initial paint/board-load. + useEffect(() => { + const warm = (): void => { + prefetchElementModule('rectangle') + prefetchElementModule('circle') + prefetchElementModule('diamond') + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ric = (window as any).requestIdleCallback as + | ((cb: () => void, opts?: { timeout: number }) => number) + | undefined + if (ric) { + const handle = ric(warm, { timeout: 3000 }) + return (): void => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).cancelIdleCallback?.(handle) + } + } + const t = setTimeout(warm, 1500) + return (): void => clearTimeout(t) + }, []) + useEffect(() => { console.log('change in componentStore in Board', componentStore) stateRefForComponentStore.current = componentStore @@ -613,6 +640,13 @@ const BoardViewPage: React.FC = (props) => { componentInfo: ComponentRecord, skipHistory: boolean = false ) => { + // [perf] Stage 5 — store mutation entry (only traced for draw shapes). + const isDrawShape = + type === 'rectangle' || type === 'circle' || type === 'diamond' + if (isDrawShape) { + perfLog('addToLocalComponentStore(): entry', { id, type }) + } + // groupobject is a transient visual construct and must never be persisted if ( type === GROUP_COMPONENT || @@ -671,6 +705,12 @@ const BoardViewPage: React.FC = (props) => { stateRefForComponentStore.current = updatedComponentStore setComponentStore(updatedComponentStore) + // [perf] Stage 5b — setComponentStore() called. React will now schedule + // a render that mounts the element component (Stage 7). + if (isDrawShape) { + perfLog('addToLocalComponentStore(): setComponentStore() called') + } + if (isPersistedRef.current && safeInfo) { insertComponent({ variables: { object: safeInfo } }).catch( // eslint-disable-next-line @typescript-eslint/no-explicit-any From 604bbedce76f80406778d4b59d5a76cb8aff49aa Mon Sep 17 00:00:00 2001 From: meetzaveri Date: Sat, 13 Jun 2026 12:47:14 +0530 Subject: [PATCH 2/9] update: prefetch groupobject chunk and add perf logs for it as well --- src/components/elements/groupobject.tsx | 24 +++++++++++++++++++++ src/views/Board/board.tsx | 28 ++++++++++++++++++------- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/components/elements/groupobject.tsx b/src/components/elements/groupobject.tsx index ea8abdf..85e81a4 100644 --- a/src/components/elements/groupobject.tsx +++ b/src/components/elements/groupobject.tsx @@ -7,6 +7,7 @@ const factoryModules: Record Promise> = import Two from 'two.js' import { useBoardContext } from '../../views/Board/boardContext' import getEditComponents from '../utils/editWrapper' +import { perfLog } from '../../utils/perfLog' import { elementOnBlurHandler } from '../../utils/misc' import { DEFAULT_TEXT_FONT_FAMILY } from '../../constants/misc' @@ -362,6 +363,14 @@ function GroupedObjectWrapper(props: ElementProps): ReactElement { const prevX = props.x const prevY = props.y + // [perf] GroupObject mount — this only runs once the groupobject chunk + // has loaded. If the chunk was warmed during idle (see board.tsx warm + // list), this fires immediately on group-select; if cold, it fires only + // after the ~580ms (Slow 4G) fetch — the invisible-then-visible blink. + perfLog('GroupObject mount: chunk loaded → building group + selector', { + childCount: props.children?.length ?? 0, + }) + const rectangle = two.makeRectangle( 0, 0, @@ -381,12 +390,27 @@ function GroupedObjectWrapper(props: ElementProps): ReactElement { group.translation.y = parseInt(String(prevY)) || 200 two.update() + // [perf] Second waterfall — each member's FACTORY chunk is lazy-loaded + // here (groupobject has its own factory glob). On a cold cache these + // fetch one-by-one and members pop in as they resolve, which is the + // suspected source of the double-flicker. Watch the gap between this + // line and the per-child "factory resolved" logs below. + perfLog('GroupObject mount: selector ready → loading child factories', { + childCount: props.children?.length ?? 0, + }) + for (let index = 0; index < props.children.length; index++) { const item = props.children[index] const factoryKey = `../../factory/${item.componentType}.ts` const loader = factoryModules[factoryKey] if (typeof loader !== 'function') continue loader().then((component) => { + // [perf] A member's factory chunk resolved; it's now being + // added to the group + the group re-sorts + two.update() fires. + perfLog('GroupObject: child factory resolved → add + reorder', { + type: item.componentType, + index, + }) const componentFactory = new component.default( two, item.x, diff --git a/src/views/Board/board.tsx b/src/views/Board/board.tsx index f012bf6..6bd1a27 100644 --- a/src/views/Board/board.tsx +++ b/src/views/Board/board.tsx @@ -442,15 +442,29 @@ const BoardViewPage: React.FC = (props) => { isPersistedRef.current = isPersisted }, [isPersisted]) - // Warm the shape element chunks once the board is idle after mount, so even - // the very first shape arm finds its chunk already cached (the per-arm - // prefetch in primary.tsx covers the rest). Best-effort: gated on idle so - // it never competes with initial paint/board-load. + // Warm the core element chunks once the board is idle after mount, so the + // first use of any of them finds its chunk already cached (the per-arm + // prefetch in primary.tsx still covers the quick-draw race). Best-effort: + // gated on idle so it never competes with initial paint/board-load. + // + // `groupobject` is included because group-selection lazy-loads it on + // demand; without warming, the group can't mount until its ~580ms (Slow 4G) + // chunk arrives, leaving the selected elements invisible — the group-select + // "blink". Geo-only components (point/area/route/geoText/cluster) are left + // out; warm them separately if/when geo mode needs it. useEffect(() => { + const CORE_ELEMENT_CHUNKS = [ + 'rectangle', + 'circle', + 'diamond', + 'arrowLine', + 'divider', + 'pencil', + 'newText', + 'groupobject', + ] const warm = (): void => { - prefetchElementModule('rectangle') - prefetchElementModule('circle') - prefetchElementModule('diamond') + CORE_ELEMENT_CHUNKS.forEach(prefetchElementModule) } // eslint-disable-next-line @typescript-eslint/no-explicit-any const ric = (window as any).requestIdleCallback as From 380b48d763e8f1ece40504d3b0e24e4b578f46d0 Mon Sep 17 00:00:00 2001 From: meetzaveri Date: Sat, 13 Jun 2026 13:29:36 +0530 Subject: [PATCH 3/9] update: parallel load factory chunks + atomic commit to build all members of group selection --- src/components/elements/groupobject.tsx | 71 ++++++++++++++++--------- src/newCanvas.tsx | 14 ++--- 2 files changed, 55 insertions(+), 30 deletions(-) diff --git a/src/components/elements/groupobject.tsx b/src/components/elements/groupobject.tsx index 85e81a4..7b6df11 100644 --- a/src/components/elements/groupobject.tsx +++ b/src/components/elements/groupobject.tsx @@ -390,33 +390,42 @@ function GroupedObjectWrapper(props: ElementProps): ReactElement { group.translation.y = parseInt(String(prevY)) || 200 two.update() - // [perf] Second waterfall — each member's FACTORY chunk is lazy-loaded - // here (groupobject has its own factory glob). On a cold cache these - // fetch one-by-one and members pop in as they resolve, which is the - // suspected source of the double-flicker. Watch the gap between this - // line and the per-child "factory resolved" logs below. + // [perf] Load every member's FACTORY chunk IN PARALLEL, then add them + // all + hide the on-canvas originals in a SINGLE two.update() so the + // group-select swap is atomic. The old code loaded factories one-by-one + // and added members as each resolved (a per-child two.update each), + // while newCanvas hid the originals up-front — leaving a blank frame + // (the flicker) between "originals hidden" and "members painted". + // Factories are prefetched (board.tsx warm list), so Promise.all + // resolves on the next microtask on a warm cache. perfLog('GroupObject mount: selector ready → loading child factories', { childCount: props.children?.length ?? 0, }) - for (let index = 0; index < props.children.length; index++) { - const item = props.children[index] + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const loaders = props.children.map((item: any) => { const factoryKey = `../../factory/${item.componentType}.ts` const loader = factoryModules[factoryKey] - if (typeof loader !== 'function') continue - loader().then((component) => { - // [perf] A member's factory chunk resolved; it's now being - // added to the group + the group re-sorts + two.update() fires. - perfLog('GroupObject: child factory resolved → add + reorder', { - type: item.componentType, - index, + return typeof loader === 'function' + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + loader().then((mod: any) => ({ item, mod })) + : Promise.resolve(null) + }) + + Promise.all(loaders).then((resolved) => { + // [perf] All member factories resolved; build them, add to the + // group, then atomically hide the originals in one update below. + perfLog( + 'GroupObject: all child factories resolved → atomic add + hide', + { childCount: resolved.filter(Boolean).length } + ) + + resolved.forEach((entry) => { + if (!entry) return + const { item, mod } = entry + const componentFactory = new mod.default(two, item.x, item.y, { + ...item, }) - const componentFactory = new component.default( - two, - item.x, - item.y, - { ...item } - ) const factoryObject = componentFactory.createElement() const coreObject = factoryObject.group coreObject.translation.x = item.x @@ -440,12 +449,26 @@ function GroupedObjectWrapper(props: ElementProps): ReactElement { } coreObject.elementData = item - group.add(coreObject) - orderGroupChildrenByZ(group) - two.update() }) - } + orderGroupChildrenByZ(group) + + // Atomic swap: hide the on-canvas originals (group-SELECT only — + // `membersToHide` is unset for paste) in the SAME update that + // reveals the member copies, so there is never a blank frame. + const hideIds: string[] = Array.isArray(props.membersToHide) + ? props.membersToHide + : [] + if (hideIds.length) { + const hideSet = new Set(hideIds) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + two.scene.children.forEach((child: any) => { + if (hideSet.has(child?.elementData?.id)) child.opacity = 0 + }) + } + + two.update() + }) groupInstance = group diff --git a/src/newCanvas.tsx b/src/newCanvas.tsx index 6bd92eb..66b7b50 100644 --- a/src/newCanvas.tsx +++ b/src/newCanvas.tsx @@ -4085,12 +4085,14 @@ const Canvas: React.FC = (props) => { newGroup.children = newChildren - twoJSInstance.scene.children.forEach((child: any) => { - if (selectedComponentArr.includes(child?.elementData?.id)) { - child.opacity = 0 - twoJSInstance.update() - } - }) + // Defer hiding the originals to the group's own assembly so the + // swap is atomic — the group hides exactly these ids in the SAME + // two.update() that paints its member copies, so there is never a + // blank frame between "originals hidden" and "group copies drawn" + // (the residual group-select flicker). Only the group-SELECT path + // sets this; paste leaves it unset (its clones have no on-canvas + // originals to hide). + newGroup.membersToHide = [...selectedComponentArr] handleSetComponentsToRender([newGroup]) } From 4255bcb0fc5bb6339273fb15e2d39d69765da987 Mon Sep 17 00:00:00 2001 From: meetzaveri Date: Sat, 13 Jun 2026 17:18:28 +0530 Subject: [PATCH 4/9] update: deselect group on undo operation --- src/components/elements/groupobject.tsx | 360 +++++++++++++++--------- src/hooks/useCanvasClipboard.ts | 125 +++++++- src/hooks/useComponentHistory.ts | 10 + src/newCanvas.tsx | 1 + 4 files changed, 361 insertions(+), 135 deletions(-) diff --git a/src/components/elements/groupobject.tsx b/src/components/elements/groupobject.tsx index 7b6df11..ffad3d9 100644 --- a/src/components/elements/groupobject.tsx +++ b/src/components/elements/groupobject.tsx @@ -64,6 +64,10 @@ function GroupedObjectWrapper(props: ElementProps): ReactElement { useState(null) const [groupId, setGroupId] = useState(null) const isDeletingRef = useRef(false) + // Last group position already written to history. commitGroupMove() compares + // the live translation against this so a move is recorded exactly once, + // whether the commit is triggered by drag-end (mouseup) or blur. + const lastCommitPosRef = useRef<{ x: number; y: number } | null>(null) let groupInstance: ShapeLike = null let selectorInstance: ShapeLike = null @@ -71,157 +75,182 @@ function GroupedObjectWrapper(props: ElementProps): ReactElement { return element && two.scene.children.includes(element) } + // Tear down just the transient overlay (resize box + member copies) and drop + // the selection state — without touching the store. two.remove defers the + // SVG detach to the next update; if that throws (scene.subtractions pitfall, + // see CLAUDE.md) clear the stuck subtraction so future updates don't keep + // retrying the broken removal. + function dismissOverlayNode(): void { + selectorInstance?.hide?.() + window.dispatchEvent(new CustomEvent('groupBlurred')) + try { + two.remove([groupInstance]) + two.update() + } catch (err) { + console.warn('two.update() during group overlay teardown:', err) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const scene = two.scene as any + scene.subtractions.length = 0 + scene._flagSubtractions = false + } + } + + // Restore the (group-level) opacity of our member elements — they were + // hidden at 0 under the overlay. metadata may be a pencil vertex array, so + // guard the `.opacity` read. + function revealMembers(): void { + const childrenIds = props.children.map((i: ShapeLike) => i.id) + two.scene.children.forEach((element: ShapeLike) => { + if (!element.elementData) return + if (!childrenIds.includes(element.elementData.id)) return + const elMeta = element.elementData.metadata + element.opacity = + elMeta && !Array.isArray(elMeta) ? (elMeta.opacity ?? 1) : 1 + }) + } + + // Sync the (hidden) member elements to the overlay's current position and + // record the move to history as ONE batch. Idempotent: if the group hasn't + // moved since the last commit (lastCommitPosRef) it's a no-op, so calling it + // on BOTH drag-end (mouseup) and blur never double-records. Committing on + // drag-end is what makes a group move the last history entry — so undo + // reverts the move even while the group is still selected, instead of + // popping the previous action (e.g. a paste). + function commitGroupMove(): void { + if (isDeletingRef.current) return + if (!groupInstance || !isInScene(groupInstance)) return + + const gx = parseInt(String(groupInstance.translation.x)) + const gy = parseInt(String(groupInstance.translation.y)) + const baseline = lastCommitPosRef.current + if ( + baseline && + Math.abs(gx - baseline.x) < 0.5 && + Math.abs(gy - baseline.y) < 0.5 + ) { + return + } + + const userId = localStorage.getItem('userId') + const childrenIds = props.children.map((i: ShapeLike) => i.id) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const batchEntries: any[] = [] + + two.scene.children.forEach((element: ShapeLike) => { + if (!element.elementData) return + if (!childrenIds.includes(element.elementData.id)) return + + let relativeData: ShapeLike = {} + props.children.forEach((item: ShapeLike) => { + if (item.id === element.elementData.id) relativeData = item + }) + const newX = gx + parseInt(String(relativeData.x)) + const newY = gy + parseInt(String(relativeData.y)) + element.translation.x = newX + element.translation.y = newY + + let newMetadata = element.elementData.metadata + if ( + element.elementData.componentType === 'pencil' && + Array.isArray(element.elementData.metadata) + ) { + const m0 = element.elementData.metadata[0] + newMetadata = element.elementData.metadata.map( + (vert: ShapeLike, index: number) => { + const lwProp = + vert.lw !== undefined ? { lw: vert.lw } : {} + if (index === 0) { + return { x: newX, y: newY, ...lwProp } + } + return { + x: newX + parseInt(String(vert.x - m0.x)), + y: newY + parseInt(String(vert.y - m0.y)), + ...lwProp, + } + } + ) + element.children.forEach((eachChild: ShapeLike) => { + if (eachChild.vertices) { + eachChild.vertices = [] + newMetadata.forEach((point: ShapeLike) => { + eachChild.vertices.push( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new (Two as any).Anchor( + point.x - newX, + point.y - newY + ) + ) + }) + } + }) + } + + const childId = element.elementData.id + const current = (stateRefForComponentStore?.current?.[childId] ?? + {}) as ShapeLike + const updateObj = { + metadata: newMetadata, + x: newX, + y: newY, + updatedBy: userId, + } + const positionChanged = + current.x !== updateObj.x || current.y !== updateObj.y + const metadataChanged = newMetadata !== current.metadata + if (!positionChanged && !metadataChanged) return + + const prevProps = { + metadata: current.metadata, + x: current.x, + y: current.y, + updatedBy: current.updatedBy, + } + updateComponentBulkPropertiesInLocalStore(childId, updateObj, true) + batchEntries.push({ + action: 'UPDATE_BULK', + id: childId, + prevProps, + bulkObj: updateObj, + }) + }) + + if (batchEntries.length > 0) { + recordBatchToHistoryLog(batchEntries) + } + // Advance the baseline even if nothing recorded, so we don't re-scan on + // every subsequent mouseup at the same position. + lastCommitPosRef.current = { x: gx, y: gy } + two.update() + } + function onBlurHandler(e: FocusEvent): void { elementOnBlurHandler(e, selectorInstance, two) window.dispatchEvent(new CustomEvent('groupBlurred')) if (!isDeletingRef.current) { - const userId = localStorage.getItem('userId') + // Commit any pending move FIRST so the (hidden) originals are synced + // to the overlay's final position before we reveal them below. + // Idempotent with the drag-end commit, so this won't double-record. + commitGroupMove() + const childrenIdsOfTheGroup = props.children.map( (item: ShapeLike) => item.id ) - let foundOriginalCount = 0 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const blurBatchEntries: any[] = [] - - const initialGroupX = parseInt(String(props.x)) || 0 - const initialGroupY = parseInt(String(props.y)) || 0 - const groupMoved = - Math.abs(groupInstance.translation.x - initialGroupX) > 0.5 || - Math.abs(groupInstance.translation.y - initialGroupY) > 0.5 - two.scene.children.forEach((element: ShapeLike) => { if (!element.elementData) return if (childrenIdsOfTheGroup.includes(element.elementData.id)) { foundOriginalCount++ - // Restore the element's own (group-level) opacity rather than - // forcing 1 — it was hidden at 0 while the group was selected, - // and per-element opacity now lives on the group. metadata may - // be a pencil vertex array, so guard the `.opacity` read. + // Reveal the (now position-synced) original: restore its own + // group-level opacity — it was hidden at 0 under the overlay. + // metadata may be a pencil vertex array, so guard the read. const elMeta = element.elementData.metadata element.opacity = elMeta && !Array.isArray(elMeta) ? (elMeta.opacity ?? 1) : 1 - - if (!groupMoved) { - return - } - - let findRelativeDataForChild: ShapeLike = {} - props.children.forEach((item: ShapeLike) => { - if (item.id === element?.elementData?.id) { - findRelativeDataForChild = item - } - }) - const newX = - parseInt(String(groupInstance.translation.x)) + - parseInt(String(findRelativeDataForChild.x)) - const newY = - parseInt(String(groupInstance.translation.y)) + - parseInt(String(findRelativeDataForChild.y)) - element.translation.x = newX - element.translation.y = newY - - let newMetadata = element.elementData.metadata - if ( - element.elementData.componentType === 'pencil' && - Array.isArray(element.elementData.metadata) - ) { - newMetadata = element.elementData.metadata.map( - (vert: ShapeLike, index: number) => { - const lwProp = - vert.lw !== undefined - ? { lw: vert.lw } - : {} - if (index === 0) { - return { x: newX, y: newY, ...lwProp } - } - return { - x: - newX + - parseInt( - String( - vert.x - - element.elementData - .metadata[0].x - ) - ), - y: - newY + - parseInt( - String( - vert.y - - element.elementData - .metadata[0].y - ) - ), - ...lwProp, - } - } - ) - element.children.forEach((eachChild: ShapeLike) => { - if (eachChild.vertices) { - eachChild.vertices = [] - newMetadata.forEach(function ( - point: ShapeLike - ) { - eachChild.vertices.push( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - new (Two as any).Anchor( - point.x - newX, - point.y - newY - ) - ) - }) - } - }) - } - - const childId = element?.elementData?.id - const current = - stateRefForComponentStore?.current?.[childId] ?? {} - const updateObj = { - metadata: newMetadata, - x: element.translation.x, - y: element.translation.y, - updatedBy: userId, - } - - const c = current as ShapeLike - const positionChanged = - c.x !== updateObj.x || c.y !== updateObj.y - const metadataChanged = newMetadata !== c.metadata - - if (!positionChanged && !metadataChanged) { - two.update() - return - } - - const prevProps = { - metadata: c.metadata, - x: c.x, - y: c.y, - updatedBy: c.updatedBy, - } - updateComponentBulkPropertiesInLocalStore( - childId, - updateObj, - true - ) - blurBatchEntries.push({ - action: 'UPDATE_BULK', - id: childId, - prevProps, - bulkObj: updateObj, - }) - two.update() } }) - - if (blurBatchEntries.length > 0) { - recordBatchToHistoryLog(blurBatchEntries) - } + two.update() if (foundOriginalCount === 0 && props.children.length > 0) { const gx = parseInt(String(groupInstance.translation.x)) @@ -498,6 +527,13 @@ function GroupedObjectWrapper(props: ElementProps): ReactElement { ) two.update() + // Baseline for commitGroupMove — the group's initial position. A move + // is only recorded once it diverges from this (then it advances). + lastCommitPosRef.current = { + x: parseInt(String(group.translation.x)), + y: parseInt(String(group.translation.y)), + } + setGroupId(group.id) return (): void => { @@ -508,6 +544,66 @@ function GroupedObjectWrapper(props: ElementProps): ReactElement { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + // Commit the move on drag-end so it lands in history immediately (like a + // single shape's mouseup), making it the entry an undo reverts even while + // the group stays selected. commitGroupMove is a no-op unless the group + // actually moved, so this safely fires on every mouseup. + useEffect(() => { + const onMouseUp = (): void => commitGroupMove() + window.addEventListener('mouseup', onMouseUp, false) + return (): void => + window.removeEventListener('mouseup', onMouseUp, false) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Self-teardown when our members are removed out from under us — e.g. undo + // of a paste, which fires 'elementRemoved' per member via the history + // applyBatch. Without this the transient overlay (its member copies + the + // resize box) would linger over now-deleted shapes as a stale selection. + // We only dismiss the overlay here; the members are already being removed + // by whoever fired the event, so we never touch the store. The group's own + // Delete-key path sets isDeletingRef and owns its teardown, so we skip then. + useEffect(() => { + const memberIds = new Set( + (props.children ?? []) + .map((c: ShapeLike) => c?.id) + .filter(Boolean) + ) + const onMemberRemoved = ((e: CustomEvent<{ id: string }>): void => { + if (isDeletingRef.current) return + if (!memberIds.has(e.detail?.id)) return + // Already torn down (a sibling member fired first) — nothing to do. + if (!groupInstance || !isInScene(groupInstance)) return + + dismissOverlayNode() + // The overlay has done its job; drop the listener so dead overlays + // don't accumulate across repeated paste/undo cycles. + window.removeEventListener('elementRemoved', onMemberRemoved) + }) as EventListener + window.addEventListener('elementRemoved', onMemberRemoved) + return (): void => + window.removeEventListener('elementRemoved', onMemberRemoved) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // After an undo/redo, the overlay shows STATIC copies that can't reflect + // members the history just moved/re-added underneath it (applyBatch touches + // the real members, not the overlay's copies). Dismiss the overlay so the + // user sees the real, now-updated members: reveal their opacity, then drop + // the overlay. We don't commit here — the move was already reverted. + useEffect(() => { + const onHistoryApplied = (): void => { + if (isDeletingRef.current) return + if (!groupInstance || !isInScene(groupInstance)) return + revealMembers() + dismissOverlayNode() + } + window.addEventListener('historyApplied', onHistoryApplied) + return (): void => + window.removeEventListener('historyApplied', onHistoryApplied) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + useEffect(() => { const el = groupId ? document.getElementById(groupId) : null if (el) { diff --git a/src/hooks/useCanvasClipboard.ts b/src/hooks/useCanvasClipboard.ts index f2a82d4..7a19748 100644 --- a/src/hooks/useCanvasClipboard.ts +++ b/src/hooks/useCanvasClipboard.ts @@ -2,8 +2,13 @@ import { useEffect, useRef } from 'react' import type { MutableRefObject } from 'react' import { GROUP_COMPONENT, isStandaloneTextType } from '../constants/misc' import { generateUUID } from '../utils/misc' -import { cloneElementData, getShapeTextNodes } from '../utils/canvasUtils' +import { + cloneElementData, + getShapeTextNodes, + pollUntilElement, +} from '../utils/canvasUtils' import type { ComponentRecord } from '../types/board' +import type { HistoryEntry } from './useComponentHistory' // Two.js scene objects are typed loosely here; canvas-side typing converges // in Stages 7–9. @@ -41,8 +46,10 @@ export interface CanvasClipboardOptions { addToLocalComponentStore: ( id: string, componentType: string, - record: ComponentRecord + record: ComponentRecord, + skipHistory?: boolean ) => void + recordBatchToHistoryLog: (entries: HistoryEntry[]) => void renderGroupRef: MutableRefObject< ((groups: ComponentRecord[]) => void) | null > @@ -58,6 +65,7 @@ export function useCanvasClipboard({ zuiInstanceRef, boardId, addToLocalComponentStore, + recordBatchToHistoryLog, renderGroupRef, }: CanvasClipboardOptions): CanvasClipboardApi { const clipboardRef = useRef(null) @@ -256,6 +264,97 @@ export function useCanvasClipboard({ cloned.relativeY = rY return cloned }) + + // Persist the pasted members to the store IMMEDIATELY, at + // absolute coords (paste origin + each child's relative offset). + // Previously the children were only written on the group's + // blur-materialize (groupobject's foundOriginalCount===0 path), + // which meant a reload while the pasted group was still selected + // lost them — they lived only as transient overlay copies, never + // in componentStore / the localStorage draft. Persisting here + // makes paste reload-safe and lets the overlay below be a pure + // selection over real standalones (see membersToHide), so blur + // takes the restore-opacity path instead of re-materialising — + // killing the teardown→async-rebuild flicker too. + const memberIds: string[] = [] + // Record all member adds as ONE batch so a single undo removes + // the whole pasted group (not one shape per press). We pass + // skipHistory to addToLocalComponentStore and push an ADD entry + // per child, then commit them together via recordBatchToHistoryLog. + const pasteBatchEntries: HistoryEntry[] = [] + newChildren.forEach((child: ComponentRecord) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const c = child as any + const absX = px + (c.relativeX ?? 0) + const absY = py + (c.relativeY ?? 0) + + // pencil / geo area / route keep their geometry as an + // absolute {x,y} vertex array in metadata — not in x/y. The + // group child stored it in the group's relative space, so + // rebase the whole array to the standalone's absolute origin + // (mirrors groupobject's blur-materialize). Without this the + // pasted stroke renders near the origin instead of under the + // group — i.e. "the pencil strokes disappear". + let memberMetadata = c.metadata + if ( + (c.componentType === 'pencil' || + c.componentType === 'area' || + c.componentType === 'route') && + Array.isArray(c.metadata) + ) { + const meta = c.metadata as Array<{ + x: number + y: number + lw?: number + }> + const m0 = meta[0] ?? { x: 0, y: 0 } + memberMetadata = meta.map((vert, index) => { + const lwProp = + vert.lw !== undefined ? { lw: vert.lw } : {} + if (index === 0) { + return { x: absX, y: absY, ...lwProp } + } + return { + x: absX + Math.trunc(vert.x - m0.x), + y: absY + Math.trunc(vert.y - m0.y), + ...lwProp, + } + }) + } + + const memberData = { + ...c, + x: absX, + y: absY, + metadata: memberMetadata, + } + memberIds.push(c.id) + addToLocalComponentStore( + c.id, + c.componentType, + memberData, + true + ) + // The history entry's componentInfo must mirror the stored + // row: addToLocalComponentStore strips the transient + // relativeX/relativeY (not DB columns), so strip them here + // too — otherwise a redo in persisted mode would insert + // those non-schema fields and fail. + const { + relativeX: _rx, + relativeY: _ry, + ...storedShape + } = memberData + pasteBatchEntries.push({ + action: 'ADD', + id: c.id, + componentInfo: storedShape as ComponentRecord, + }) + }) + if (pasteBatchEntries.length > 0) { + recordBatchToHistoryLog(pasteBatchEntries) + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any const newGroup: any = { id: generateUUID(), @@ -268,8 +367,28 @@ export function useCanvasClipboard({ fill: null, stroke: null, children: newChildren, + // Hide the just-persisted standalones beneath the overlay in + // the same update that paints the group's copies (atomic + // swap — see groupobject.tsx). Because the standalones now + // exist in the scene, the group's blur handler takes the + // restore-opacity path (foundOriginalCount > 0) rather than + // re-materialising them: no double-write, no flicker. + membersToHide: memberIds, + } + + // Standalones mount asynchronously (React.lazy). Wait until the + // last one is in the scene before rendering the overlay so the + // group's atomic hide finds them — no brief double-paint of a + // standalone plus its overlay copy. Falls back to immediate + // render if there's nothing to wait on. + const lastId = memberIds[memberIds.length - 1] + if (lastId && twoJSInstance) { + pollUntilElement(twoJSInstance, lastId, () => { + renderGroupRef.current?.([newGroup]) + }) + } else { + renderGroupRef.current?.([newGroup]) } - renderGroupRef.current?.([newGroup]) } } window.addEventListener('keydown', onPasteEvent) diff --git a/src/hooks/useComponentHistory.ts b/src/hooks/useComponentHistory.ts index eeed201..3996282 100644 --- a/src/hooks/useComponentHistory.ts +++ b/src/hooks/useComponentHistory.ts @@ -852,6 +852,12 @@ export function useComponentHistory({ const updatedBucket = [...bucketLogRef.current, enrichedForRedo] writeBucket(updatedBucket) + + // An active group overlay shows static copies of its members, so it + // can't reflect an undo that moved/removed them underneath. Signal it to + // dismiss (reveal the now-updated real members + drop the overlay). Sent + // after applyBatch so members are already at their reverted state. + window.dispatchEvent(new CustomEvent('historyApplied')) } const redoLastAction = (): void => { @@ -892,6 +898,10 @@ export function useComponentHistory({ delete cleanEntry.nextProps const updatedLog = [...historyLogRef.current, cleanEntry as HistoryEntry] writeHistory(updatedLog) + + // See undoLastAction: dismiss any active group overlay so it can't show + // stale copies of members a redo just moved/re-added. + window.dispatchEvent(new CustomEvent('historyApplied')) } const clearHistory = ( diff --git a/src/newCanvas.tsx b/src/newCanvas.tsx index 66b7b50..3e94e94 100644 --- a/src/newCanvas.tsx +++ b/src/newCanvas.tsx @@ -3656,6 +3656,7 @@ const Canvas: React.FC = (props) => { zuiInstanceRef, boardId: props.boardId, addToLocalComponentStore, + recordBatchToHistoryLog, renderGroupRef, }) From d1b3bd0b134967d4f665349420b3f3ca48fb1b58 Mon Sep 17 00:00:00 2001 From: meetzaveri Date: Sat, 13 Jun 2026 17:51:58 +0530 Subject: [PATCH 5/9] update: remove scaffolding for performance testing (perf logs) --- src/components/elements/circle.tsx | 13 -------- src/components/elements/diamond.tsx | 13 -------- src/components/elements/groupobject.tsx | 36 +++++---------------- src/components/elements/rectangle.tsx | 16 --------- src/components/sidebar/primary.tsx | 19 +++-------- src/elementModules.ts | 25 +++----------- src/newCanvas.tsx | 30 ----------------- src/utils/perfLog.ts | 43 ------------------------- src/views/Board/board.tsx | 14 -------- 9 files changed, 16 insertions(+), 193 deletions(-) delete mode 100644 src/utils/perfLog.ts diff --git a/src/components/elements/circle.tsx b/src/components/elements/circle.tsx index f07d40b..aaa8d27 100644 --- a/src/components/elements/circle.tsx +++ b/src/components/elements/circle.tsx @@ -5,7 +5,6 @@ import { useBoardContext } from '../../views/Board/boardContext' import CircleFactory from '../../factory/circle' import { strokeTypeToDashes } from '../../utils/misc' import { applyShapeText } from '../../utils/canvasUtils' -import { perfLog } from '../../utils/perfLog' import { componentTypes } from '../../constants/misc' // Element components receive a fluid prop bag composed of the ComponentRecord @@ -30,13 +29,6 @@ function Circle(props: ElementProps): ReactElement { const prevX = props.x const prevY = props.y - // [perf] Stage 7 — see rectangle.tsx for the lifecycle map. - if (!props.parentGroup) { - perfLog('Circle mount: building group (React render → Two.js)', { - id: props.id, - }) - } - const elementFactory = new CircleFactory(two, prevX, prevY, { ...props, }) @@ -81,11 +73,6 @@ function Circle(props: ElementProps): ReactElement { String(props.linewidth ?? '') ) } - - // [perf] Stage 7b — group in scene, painted at full opacity. - perfLog('Circle mount: group in scene, two.update() done', { - id: props.id, - }) } return (): void => { diff --git a/src/components/elements/diamond.tsx b/src/components/elements/diamond.tsx index e6256aa..0a5529a 100644 --- a/src/components/elements/diamond.tsx +++ b/src/components/elements/diamond.tsx @@ -5,7 +5,6 @@ import { useBoardContext } from '../../views/Board/boardContext' import ElementFactory from '../../factory/diamond' import { strokeTypeToDashes } from '../../utils/misc' import { applyShapeText } from '../../utils/canvasUtils' -import { perfLog } from '../../utils/perfLog' import { componentTypes } from '../../constants/misc' // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -25,13 +24,6 @@ function Diamond(props: ElementProps): ReactElement { const prevX = props.x const prevY = props.y - // [perf] Stage 7 — see rectangle.tsx for the lifecycle map. - if (!props.parentGroup) { - perfLog('Diamond mount: building group (React render → Two.js)', { - id: props.id, - }) - } - const elementFactory = new ElementFactory(two, prevX, prevY, { ...props, }) @@ -74,11 +66,6 @@ function Diamond(props: ElementProps): ReactElement { String(props.linewidth ?? '') ) } - - // [perf] Stage 7b — group in scene, painted at full opacity. - perfLog('Diamond mount: group in scene, two.update() done', { - id: props.id, - }) } return (): void => { diff --git a/src/components/elements/groupobject.tsx b/src/components/elements/groupobject.tsx index ffad3d9..2644bb2 100644 --- a/src/components/elements/groupobject.tsx +++ b/src/components/elements/groupobject.tsx @@ -7,7 +7,6 @@ const factoryModules: Record Promise> = import Two from 'two.js' import { useBoardContext } from '../../views/Board/boardContext' import getEditComponents from '../utils/editWrapper' -import { perfLog } from '../../utils/perfLog' import { elementOnBlurHandler } from '../../utils/misc' import { DEFAULT_TEXT_FONT_FAMILY } from '../../constants/misc' @@ -392,14 +391,6 @@ function GroupedObjectWrapper(props: ElementProps): ReactElement { const prevX = props.x const prevY = props.y - // [perf] GroupObject mount — this only runs once the groupobject chunk - // has loaded. If the chunk was warmed during idle (see board.tsx warm - // list), this fires immediately on group-select; if cold, it fires only - // after the ~580ms (Slow 4G) fetch — the invisible-then-visible blink. - perfLog('GroupObject mount: chunk loaded → building group + selector', { - childCount: props.children?.length ?? 0, - }) - const rectangle = two.makeRectangle( 0, 0, @@ -419,18 +410,14 @@ function GroupedObjectWrapper(props: ElementProps): ReactElement { group.translation.y = parseInt(String(prevY)) || 200 two.update() - // [perf] Load every member's FACTORY chunk IN PARALLEL, then add them - // all + hide the on-canvas originals in a SINGLE two.update() so the - // group-select swap is atomic. The old code loaded factories one-by-one - // and added members as each resolved (a per-child two.update each), - // while newCanvas hid the originals up-front — leaving a blank frame - // (the flicker) between "originals hidden" and "members painted". - // Factories are prefetched (board.tsx warm list), so Promise.all - // resolves on the next microtask on a warm cache. - perfLog('GroupObject mount: selector ready → loading child factories', { - childCount: props.children?.length ?? 0, - }) - + // Load every member's FACTORY chunk IN PARALLEL, then add them all + + // hide the on-canvas originals in a SINGLE two.update() so the + // group-select swap is atomic. Loading factories one-by-one and adding + // members as each resolved (a per-child two.update each), while newCanvas + // hid the originals up-front, left a blank frame (the flicker) between + // "originals hidden" and "members painted". Factories are prefetched + // (board.tsx warm list), so Promise.all resolves on the next microtask + // on a warm cache. // eslint-disable-next-line @typescript-eslint/no-explicit-any const loaders = props.children.map((item: any) => { const factoryKey = `../../factory/${item.componentType}.ts` @@ -442,13 +429,6 @@ function GroupedObjectWrapper(props: ElementProps): ReactElement { }) Promise.all(loaders).then((resolved) => { - // [perf] All member factories resolved; build them, add to the - // group, then atomically hide the originals in one update below. - perfLog( - 'GroupObject: all child factories resolved → atomic add + hide', - { childCount: resolved.filter(Boolean).length } - ) - resolved.forEach((entry) => { if (!entry) return const { item, mod } = entry diff --git a/src/components/elements/rectangle.tsx b/src/components/elements/rectangle.tsx index 5d49523..d147005 100644 --- a/src/components/elements/rectangle.tsx +++ b/src/components/elements/rectangle.tsx @@ -5,7 +5,6 @@ import { useBoardContext } from '../../views/Board/boardContext' import ElementFactory from '../../factory/rectangle' import { strokeTypeToDashes } from '../../utils/misc' import { applyShapeText } from '../../utils/canvasUtils' -import { perfLog } from '../../utils/perfLog' import { componentTypes } from '../../constants/misc' // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -25,15 +24,6 @@ function Rectangle(props: ElementProps): ReactElement { const prevX = props.x const prevY = props.y - // [perf] Stage 7 — React mounted the Rectangle component and is now - // building the Two.js group. Only top-level draws (no parentGroup) are - // the freshly-drawn-shape path we're tracing. - if (!props.parentGroup) { - perfLog('Rectangle mount: building group (React render → Two.js)', { - id: props.id, - }) - } - const elementFactory = new ElementFactory(two, prevX, prevY, { ...props, }) @@ -78,12 +68,6 @@ function Rectangle(props: ElementProps): ReactElement { String(props.linewidth ?? '') ) } - - // [perf] Stage 7b — group is in two.scene and two.update() painted - // it at full opacity. The poll in mouseup will detect it next frame. - perfLog('Rectangle mount: group in scene, two.update() done', { - id: props.id, - }) } return (): void => { diff --git a/src/components/sidebar/primary.tsx b/src/components/sidebar/primary.tsx index ebe4447..2be68ce 100644 --- a/src/components/sidebar/primary.tsx +++ b/src/components/sidebar/primary.tsx @@ -6,7 +6,6 @@ import ShapesToolbar from './shapesToolbar' import { GET_COMPONENT_TYPES } from '../../schema/queries' import SpinnerWithSize from '../common/spinnerWithSize' import { generateUUID } from '../../utils/misc' -import { perfStart, perfLog } from '../../utils/perfLog' import { prefetchElementModule } from '../../elementModules' import { useBoardContext } from '../../views/Board/boardContext' import { useMediaQueryUtils } from '../../constants/exportHooks' @@ -306,14 +305,11 @@ const PrimarySidebar = (): ReactElement => { } const addElement = (label: string, category?: string): void => { - // [perf] Stage 1 — the toolbar-select entry point. Reset the timeline - // for shape draws so every later milestone is measured from here. + // Warm the shape's lazy chunk NOW, while the user moves to the canvas + // and drags (~700ms–1s). By mouseup the chunk is cached, so the + // component mounts instantly instead of the freshly-drawn shape sitting + // dimmed during a first-time network fetch on prod. if (DRAW_SHAPE_TYPES.includes(label)) { - perfStart(`addElement() toolbar select: ${label}`) - // Warm the shape's lazy chunk NOW, while the user moves to the - // canvas and drags (~700ms–1s per the perf traces). By mouseup the - // chunk is cached, so the component mounts instantly instead of the - // freshly-drawn shape sitting dimmed during a first-time fetch. prefetchElementModule(label) } cancelPendingElement() @@ -493,13 +489,6 @@ const PrimarySidebar = (): ReactElement => { ) const root = document.getElementById('main-two-root') if (root) root.style.cursor = 'crosshair' - // [perf] Stage 2 — pending shape armed, cursor crosshair. - // Canvas now waits for mousedown/mouseup to draw it. (Note: - // the hint button above is shown via a 100ms setTimeout.) - perfLog('addElement() armed pendingShape + crosshair', { - label, - id: generateId, - }) } else { updateLastAddedElement(shapeData) localStorage.setItem('lastAddedElementId', generateId) diff --git a/src/elementModules.ts b/src/elementModules.ts index 8adba05..922a41e 100644 --- a/src/elementModules.ts +++ b/src/elementModules.ts @@ -10,8 +10,6 @@ // e.g. './components/elements/circle.tsx') matches newCanvas's original glob // verbatim — newCanvas keys into this map with that exact string. -import { perfLog } from './utils/perfLog' - export const elementModules = import.meta.glob('./components/elements/*.tsx') // Idempotent prefetch: kicks off (and caches) the dynamic import for a shape @@ -24,23 +22,8 @@ export function prefetchElementModule(componentType: string): void { const key = `./components/elements/${componentType}.tsx` const loader = elementModules[key] if (!loader) return - if (inFlight.has(key)) { - // [perf] Already warming/warm — the mount will be instant. - perfLog(`prefetch: ${componentType} chunk already warm`) - return - } - // [perf] Cold chunk — fetch starts now. On prod this is the network hit - // that, before prefetching, blocked the post-mouseup mount. - perfLog(`prefetch: ${componentType} chunk fetch START`) - const p = loader() - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .then((m: any) => { - // [perf] Chunk is now cached; React.lazy will resolve immediately. - perfLog(`prefetch: ${componentType} chunk fetch DONE (warm)`) - return m - }) - // Best-effort warm-up — the real load path surfaces genuine failures - // via its own Suspense/error boundary, so swallow here. - .catch(() => undefined) - inFlight.set(key, p) + if (inFlight.has(key)) return + // Best-effort warm-up — the real load path surfaces genuine failures via + // its own Suspense/error boundary, so swallow here. + inFlight.set(key, loader().catch(() => undefined)) } diff --git a/src/newCanvas.tsx b/src/newCanvas.tsx index 3e94e94..fd6621a 100644 --- a/src/newCanvas.tsx +++ b/src/newCanvas.tsx @@ -89,7 +89,6 @@ import { shapeTextStyleFromMeta, } from './utils/canvasUtils' import { growShapeToFitText, usableTextWidth } from './utils/shapeTextFit' -import { perfLog } from './utils/perfLog' import { isSelectPanMode, isPanMode } from './utils/drawModeUtils' import { createDiamondPath } from './factory/diamond' import { useCanvasClipboard } from './hooks/useCanvasClipboard' @@ -1842,13 +1841,6 @@ function addZUI( two.update() } - // [perf] Stage 3 — mousedown: preview shape created at reduced - // opacity. This is the dimmed shape the user sees while drawing. - perfLog('mousedown: preview shape created (dimmed)', { - drawShapeType, - opacity: DEFAULT_PREVIEW_OPACITY, - }) - domElement.addEventListener('mousemove', mousemove, false) domElement.addEventListener('mouseup', mouseup, false) setRootCursor('crosshair') @@ -2732,45 +2724,23 @@ function addZUI( height: finalHeight, } - // [perf] Stage 4 — mouseup: gesture done, about to commit to - // the React store. Preview is still on screen at this point. - perfLog('mouseup: commit start (preview still visible)', { - finalId, - finalWidth, - finalHeight, - }) - addToLocalComponentStore( finalId, drawShapeType ?? '', finalShapeData as unknown as ComponentRecord ) - // [perf] Stage 6 — store updated; React render is now scheduled. - // The rAF poll below measures how long until the element mounts. - perfLog('mouseup: addToLocalComponentStore() returned') - // React renders the element asynchronously; poll until it appears in two.scene.children, // then remove the preview so there is no blank gap between preview removal and final render. // Once the real group exists, auto-select it so the resize box and the edit toolbar appear // immediately — a visual cue to new users that the freshly drawn shape is editable. // eslint-disable-next-line @typescript-eslint/no-explicit-any const finishPlacement = (el?: any) => { - // [perf] Stage 8 — element is in two.scene; remove the dimmed - // preview and select the real shape. Gap between Stage 4 and - // here is the visible dimmed-shape / flicker window. - perfLog( - 'finishPlacement: poll detected real shape in scene → remove preview + select', - { found: !!el } - ) if (capturedPreview) { two.remove(capturedPreview) two.update() } if (el) selectionController.attach(el) - perfLog( - 'finishPlacement: done (preview removed, selection attached)' - ) } pollUntilElement(two, finalId, finishPlacement, { // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/utils/perfLog.ts b/src/utils/perfLog.ts deleted file mode 100644 index b287847..0000000 --- a/src/utils/perfLog.ts +++ /dev/null @@ -1,43 +0,0 @@ -// ── Shape-draw perf tracing ── -// Temporary instrumentation to diagnose the "freshly drawn shape sits at -// reduced opacity / flickers for a couple seconds before committing" issue. -// -// It traces the full lifecycle of a single shape placement: -// toolbar select → addElement() arms pending shape → mousedown preview → -// mouseup commit → addToLocalComponentStore() → setComponentStore() → -// React mounts the element component → group enters two.scene → two.update() -// → pollUntilElement finds it → finishPlacement (preview removed + selected) -// -// Every line is tagged `[perf]` so it's trivial to grep and strip later. -// Flip PERF_LOG to false (or delete this file + its imports) to silence. - -const PERF_LOG = true - -let startTs: number | null = null -let lastTs: number | null = null - -// Reset the timeline. Call this at the very first user action (toolbar select) -// so every subsequent perfLog reports both cumulative and per-step elapsed time. -export function perfStart(label: string): void { - if (!PERF_LOG) return - startTs = performance.now() - lastTs = startTs - // eslint-disable-next-line no-console - console.log( - `[perf] ▶ ${label} — timeline start @ ${startTs.toFixed(1)}ms` - ) -} - -// Log a milestone with elapsed-since-start and elapsed-since-previous deltas. -export function perfLog(label: string, data?: Record): void { - if (!PERF_LOG) return - const now = performance.now() - const sinceStart = startTs != null ? (now - startTs).toFixed(1) : '—' - const sinceLast = lastTs != null ? (now - lastTs).toFixed(1) : '—' - lastTs = now - // eslint-disable-next-line no-console - console.log( - `[perf] ${label} · +${sinceStart}ms total / +${sinceLast}ms step`, - data ?? '' - ) -} diff --git a/src/views/Board/board.tsx b/src/views/Board/board.tsx index 6bd1a27..89f85ca 100644 --- a/src/views/Board/board.tsx +++ b/src/views/Board/board.tsx @@ -32,7 +32,6 @@ import controlsIcon from '../../assets/controls.svg' import PermissionErrorModal from '../../components/modals/PermissionErrorModal' import StorageLimitModal from '../../components/modals/StorageLimitModal' import { generateUUID, generateRandomUsernames } from '../../utils/misc' -import { perfLog } from '../../utils/perfLog' import { prefetchElementModule } from '../../elementModules' import { pollUntilElement, @@ -654,13 +653,6 @@ const BoardViewPage: React.FC = (props) => { componentInfo: ComponentRecord, skipHistory: boolean = false ) => { - // [perf] Stage 5 — store mutation entry (only traced for draw shapes). - const isDrawShape = - type === 'rectangle' || type === 'circle' || type === 'diamond' - if (isDrawShape) { - perfLog('addToLocalComponentStore(): entry', { id, type }) - } - // groupobject is a transient visual construct and must never be persisted if ( type === GROUP_COMPONENT || @@ -719,12 +711,6 @@ const BoardViewPage: React.FC = (props) => { stateRefForComponentStore.current = updatedComponentStore setComponentStore(updatedComponentStore) - // [perf] Stage 5b — setComponentStore() called. React will now schedule - // a render that mounts the element component (Stage 7). - if (isDrawShape) { - perfLog('addToLocalComponentStore(): setComponentStore() called') - } - if (isPersistedRef.current && safeInfo) { insertComponent({ variables: { object: safeInfo } }).catch( // eslint-disable-next-line @typescript-eslint/no-explicit-any From be4e5114f453e86aa9c2c34e243397e19d8cf166 Mon Sep 17 00:00:00 2001 From: meetzaveri Date: Sat, 13 Jun 2026 18:42:26 +0530 Subject: [PATCH 6/9] update: gate port-connectors behind feature flag --- src/assets/settings.svg | 1 + src/canvas/selectionController.ts | 12 ++++-- src/components/common/toggleSwitch.tsx | 46 +++++++++++++++++++++++ src/components/sidebar/menuDrawer.tsx | 30 +++++++++++++++ src/components/sidebar/settingsModal.tsx | 48 ++++++++++++++++++++++++ src/constants/misc.ts | 5 +++ src/hooks/useConnectorsEnabled.ts | 17 +++++++++ src/newCanvas.tsx | 20 ++++++++++ src/utils/featureFlags.ts | 46 +++++++++++++++++++++++ 9 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 src/assets/settings.svg create mode 100644 src/components/common/toggleSwitch.tsx create mode 100644 src/components/sidebar/settingsModal.tsx create mode 100644 src/hooks/useConnectorsEnabled.ts create mode 100644 src/utils/featureFlags.ts diff --git a/src/assets/settings.svg b/src/assets/settings.svg new file mode 100644 index 0000000..e82bc96 --- /dev/null +++ b/src/assets/settings.svg @@ -0,0 +1 @@ + Settings \ No newline at end of file diff --git a/src/canvas/selectionController.ts b/src/canvas/selectionController.ts index 1e1b9a0..1314978 100644 --- a/src/canvas/selectionController.ts +++ b/src/canvas/selectionController.ts @@ -6,6 +6,7 @@ import { shapeTextStyleFromMeta, } from '../utils/canvasUtils' import { reflowTextForShape, minShapeWidthForText } from '../utils/shapeTextFit' +import { getConnectorsEnabled } from '../utils/featureFlags' // Two.js scene shapes carry codebase-specific bookkeeping (elementData, // _renderer, etc.) outside the published types. Stay loose here; Stage 12 @@ -557,8 +558,10 @@ export default class SelectionController { const isRect = this.currentGroup?.elementData?.componentType === 'rectangle' - this.portHandles.visible = isRect - if (isRect) { + // Ports only render when the connectors feature flag is on (live). + const portsOn = isRect && getConnectorsEnabled() + this.portHandles.visible = portsOn + if (portsOn) { const hw = (width + pad * 2) / 2 const hh = (height + pad * 2) / 2 this._halfW = hw @@ -639,7 +642,7 @@ export default class SelectionController { surface: { x: number; y: number }, targetGroup?: ShapeLike ): void { - if (!this.portGlow) return + if (!this.portGlow || !getConnectorsEnabled()) return const scale = this.zui.scale || 1 this.portGlow.position.set(surface.x, surface.y) this.portGlow.scale = 1 / scale @@ -841,6 +844,9 @@ export default class SelectionController { // Edge name (n/e/s/w-resize) whose port the surface point is hovering, or // null. Rectangle-only; this is what the port arrow keys off of. private _hoveredPortEdge(point: { x: number; y: number }): string | null { + // Single chokepoint for both hover (port arrow) and `hitTestPort` + // (pull-out). Off when connectors are disabled. + if (!getConnectorsEnabled()) return null if (this.currentGroup?.elementData?.componentType !== 'rectangle') { return null } diff --git a/src/components/common/toggleSwitch.tsx b/src/components/common/toggleSwitch.tsx new file mode 100644 index 0000000..4a5eabf --- /dev/null +++ b/src/components/common/toggleSwitch.tsx @@ -0,0 +1,46 @@ +import type { ReactElement } from 'react' + +interface ToggleSwitchProps { + checked: boolean + onChange: (checked: boolean) => void + id?: string + label?: string + disabled?: boolean +} + +// A small accessible on/off switch. Uses the amber `accent` token when on and a +// neutral track when off; the knob slides between the two ends. +const ToggleSwitch = ({ + checked, + onChange, + id, + label, + disabled = false, +}: ToggleSwitchProps): ReactElement => ( + +) + +export default ToggleSwitch diff --git a/src/components/sidebar/menuDrawer.tsx b/src/components/sidebar/menuDrawer.tsx index a7bc092..90abc38 100644 --- a/src/components/sidebar/menuDrawer.tsx +++ b/src/components/sidebar/menuDrawer.tsx @@ -6,6 +6,8 @@ import { useBoardContext } from '../../views/Board/boardContext' import { downloadViewportAsImage } from '../../utils/exportViewport' import Modal from '../common/modal' import Button from '../common/button' +import SettingsModal from './settingsModal' +import settingsIcon from '../../assets/settings.svg' const HamburgerIcon = (): ReactElement => ( { const refNode = useRef(null) const [showMenu, setShowMenu] = useState(false) const [showConfirm, setShowConfirm] = useState(false) + const [showSettings, setShowSettings] = useState(false) const [isExporting, setIsExporting] = useState(false) const { clearBoard } = useBoardContext() @@ -112,6 +115,11 @@ const MenuDrawer = (): ReactElement => { setShowConfirm(true) } + const handleSettingsClick = (): void => { + setShowMenu(false) + setShowSettings(true) + } + const handleDownloadClick = async (): Promise => { setShowMenu(false) try { @@ -269,6 +277,23 @@ const MenuDrawer = (): ReactElement => {
+ + +
+
+ + ) +} + +export default SettingsModal diff --git a/src/constants/misc.ts b/src/constants/misc.ts index 5f12ea1..39a0a7a 100644 --- a/src/constants/misc.ts +++ b/src/constants/misc.ts @@ -199,6 +199,11 @@ export const STORAGE_QUOTA_ERROR_NAME = 'QuotaExceededError' // never seed again for this browser profile. export const WELCOME_DISMISSED_KEY = 'craftbase_welcome_dismissed' +// Feature-flag preference: connectable arrows / shape edge ports. User-toggled +// in the Settings modal, persisted in localStorage, read live (see +// `src/utils/featureFlags.ts`). Defaults to enabled. +export const CONNECTORS_ENABLED_KEY = 'craftbase_connectors_enabled' + // Canvas rendering constants export const HOVER_THRESHOLD = 15 export const HOVER_COLOR = 'rgba(196, 144, 26, 0.7)' diff --git a/src/hooks/useConnectorsEnabled.ts b/src/hooks/useConnectorsEnabled.ts new file mode 100644 index 0000000..d7ca47f --- /dev/null +++ b/src/hooks/useConnectorsEnabled.ts @@ -0,0 +1,17 @@ +import { useState, useEffect } from 'react' +import { + getConnectorsEnabled, + setConnectorsEnabled, + subscribeConnectorsEnabled, +} from '../utils/featureFlags' + +// React binding for the live connectors feature flag. Returns the current value +// and a setter; re-renders when the flag changes from anywhere (other tabs of +// the same component, the Settings modal, etc.). +export function useConnectorsEnabled(): [boolean, (enabled: boolean) => void] { + const [enabled, setEnabled] = useState(getConnectorsEnabled) + + useEffect(() => subscribeConnectorsEnabled(setEnabled), []) + + return [enabled, setConnectorsEnabled] +} diff --git a/src/newCanvas.tsx b/src/newCanvas.tsx index fd6621a..f109444 100644 --- a/src/newCanvas.tsx +++ b/src/newCanvas.tsx @@ -72,6 +72,10 @@ import { PORT_TAIL_STACK_GAP, } from './utils/shapePorts' import { generateUUID } from './utils/misc' +import { + getConnectorsEnabled, + subscribeConnectorsEnabled, +} from './utils/featureFlags' import { velocityToLinewidth, smoothLinewidth, @@ -591,6 +595,15 @@ function addZUI( activeGroupRef.current = null }) + // Connectors flag is live-toggleable from Settings. When it flips, re-sync + // the current selection box so its edge ports appear/disappear immediately + // (rather than waiting for the next transform). If the flag goes off mid + // arrow-drag, clear any lingering radar glow too. + subscribeConnectorsEnabled((enabled) => { + if (!enabled) selectionController.hidePortGlow() + selectionController.resync() + }) + function dblclick(e: MouseEvent) { // In a multi-click geo draw, a double-click finishes it. Drop the // duplicate vertex the second mousedown added. @@ -1127,6 +1140,8 @@ function addZUI( dragContext: PortDragContext | null = null, excludeShapeId: string | null = arrowDrawTailShapeId ) { + // No port snapping/glow while connectors are disabled (live flag). + if (!getConnectorsEnabled()) return const threshold = PORT_RADAR_RADIUS / (zui.scale || 1) const nearest = findNearestPort( two.scene.children, @@ -1313,6 +1328,10 @@ function addZUI( // the free endpoint fixed in surface space. // eslint-disable-next-line @typescript-eslint/no-explicit-any function reanchorArrowsForShape(group: any) { + // Bound arrows only track their shape while connectors are enabled. When + // off, existing bindings lie dormant (the arrow stays put) rather than + // being stripped — flip the flag back on to resume gluing. + if (!getConnectorsEnabled()) return const shapeId = group?.elementData?.id if (!shapeId) return // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -1378,6 +1397,7 @@ function addZUI( // moves), and isLineCircle drives the x1/y1/x2/y2 write. // eslint-disable-next-line @typescript-eslint/no-explicit-any function persistBoundArrows(group: any) { + if (!getConnectorsEnabled()) return const shapeId = group?.elementData?.id if (!shapeId) return // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/utils/featureFlags.ts b/src/utils/featureFlags.ts new file mode 100644 index 0000000..bdc7ee4 --- /dev/null +++ b/src/utils/featureFlags.ts @@ -0,0 +1,46 @@ +import { CONNECTORS_ENABLED_KEY } from '../constants/misc' + +// Live, user-toggleable feature flags backed by localStorage. +// +// Unlike build-time `BoardProps` flags (e.g. `geoObjectsEnabled`), these are +// edited at runtime from the Settings modal and must take effect on the +// already-running app. The value is cached in a module-level variable so the +// hot Two.js paths (selection-box render, hover hit-test, arrow radar — all +// living inside `addZUI`'s stale-closure DOM handlers) can read it cheaply and +// live via `getConnectorsEnabled()` without re-binding listeners or hitting +// localStorage every frame. React UI subscribes via `useConnectorsEnabled`. + +// Opt-in feature: default OFF. Users enable connectors from the Settings modal. +const DEFAULT_CONNECTORS_ENABLED = false + +type Listener = (enabled: boolean) => void + +let cached: boolean | null = null +const listeners = new Set() + +export function getConnectorsEnabled(): boolean { + if (cached === null) { + const stored = localStorage.getItem(CONNECTORS_ENABLED_KEY) + cached = stored === null ? DEFAULT_CONNECTORS_ENABLED : stored === 'true' + } + return cached +} + +export function setConnectorsEnabled(enabled: boolean): void { + cached = enabled + try { + localStorage.setItem(CONNECTORS_ENABLED_KEY, String(enabled)) + } catch { + // Persistence is best-effort; an in-memory toggle still works for the + // current session even if storage is full/blocked. + } + listeners.forEach((fn) => fn(enabled)) +} + +// Subscribe to live changes. Returns an unsubscribe fn. +export function subscribeConnectorsEnabled(fn: Listener): () => void { + listeners.add(fn) + return (): void => { + listeners.delete(fn) + } +} From 0c4da919f9fa6eee6e44f37ac485fb1341353a73 Mon Sep 17 00:00:00 2001 From: meetzaveri Date: Sun, 14 Jun 2026 14:51:34 +0530 Subject: [PATCH 7/9] update: fix text selection bug + add sitemap + change essential shades --- README.md | 2 +- public/robots.txt | 2 + public/sitemap.xml | 33 ++ src/App.tsx | 5 + src/assets/settings.svg | 2 +- src/canvas/selectionController.ts | 158 +++++++++- src/components/elements/groupobject.tsx | 14 + src/components/elements/newText.tsx | 399 +++++++++--------------- src/components/sidebar/menuDrawer.tsx | 37 ++- src/newCanvas.tsx | 120 +++++-- src/routes.ts | 1 + src/utils/canvasUtils.ts | 106 +++++++ src/utils/constants.ts | 12 +- src/views/Board/board.tsx | 42 ++- src/views/Embeddable/embeddable.tsx | 236 ++++++++++++++ src/views/Embeddable/errorBoundary.tsx | 34 ++ src/views/Embeddable/index.tsx | 15 + tests/e2e/copy-paste.spec.js | 6 +- tests/e2e/group-apply-property.spec.js | 2 +- 19 files changed, 895 insertions(+), 331 deletions(-) create mode 100644 public/sitemap.xml create mode 100644 src/views/Embeddable/embeddable.tsx create mode 100644 src/views/Embeddable/errorBoundary.tsx create mode 100644 src/views/Embeddable/index.tsx diff --git a/README.md b/README.md index f15328a..33f8a66 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Craftbase -A minimal whiteboard you can open and start drawing on. No signup, no setup, no empty-state tutorial to click through — the canvas is just there waiting. +A minimal online whiteboard you can open and start drawing on. No signup, no setup, no empty-state tutorial to click through — the canvas is just there waiting. **Try it: [craftbase.org](https://craftbase.org)** diff --git a/public/robots.txt b/public/robots.txt index e9e57dc..f7353e9 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,3 +1,5 @@ # https://www.robotstxt.org/robotstxt.html User-agent: * Disallow: + +Sitemap: https://craftbase.org/sitemap.xml diff --git a/public/sitemap.xml b/public/sitemap.xml new file mode 100644 index 0000000..7293bc8 --- /dev/null +++ b/public/sitemap.xml @@ -0,0 +1,33 @@ + + + + https://craftbase.org/ + 2026-06-14 + weekly + 1.0 + + + https://craftbase.org/home + 2026-06-14 + monthly + 0.8 + + + https://craftbase.org/embeddable-whiteboard + 2026-06-14 + monthly + 0.9 + + + https://craftbase.org/support + 2026-06-14 + monthly + 0.5 + + + https://craftbase.org/privacy + 2026-06-14 + yearly + 0.3 + + diff --git a/src/App.tsx b/src/App.tsx index 17e8c1d..29e6ae4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import BoardViewContainer from './views/Board' import HomePageViewContainer from './views/Home' import SupportViewContainer from './views/Support' import PrivacyViewContainer from './views/Privacy' +import EmbeddableViewContainer from './views/Embeddable' import CraftbaseLoader from './components/common/craftbaseLoader' import routes from './routes' @@ -171,6 +172,10 @@ class App extends Component { path={routes.privacy} element={} /> + } + />
diff --git a/src/assets/settings.svg b/src/assets/settings.svg index e82bc96..e72174e 100644 --- a/src/assets/settings.svg +++ b/src/assets/settings.svg @@ -1 +1 @@ - Settings \ No newline at end of file + Settings \ No newline at end of file diff --git a/src/canvas/selectionController.ts b/src/canvas/selectionController.ts index 1314978..6cdb3ca 100644 --- a/src/canvas/selectionController.ts +++ b/src/canvas/selectionController.ts @@ -4,8 +4,15 @@ import { getShapeTextNodes, renderShapeTextLayer, shapeTextStyleFromMeta, + syncTextHitRect, } from '../utils/canvasUtils' import { reflowTextForShape, minShapeWidthForText } from '../utils/shapeTextFit' +import { + lineHeightFor, + measureTextWidth, + type FontSpec, +} from '../utils/textLayout' +import { DEFAULT_TEXT_FONT_FAMILY } from '../constants/misc' import { getConnectorsEnabled } from '../utils/featureFlags' // Two.js scene shapes carry codebase-specific bookkeeping (elementData, @@ -26,6 +33,40 @@ interface ShapeAdapter { resizable: boolean minWidth: number minHeight: number + // 'dimension' (default) → corner drag changes width/height. 'font' → corner + // drag scales the font size of a standalone text block (no w/h change). The + // box still tracks the rendered block via getLocalSize. + resizeMode?: 'dimension' | 'font' +} + +// Font spec for a single standalone text line node. +function textNodeFontSpec(node: ShapeLike): FontSpec { + return { + family: node?.family || DEFAULT_TEXT_FONT_FAMILY, + size: node?.size || 36, + weight: node?.weight, + } +} + +// Surface-unit size of a standalone text block: widest measured line × the +// stacked line height. measureTextWidth returns surface units (same space as a +// shape's width), so this feeds the selection box directly — no screen↔surface +// conversion needed. +function textBlockLocalSize(group: ShapeLike): { + width: number + height: number +} { + const nodes = getShapeTextNodes(group) + if (!nodes.length) return { width: 60, height: 36 } + const size = nodes[0]?.size || 36 + let maxW = 0 + nodes.forEach((nd) => { + maxW = Math.max(maxW, measureTextWidth(nd?.value || '', textNodeFontSpec(nd))) + }) + return { + width: Math.max(maxW, 20), + height: Math.max(nodes.length * lineHeightFor(size), size), + } } const DEFAULT_ADAPTER: ShapeAdapter = { @@ -42,14 +83,13 @@ const DEFAULT_ADAPTER: ShapeAdapter = { minHeight: 20, } -// eslint-disable-next-line @typescript-eslint/no-unused-vars const TEXT_ADAPTER: ShapeAdapter = { - getLocalSize: (shape) => ({ - width: shape.getBoundingClientRect(true).width || 60, - height: shape.getBoundingClientRect(true).height || 30, - }), - applySize: () => {}, - resizable: false, + // currentShape is line 1 (group.children[0]); walk up to the group to size + // the whole multiline block. + getLocalSize: (shape) => textBlockLocalSize(shape?.parent ?? shape), + applySize: () => {}, // sizing happens via font scaling, not w/h + resizable: true, + resizeMode: 'font', minWidth: 20, minHeight: 20, } @@ -58,6 +98,7 @@ const SHAPE_ADAPTERS: Record = { rectangle: DEFAULT_ADAPTER, circle: DEFAULT_ADAPTER, diamond: DEFAULT_ADAPTER, + newText: TEXT_ADAPTER, } // Handle dot diameter in *screen* px, stepped across 3 zoom ranges so the dots @@ -135,7 +176,16 @@ interface SelectionControllerOptions { onDeselect?: () => void commit?: ( id: string, - patch: { width: number; height: number; x: number; y: number } + patch: { + width: number + height: number + x: number + y: number + // Font resize on text also carries updated metadata (fontSize + + // multiline content). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + metadata?: Record + } ) => void recordHistory?: () => void onDelete?: (group: GroupLike) => void @@ -162,6 +212,8 @@ interface ScaleInteraction { initialHeight: number initialPosition: { x: number; y: number } initialRotation: number + // Font size at gesture start — only used for 'font' resizeMode (text). + initialFontSize?: number } interface RotateInteraction { @@ -550,8 +602,15 @@ export default class SelectionController { this.box.width = width + pad * 2 this.box.height = height + pad * 2 + // Standalone text is anchored left/middle at the group origin (the text + // extends RIGHT from translation.x and is vertically centered on + // translation.y). The box is centered on `ui`, so shift `ui` right by + // half the block width to wrap the text instead of sitting left of it. + // Shapes are centered on their origin, so no offset. + const anchorOffsetX = + this.currentAdapter?.resizeMode === 'font' ? width / 2 : 0 this.ui.position.set( - this.currentGroup.translation.x, + this.currentGroup.translation.x + anchorOffsetX, this.currentGroup.translation.y ) this.ui.rotation = this.currentGroup.rotation || 0 @@ -987,6 +1046,7 @@ export default class SelectionController { y: this.currentGroup.translation.y, }, initialRotation: this.currentGroup.rotation || 0, + initialFontSize: this.currentShape?.size ?? 36, } this._attachPointerStream() return true @@ -1087,6 +1147,12 @@ export default class SelectionController { private _scaleMove(e: MouseEvent): void { if (!this.interaction || this.interaction.mode !== 'scale') return if (!this.currentAdapter) return + // Standalone text resizes by font size (anchored at its center), not by + // width/height like shapes. + if (this.currentAdapter.resizeMode === 'font') { + this._fontScaleMove(e) + return + } const { corner, startSurface, @@ -1270,6 +1336,55 @@ export default class SelectionController { this.callbacks.onTransform(this.currentGroup) } + // Font-size resize for standalone text: scale the size by how far the + // cursor moved relative to the block's center (mirrors the old per-element + // interactjs handle). Anchored at the center, so the block never translates. + private _fontScaleMove(e: MouseEvent): void { + if (!this.interaction || this.interaction.mode !== 'scale') return + const { startSurface, initialPosition, initialFontSize, initialWidth } = + this.interaction + // Text is anchored left/middle at the group origin, so its visual center + // is offset right by half the block width. Anchor the scaling there + // (mirrors the old per-element resize which keyed off the text center). + const center = { + x: initialPosition.x + (initialWidth ?? 0) / 2, + y: initialPosition.y, + } + const surface = this.zui.clientToSurface(e.clientX, e.clientY) + const startDist = Math.hypot( + startSurface.x - center.x, + startSurface.y - center.y + ) + const curDist = Math.hypot( + surface.x - center.x, + surface.y - center.y + ) + const factor = curDist / Math.max(startDist, 1) + const base = initialFontSize ?? 36 + const newSize = Math.round(Math.min(Math.max(base * factor, 8), 300)) + this._applyTextFontSize(this.currentGroup, newSize) + this.syncToTarget() + this.two.update() + this.callbacks.onTransform(this.currentGroup) + } + + // Resize every line node to `size` and re-stack the block at the new line + // height, vertically centered on the group origin. Matches newText's + // syncMultilineLayout so the live scene and a reload render identically. + private _applyTextFontSize(group: ShapeLike, size: number): void { + const nodes = getShapeTextNodes(group) + const n = nodes.length + const lineH = lineHeightFor(size) + nodes.forEach((nd, i) => { + nd.size = size + nd.leading = size + nd.translation.set(0, (i - (n - 1) / 2) * lineH) + }) + // Re-fit the transparent gap hit area to the resized block so the text + // stays selectable across the whole block after a font resize. + syncTextHitRect(this.two, group) + } + private _rotateMove(e: MouseEvent): void { if (!this.interaction || this.interaction.mode !== 'rotate') return const { center, startSurface, initialRotation } = this.interaction @@ -1304,12 +1419,35 @@ export default class SelectionController { const { width, height } = this.currentAdapter.getLocalSize( this.currentShape ) - const patch = { + const patch: { + width: number + height: number + x: number + y: number + // eslint-disable-next-line @typescript-eslint/no-explicit-any + metadata?: Record + } = { width: parseInt(String(width)), height: parseInt(String(height)), x: parseInt(String(this.currentGroup.translation.x)), y: parseInt(String(this.currentGroup.translation.y)), } + // Font resize (text): persist the new size + the multiline content + // so a reload restores the resized block. + if (this.currentAdapter.resizeMode === 'font') { + const nodes = getShapeTextNodes(this.currentGroup) + const size = this.currentShape?.size + const meta = this.currentGroup?.elementData?.metadata || {} + const newMeta = { + ...meta, + fontSize: size, + content: nodes.map((nd) => nd?.value ?? '').join('\n'), + } + patch.metadata = newMeta + if (this.currentGroup.elementData) { + this.currentGroup.elementData.metadata = newMeta + } + } this.callbacks.commit(componentId, patch) } diff --git a/src/components/elements/groupobject.tsx b/src/components/elements/groupobject.tsx index 2644bb2..b8488f3 100644 --- a/src/components/elements/groupobject.tsx +++ b/src/components/elements/groupobject.tsx @@ -9,6 +9,7 @@ import { useBoardContext } from '../../views/Board/boardContext' import getEditComponents from '../utils/editWrapper' import { elementOnBlurHandler } from '../../utils/misc' import { DEFAULT_TEXT_FONT_FAMILY } from '../../constants/misc' +import { layoutStandaloneText } from '../../utils/canvasUtils' // eslint-disable-next-line @typescript-eslint/no-explicit-any type ElementProps = any @@ -443,6 +444,19 @@ function GroupedObjectWrapper(props: ElementProps): ReactElement { coreObject.opacity = item.metadata.opacity } + // Standalone text: the factory makes ONE Two.Text from the raw + // content, but SVG collapses `\n` to a single line. Re-lay it out + // as the stacked multiline block (same as the newText component) + // so a grouped/duplicated text keeps its line breaks. + if (item.componentType === 'newText') { + layoutStandaloneText( + two, + coreObject, + item.metadata?.content ?? '', + item.metadata?.fontSize || 36 + ) + } + const meta = item.metadata || {} if (meta.hasText && meta.textContent) { const twoText = two.makeText(meta.textContent, 0, 0) diff --git a/src/components/elements/newText.tsx b/src/components/elements/newText.tsx index a7e3a91..8e95371 100644 --- a/src/components/elements/newText.tsx +++ b/src/components/elements/newText.tsx @@ -1,20 +1,13 @@ import React, { useEffect, useState, useRef } from 'react' import type { ReactElement } from 'react' -import interact from 'interactjs' import { useImmer } from 'use-immer' import { useBoardContext } from '../../views/Board/boardContext' -import { elementOnBlurHandler } from '../../utils/misc' -import getEditComponents from '../utils/editWrapper' import NewTextFactory from '../../factory/newText' -import { - TEXT_SIZES_OBJECT, - MOBILE_TEXT_SIZES_OBJECT, -} from '../../utils/constants' +import { syncTextHitRect } from '../../utils/canvasUtils' import { lineHeightFor } from '../../utils/textLayout' import { htmlToBulletText } from '../../utils/htmlToBulletText' import { DEFAULT_TEXT_FONT_FAMILY } from '../../constants/misc' -import { useMediaQueryUtils } from '../../constants/exportHooks' // eslint-disable-next-line @typescript-eslint/no-explicit-any type ElementProps = any @@ -23,13 +16,9 @@ type ShapeLike = any // eslint-disable-next-line @typescript-eslint/no-explicit-any type InternalState = Record -interface ResizeState { - centerX: number - centerY: number - startDist: number - startSize: number -} - +// Selection, drag-follow, font-resize and deletion are owned by the generic +// SelectionController (TEXT_ADAPTER with resizeMode:'font'). This component only +// renders the text and owns the inline text-edit overlay (dblclick → textarea). function NewText(props: ElementProps): ReactElement { const { updateComponentBulkPropertiesInLocalStore, @@ -39,11 +28,7 @@ function NewText(props: ElementProps): ReactElement { isArrowSelected, } = useBoardContext() - const { isMobile } = useMediaQueryUtils() - const [showToolbar, toggleToolbar] = useState(false) - const [, setShowMobilePanel] = useState(false) const [internalState, setInternalState] = useImmer({}) - const mobileTriggerRef = useRef(null) const [textValue, setTextValue] = useState( props?.metadata?.content || '' ) @@ -57,42 +42,9 @@ function NewText(props: ElementProps): ReactElement { const two = props.twoJSInstance - let selectorInstance: ShapeLike = null - let groupObject: ShapeLike = null - - function onBlurHandler(e: FocusEvent): void { - elementOnBlurHandler(e, selectorInstance, two) - if (groupObject) { - document - .getElementById(`${groupObject.id}`) - ?.removeEventListener('keydown', handleKeyDown) - } - } - - function handleKeyDown(e: KeyboardEvent): void { - if (e.keyCode === 8 || e.keyCode === 46) { - if (groupObject) { - document.getElementById(`${groupObject.id}`)?.blur() - props.handleDeleteComponent?.(groupObject) - two.remove([groupObject]) - } - two.update() - } - } - - function onFocusHandler(): void { - if (!groupObject) return - const el = document.getElementById(`${groupObject.id}`) - if (el) { - el.style.outline = '0' - el.addEventListener('keydown', handleKeyDown) - } - } - useEffect(() => { const prevX = props.x const prevY = props.y - let handleGlobalMousedown: ((e: MouseEvent) => void) | null = null const elementFactory = new NewTextFactory(two, prevX, prevY, props) const { group, twoText } = elementFactory.createElement() @@ -100,7 +52,6 @@ function NewText(props: ElementProps): ReactElement { twoText.opacity = props.metadata?.opacity ?? 1 twoTextRef.current = twoText - groupObject = group // Multiline rendering for standalone text: `twoText` holds line 1; // satellite Two.Text nodes hold lines 2..N. We honor only hard @@ -138,6 +89,9 @@ function NewText(props: ElementProps): ReactElement { const surplus = extra.splice(Math.max(n - 1, 0)) if (surplus.length > 0) group.remove(surplus) } + // 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() } syncMultilineRef.current = syncMultilineLayout @@ -182,120 +136,8 @@ function NewText(props: ElementProps): ReactElement { // Render any persisted multiline content as the stacked block. syncMultilineLayout() - - const { selector } = getEditComponents(two, group, 4) - selectorInstance = selector two.update() - // Resize via corner handles (proportional font-size scaling). - const cornerCircles: ShapeLike[] = [ - selectorInstance.circle1, - selectorInstance.circle2, - selectorInstance.circle3, - selectorInstance.circle4, - ].filter(Boolean) - - const resizeCursors = [ - 'nwse-resize', // circle1 = TL - 'nesw-resize', // circle2 = TR - 'nwse-resize', // circle3 = BR - 'nesw-resize', // circle4 = BL - ] - - let resizeState: ResizeState | null = null - - const onResizeMouseMove = (e: MouseEvent): void => { - if (!resizeState) return - const { centerX, centerY, startDist, startSize } = resizeState - const currentDist = Math.sqrt( - (e.clientX - centerX) ** 2 + (e.clientY - centerY) ** 2 - ) - const scale = currentDist / Math.max(startDist, 1) - const newSize = Math.round( - Math.min(Math.max(startSize * scale, 8), 300) - ) - - twoText.size = newSize - twoText.leading = newSize - extraLineNodesRef.current.forEach((nd) => { - nd.size = newSize - nd.leading = newSize - }) - // Re-stack for the new line height, then box the whole block. - syncMultilineLayout() - - const bRect = blockRect() - selectorInstance.update( - bRect.left - 4, - bRect.right + 4, - bRect.top - 4, - bRect.bottom + 4 - ) - - setTextSize(newSize) - } - - const onResizeMouseUp = (): void => { - if (!resizeState) return - const finalSize = twoText.size - resizeState = null - - window.removeEventListener('mousemove', onResizeMouseMove) - window.removeEventListener('mouseup', onResizeMouseUp) - - const bRect = blockRect() - const newWidth = Math.round(bRect.width || 60) - const newHeight = Math.round(bRect.height || twoText.size) - - const resizeMetadata = { - ...props.metadata, - fontSize: finalSize, - content: textValueRef.current, - } - updateComponentBulkPropertiesInLocalStore(props.id, { - width: newWidth, - height: newHeight, - metadata: resizeMetadata, - }) - if (group.elementData) { - group.elementData.metadata = resizeMetadata - } - } - - cornerCircles.forEach((circle, index) => { - const circleElem = circle._renderer?.elem as HTMLElement | undefined - if (!circleElem) return - - circleElem.style.cursor = resizeCursors[index] ?? 'pointer' - circleElem.style.pointerEvents = 'all' - - circleElem.addEventListener('mousedown', (e: MouseEvent) => { - if (selectorInstance.areaGroup.opacity === 0) return - - e.stopPropagation() - e.preventDefault() - - const textDomElem = twoText._renderer.elem - const textScreenRect = textDomElem.getBoundingClientRect() - const centerX = textScreenRect.left + textScreenRect.width / 2 - const centerY = textScreenRect.top + textScreenRect.height / 2 - - const startDist = Math.sqrt( - (e.clientX - centerX) ** 2 + (e.clientY - centerY) ** 2 - ) - - resizeState = { - centerX, - centerY, - startDist, - startSize: twoText.size || 16, - } - - window.addEventListener('mousemove', onResizeMouseMove) - window.addEventListener('mouseup', onResizeMouseUp) - }) - }) - const groupEl = document.getElementById(group.id) if (groupEl) { groupEl.setAttribute('class', 'dragger-picker') @@ -311,23 +153,94 @@ function NewText(props: ElementProps): ReactElement { }) const getGroupElementFromDOM = document.getElementById(`${group.id}`) - getGroupElementFromDOM?.addEventListener('focus', onFocusHandler) - getGroupElementFromDOM?.addEventListener('blur', onBlurHandler) 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 + // getBoundingClientRect on the now-hidden group → (0,0) and drops a + // duplicate editor in the top-left corner. One editor at a time. + if (document.querySelector('.temp-input-area')) return + const groupDomElem = document.getElementById(`${group.id}`) if (!groupDomElem) return - const textDomElem = twoText._renderer.elem as HTMLElement - const screenRect = textDomElem.getBoundingClientRect() + // Live block screen rect = union of every line node's DOM rect. + // Re-read each frame so the editor follows the text as the canvas + // pans/zooms. The block is vertically centered on the group origin + // (line 1 sits at its TOP), so unioning ALL lines — not just line 1 — + // is what keeps multi-line text centered in the editor/box. + const blockScreenRect = (): { + left: number + top: number + width: number + height: number + } => { + const lineNodes = [ + twoTextRef.current, + ...extraLineNodesRef.current, + ].filter(Boolean) + let L = Infinity + let R = -Infinity + let T = Infinity + let Bm = -Infinity + lineNodes.forEach((nd: ShapeLike) => { + const el = nd?._renderer?.elem as HTMLElement | undefined + if (!el) return + const r = el.getBoundingClientRect() + L = Math.min(L, r.left) + R = Math.max(R, r.right) + T = Math.min(T, r.top) + Bm = Math.max(Bm, r.bottom) + }) + if (L === Infinity) { + const r = ( + twoText._renderer.elem as HTMLElement + ).getBoundingClientRect() + return { + left: r.left, + top: r.top, + width: r.width, + height: r.height, + } + } + return { left: L, top: T, width: R - L, height: Bm - T } + } + // Measure the text's real screen rect WHILE it's still visible, then + // hide it with display:none. (We must NOT use visibility:hidden: + // Two.js drives the SVG visibility/display from its own `.visible` + // flag and overwrites a CSS visibility we set on the next two.update, + // re-showing the text on top of the textarea — double text. opacity:0 + // is no good either: the renderer skips updating opacity-0 nodes, so + // typed changes wouldn't track.) With display:none we can't read the + // hidden text's rect, so we map its FIXED surface anchor → screen via + // the live camera instead. + const startRect = blockScreenRect() groupDomElem.style.display = 'none' - const fontSize = twoText.size || 36 - const sceneScale = two?.scene?.scale || 1 - const cssFontSize = fontSize * sceneScale - const lineH = Math.ceil(cssFontSize * 1.6) - const vertPad = Math.ceil((lineH - cssFontSize) / 2) + 4 + const scale0 = two?.scene?.scale || 1 + // The anchor is invariant during edit: text is left-aligned at the + // group origin x and vertically centered on the group origin y. + const surfaceLeft = group.translation.x + const surfaceCenterY = group.translation.y + const surfaceWidth = startRect.width / scale0 + // 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 + const calibY = + startRect.top + + startRect.height / 2 - + two.scene.translation.y - + surfaceCenterY * scale0 + + // Camera-dependent geometry — recomputed each two.update (pan/zoom). + let cssFontSize = (twoText.size || 36) * scale0 + let lineH = Math.ceil(cssFontSize * 1.6) + let vertPad = Math.ceil((lineH - cssFontSize) / 2) + 4 + let leftAnchor = 0 + let centerY = 0 + let minContentWidth = 0 const input = document.createElement('textarea') const randomId = Math.floor(Math.random() * 90 + 10) @@ -338,6 +251,12 @@ function NewText(props: ElementProps): ReactElement { input.style.background = 'transparent' input.style.padding = `${vertPad}px 8px` input.style.color = twoText.fill || '#3A342C' + // Match the element's current opacity so the editor doesn't flash to + // full opacity on entering edit mode. The opacity handler stores it + // on metadata (and applies it at group level), so read that. + input.style.opacity = String( + group.elementData?.metadata?.opacity ?? group.opacity ?? 1 + ) input.style.fontSize = `${cssFontSize}px` input.style.fontFamily = twoText.family || DEFAULT_TEXT_FONT_FAMILY input.style.fontWeight = twoText.weight || 'normal' @@ -352,9 +271,6 @@ function NewText(props: ElementProps): ReactElement { input.style.boxSizing = 'border-box' input.className = 'temp-input-area' - const centerY = screenRect.top + screenRect.height / 2 - const leftAnchor = screenRect.left - 8 - document.getElementById('main-two-root')?.append(input) const measureSpan = document.createElement('span') @@ -370,6 +286,29 @@ function NewText(props: ElementProps): ReactElement { measureSpan.style.padding = '0' document.body.appendChild(measureSpan) + // Pull font + anchor from the LIVE camera + text position. Called on + // every two.update so the editor pans/zooms with the text. + const recomputeGeometry = (): void => { + const scale = two?.scene?.scale || 1 + cssFontSize = (twoText.size || 36) * scale + lineH = Math.ceil(cssFontSize * 1.6) + vertPad = Math.ceil((lineH - cssFontSize) / 2) + 4 + // Map the fixed surface anchor → current screen via the live + // camera. No getBoundingClientRect (the text is display:none). + leftAnchor = + calibX + two.scene.translation.x + surfaceLeft * scale + centerY = + calibY + two.scene.translation.y + surfaceCenterY * scale + minContentWidth = surfaceWidth * scale + input.style.fontSize = `${cssFontSize}px` + input.style.lineHeight = `${lineH}px` + input.style.padding = `${vertPad}px 8px` + measureSpan.style.fontSize = `${cssFontSize}px` + measureSpan.style.lineHeight = `${lineH}px` + } + + // Pure DOM: size + place the textarea from the current geometry. + // No two.update here (so it's safe to call from the update handler). const autoSizeAndCenter = (): void => { const val = input.value || 'M' measureSpan.textContent = val @@ -379,7 +318,7 @@ function NewText(props: ElementProps): ReactElement { const contentWidth = Math.max( measuredW + 40, - screenRect.width + 40, + minContentWidth + 40, 80 ) const contentHeight = Math.max( @@ -389,14 +328,29 @@ function NewText(props: ElementProps): ReactElement { input.style.width = `${contentWidth}px` input.style.height = `${contentHeight}px` - input.style.left = `${leftAnchor}px` input.style.top = `${centerY - contentHeight / 2}px` } - autoSizeAndCenter() + // Re-glue the editor to the text after any render (pan/zoom/content). + const repositionEditor = (): void => { + recomputeGeometry() + autoSizeAndCenter() + } - input.addEventListener('input', autoSizeAndCenter) + // On typing: push the value into the hidden Two.js text nodes so the + // SelectionController's box (and our live block rect) reflect it. The + // syncMultilineLayout's two.update fires 'update' → repositionEditor. + const onTextInput = (): void => { + textValueRef.current = input.value + syncMultilineLayout() + repositionEditor() + } + + repositionEditor() + two.bind('update', repositionEditor) + + input.addEventListener('input', onTextInput) // Pasting a bulleted list from a rich-text source (Docs, Notion, // Notes) into this plain textarea would otherwise drop the bullet @@ -419,21 +373,9 @@ function NewText(props: ElementProps): ReactElement { const caret = start + converted.length input.selectionStart = caret input.selectionEnd = caret - autoSizeAndCenter() + onTextInput() }) - input.onfocus = function (): void { - const bRect = blockRect() - selectorInstance.update( - bRect.left - 4, - bRect.right + 4, - bRect.top - 4, - bRect.bottom + 4 - ) - selectorInstance.show() - two.update() - } - input.focus() input.addEventListener('keydown', (event: KeyboardEvent) => { @@ -454,7 +396,8 @@ function NewText(props: ElementProps): ReactElement { }) input.addEventListener('blur', () => { - input.removeEventListener('input', autoSizeAndCenter) + two.unbind('update', repositionEditor) + input.removeEventListener('input', onTextInput) if (measureSpan.parentNode) { measureSpan.parentNode.removeChild(measureSpan) } @@ -472,17 +415,19 @@ function NewText(props: ElementProps): ReactElement { const newWidth = Math.round(bRect.width || 60) const newHeight = Math.round(bRect.height || twoText.size) - selectorInstance.update( - bRect.left - 4, - bRect.right + 4, - bRect.top - 4, - bRect.bottom + 4 - ) - selectorInstance.hide() + // two.update() re-runs the SelectionController's 'update' bind, + // which re-syncs its box to the (possibly resized) text block. two.update() + // Merge onto the LIVE metadata (kept current by the toolbar's + // size/font/opacity handlers + controller resize), not the + // props snapshot frozen at mount — otherwise editing the text + // after a size/opacity change would write those stale values + // back and revert them on reload. + const baseMetadata = + group.elementData?.metadata ?? props.metadata ?? {} const updatedMetadata = { - ...props.metadata, + ...baseMetadata, content: textValueRef.current, } updateComponentBulkPropertiesInLocalStore(props.id, { @@ -514,50 +459,11 @@ function NewText(props: ElementProps): ReactElement { } window.addEventListener('triggerTextInput', handleTriggerTextInput) - interact(`#${group.id}`).on('click', () => { - const bRect = blockRect() - selector.update( - bRect.left - 4, - bRect.right + 4, - bRect.top - 4, - bRect.bottom + 4 - ) - two.update() - toggleToolbar(true) - }) - - handleGlobalMousedown = (e: MouseEvent): void => { - const path: EventTarget[] = e.composedPath - ? e.composedPath() - : [] - const isOnGroup = path.some( - (el: EventTarget) => (el as HTMLElement)?.id === group.id - ) - const isOnToolbar = path.some( - (el: EventTarget) => - (el as HTMLElement)?.id === 'floating-toolbar' - ) - const isOnMobileTrigger = - mobileTriggerRef.current && - path.includes(mobileTriggerRef.current) - if (!isOnGroup && !isOnToolbar && !isOnMobileTrigger) { - selectorInstance.hide() - toggleToolbar(false) - two.update() - } - } - window.addEventListener('mousedown', handleGlobalMousedown) - return (): void => { window.removeEventListener( 'triggerTextInput', handleTriggerTextInput ) - if (handleGlobalMousedown) { - window.removeEventListener('mousedown', handleGlobalMousedown) - } - window.removeEventListener('mousemove', onResizeMouseMove) - window.removeEventListener('mouseup', onResizeMouseUp) } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -623,10 +529,6 @@ function NewText(props: ElementProps): ReactElement { ) }, [props.id, two]) - useEffect(() => { - if (!showToolbar) setShowMobilePanel(false) - }, [showToolbar]) - useEffect(() => { const groupId = internalState?.group?.id const el = groupId ? document.getElementById(groupId) : null @@ -647,11 +549,6 @@ function NewText(props: ElementProps): ReactElement { internalState?.group?.id, ]) - // TEXT_SIZES_OBJECT and MOBILE_TEXT_SIZES_OBJECT used by callbacks - // wired in via the toolbar; keep imports referenced via a no-op. - void TEXT_SIZES_OBJECT - void MOBILE_TEXT_SIZES_OBJECT - return (
diff --git a/src/components/sidebar/menuDrawer.tsx b/src/components/sidebar/menuDrawer.tsx index 90abc38..218d2ef 100644 --- a/src/components/sidebar/menuDrawer.tsx +++ b/src/components/sidebar/menuDrawer.tsx @@ -162,7 +162,7 @@ const MenuDrawer = (): ReactElement => {
{ pointerEvents: showMenu ? 'auto' : 'none', }} > -
-
- - More - -
- -
- +
{ + setShowMenu(false)} + > + + + + Embeddable whiteboard + + { + const w = measureTextWidth(nd?.value || '', { + family: nd?.family || DEFAULT_TEXT_FONT_FAMILY, + size: nd?.size || size, + weight: nd?.weight, + }) + maxW = Math.max(maxW, w) + }) + const blockH = Math.max(nodes.length * lineH, size) + + let rect = Array.from(group.children as ArrayLike).find( + (c: ShapeLike) => c?._isTextHitArea + ) + if (!rect) { + rect = two.makeRectangle(0, 0, maxW, blockH) + rect.fill = 'rgba(0,0,0,0)' + rect.noStroke() + rect._isTextHitArea = true + group.add(rect) + } + rect.width = maxW + rect.height = blockH + // Text is left-aligned at the group origin (extends right) and vertically + // centered on it, so center the rect at (width/2, 0). + rect.translation.set(maxW / 2, 0) +} + +/** + * Lay out STANDALONE text (the `newText` kind) as a vertical stack of one + * Two.Text per hard-newline line, centered on the group origin. An SVG + * collapses `\n`, so multiline standalone text must be rendered as stacked + * nodes — newText's component does this internally, but the same layout is + * needed whenever the text is re-materialised outside that component (e.g. as a + * cloned member of a group selection). Reuses any existing line nodes (line 1 is + * the factory's text node), adds nodes for new lines, removes surplus ones. + * + * Keep in sync with newText.tsx's `syncMultilineLayout`. + */ +export function layoutStandaloneText( + two: TwoLike, + group: ShapeLike, + content: string, + size: number +): void { + const nodes = getShapeTextNodes(group) + const first = nodes[0] + if (!first) return + const lines = (content || '').split('\n') + const n = lines.length + const lineH = lineHeightFor(size) + + first.value = lines[0] ?? '' + first.size = size + first.leading = size + first.translation.set(0, (0 - (n - 1) / 2) * lineH) + + const extra = nodes.slice(1) + for (let i = 1; i < n; i++) { + let node = extra[i - 1] + if (!node) { + node = two.makeText(lines[i] ?? '', 0, 0) + group.add(node) + } + node.value = lines[i] ?? '' + node.fill = first.fill + node.size = size + node.leading = size + node.family = first.family + node.alignment = first.alignment + node.baseline = first.baseline + node.opacity = first.opacity + node.translation.set(0, (i - (n - 1) / 2) * lineH) + } + + if (extra.length > n - 1) { + const surplus = extra.slice(n - 1) + if (surplus.length) group.remove(surplus) + } +} + /** * Render `lines` as a vertical stack of Two.Text nodes inside `group`'s text * layer, creating the layer on first use. Existing line nodes are reused diff --git a/src/utils/constants.ts b/src/utils/constants.ts index da16106..c85586e 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -109,15 +109,15 @@ export const essentialShades: string[] = [ '#0065FF', ] -// Fill picker only — transparent ("no fill") first, replacing the green that -// the shared essentialShades keeps for stroke/text. +// Fill picker only — transparent ("no fill") and white kept, followed by light +// pastel shades suited to fills (vs. the saturated stroke/text essentialShades). export const fillEssentialShades: string[] = [ TRANSPARENT_FILL, '#FFFFFF', - '#000000', - '#FF5630', - '#FFAB00', - '#0065FF', + '#FFBDAD', + '#FFF0B3', + '#ABF5D1', + '#B3D4FF', ] export interface DrawerElement { diff --git a/src/views/Board/board.tsx b/src/views/Board/board.tsx index 89f85ca..2aa9eb9 100644 --- a/src/views/Board/board.tsx +++ b/src/views/Board/board.tsx @@ -1184,14 +1184,22 @@ const BoardViewPage: React.FC = (props) => { const componentId = sel?.group?.data?.elementData?.id const existingMetadata = sel?.group?.data?.elementData?.metadata ?? {} + const updatedMetadata = { + ...existingMetadata, + fontSize: textSize, + // Reconstruct the raw multiline string from every line node, + // not just line 1 — otherwise a reload would drop lines 2..N. + content: nodes.map((node) => node.value).join('\n'), + } + // Keep the in-place elementData.metadata current too. Other property + // handlers (e.g. opacity in applyProperty) read it as the merge base; if + // left stale they'd write the OLD fontSize back to the store and the + // resize would silently revert on reload. + if (sel?.group?.data?.elementData) { + sel.group.data.elementData.metadata = updatedMetadata + } updateComponentBulkPropertiesInLocalStore(componentId, { - metadata: { - ...existingMetadata, - fontSize: textSize, - // Reconstruct the raw multiline string from every line node, - // not just line 1 — otherwise a reload would drop lines 2..N. - content: nodes.map((node) => node.value).join('\n'), - }, + metadata: updatedMetadata, }) twoJSInstance?.update() syncOpenTextarea({ fontSize: textSize }) @@ -1286,14 +1294,20 @@ const BoardViewPage: React.FC = (props) => { const componentId = sel?.group?.data?.elementData?.id const existingMetadata = sel?.group?.data?.elementData?.metadata ?? {} + const updatedMetadata = { + ...existingMetadata, + textFontFamily: fontFamily, + // Reconstruct the raw multiline string from every line node, + // not just line 1 — otherwise a reload would drop lines 2..N. + content: nodes.map((node) => node.value).join('\n'), + } + // Keep in-place elementData.metadata current so later handlers (opacity, + // etc.) merge onto the new family instead of writing a stale one back. + if (sel?.group?.data?.elementData) { + sel.group.data.elementData.metadata = updatedMetadata + } updateComponentBulkPropertiesInLocalStore(componentId, { - metadata: { - ...existingMetadata, - textFontFamily: fontFamily, - // Reconstruct the raw multiline string from every line node, - // not just line 1 — otherwise a reload would drop lines 2..N. - content: nodes.map((node) => node.value).join('\n'), - }, + metadata: updatedMetadata, }) twoJSInstance?.update() syncOpenTextarea({ fontFamily }) diff --git a/src/views/Embeddable/embeddable.tsx b/src/views/Embeddable/embeddable.tsx new file mode 100644 index 0000000..f3dce4b --- /dev/null +++ b/src/views/Embeddable/embeddable.tsx @@ -0,0 +1,236 @@ +import React, { useEffect } from 'react' +import type { ReactElement, ReactNode } from 'react' +import { Link } from 'react-router-dom' +import routes from '../../routes' +import { useMediaQueryUtils } from '../../constants/exportHooks' + +const ChevronLeft = (): ReactElement => ( + + + +) + +// Small, dependency-free code block. The page is app-only (not part of the +// published library surface in lib.ts), so standard Tailwind utilities are safe +// here — no consumer purge to worry about. +const CodeBlock = ({ children }: { children: ReactNode }): ReactElement => ( +
+        {children}
+    
+) + +const Section = ({ + title, + children, +}: { + title: string + children: ReactNode +}): ReactElement => ( +
+

+ {title} +

+ {children} +
+) + +const EmbeddablePage: React.FC = () => { + const { isMobile } = useMediaQueryUtils() + + // Set the document title + meta description for this route. The app is an + // SPA, so the static index.html title is shared across routes — updating it + // here gives this page a unique title/snippet when crawled and when shared. + useEffect(() => { + const prevTitle = document.title + document.title = + 'Embeddable Whiteboard for React Apps — Craftbase' + + const description = + 'Embed Craftbase, an open-source whiteboard canvas, into your React app with a single component. Whiteboard data lives in the browser localStorage — no backend required to get started.' + let meta = document.querySelector( + 'meta[name="description"]' + ) as HTMLMetaElement | null + const createdMeta = !meta + if (!meta) { + meta = document.createElement('meta') + meta.name = 'description' + document.head.appendChild(meta) + } + const prevDescription = meta.content + meta.content = description + + return (): void => { + document.title = prevTitle + if (createdMeta) { + meta?.remove() + } else if (meta) { + meta.content = prevDescription + } + } + }, []) + + return ( +
+ {/* Nav */} + + + {/* Body */} +
+ {/* Header */} +
+

+ Embeddable Whiteboard for React +

+

+ Craftbase is an open-source, embeddable whiteboard canvas + you can drop into any React app as a single component. + Mount the {''}{' '} + and you get a full sketching surface — shapes, arrows, + text, freehand drawing, pan and zoom — rendered with + Two.js. No backend is required to get started: your + whiteboard data lives in the browser's{' '} + localStorage. +

+
+ +
+

+ Add Craftbase as a dependency. During local development + you can link the package directly from a sibling + checkout: +

+ {`// package.json +{ + "dependencies": { + "craftbase": "link:../craftbase" + } +}`} +
+ +
+

+ Import the Board{' '} + component and render it inside a sized container. That's + the whole integration — Craftbase owns the canvas, tools + and interactions. +

+ {`import { Board } from 'craftbase' + +export default function Whiteboard() { + return ( +
+ +
+ ) +}`}
+
+ +
+

+ By default Craftbase runs in local + mode. Everything a user draws is kept in React + state and continuously saved to the browser's{' '} + localStorage as a + draft. This means: +

+
    +
  • + No database, server or account is needed to start — + the canvas works fully offline. +
  • +
  • + The board is restored automatically on reload from + the saved localStorage draft. +
  • +
  • + Data is scoped to the user's browser and origin, so + it is private to that device until you choose to + persist or share it. +
  • +
+

+ Because the draft is just localStorage, clearing the + browser's site data (or opening the app in a different + browser/device) starts a fresh board. When you're ready + to sync across devices, Craftbase can be wired to a + backend, but that's entirely opt-in. +

+
+ +
+

+ Craftbase ships TypeScript source ( + .ts/ + .tsx). Make sure + your bundler compiles it and that Tailwind scans + Craftbase's classes so they survive purging: +

+ {`// tailwind.config.js +export default { + content: [ + './src/**/*.{ts,tsx}', + './node_modules/craftbase/src/**/*.{ts,tsx}', + ], +} + +// vite.config.js — let Vite handle Craftbase's TS source +export default { + optimizeDeps: { exclude: ['craftbase'] }, +}`} +
+ + {/* CTA */} +
+
+ Want the full API and extension points? Browse the source + and docs on GitHub. +
+ + View on GitHub → + +
+
+
+ ) +} + +export default EmbeddablePage diff --git a/src/views/Embeddable/errorBoundary.tsx b/src/views/Embeddable/errorBoundary.tsx new file mode 100644 index 0000000..f478134 --- /dev/null +++ b/src/views/Embeddable/errorBoundary.tsx @@ -0,0 +1,34 @@ +import React, { type ReactNode } from 'react' + +interface Props { + children?: ReactNode +} + +interface State { + hasError: boolean +} + +class ErrorBoundaryEmbeddableView extends React.Component { + constructor(props: Props) { + super(props) + this.state = { hasError: false } + } + + static getDerivedStateFromError(_error: unknown): State { + return { hasError: true } + } + + override componentDidCatch( + _error: Error, + _errorInfo: React.ErrorInfo + ): void {} + + override render(): ReactNode { + if (this.state.hasError) { + return

Couldn't load this page. Something went wrong

+ } + return this.props.children + } +} + +export default ErrorBoundaryEmbeddableView diff --git a/src/views/Embeddable/index.tsx b/src/views/Embeddable/index.tsx new file mode 100644 index 0000000..d4de71b --- /dev/null +++ b/src/views/Embeddable/index.tsx @@ -0,0 +1,15 @@ +import React, { Suspense } from 'react' +import ErrorBoundary from './errorBoundary' +import Spinner from '../../components/common/spinner' + +const EmbeddablePage = React.lazy(() => import('./embeddable')) + +const EmbeddableViewContainer: React.FC = (props) => ( + }> + + + + +) + +export default EmbeddableViewContainer diff --git a/tests/e2e/copy-paste.spec.js b/tests/e2e/copy-paste.spec.js index e52c2d1..3949b37 100644 --- a/tests/e2e/copy-paste.spec.js +++ b/tests/e2e/copy-paste.spec.js @@ -180,14 +180,14 @@ test.describe('Copy-paste', () => { * Flow exercised: * 1. Draw circle (default fill #f4f4f2) * 2. Click circle → floating toolbar opens - * 3. Click #FFAB00 swatch in Background section → fill updated in store + * 3. Click #FFF0B3 swatch in Background section → fill updated in store * 4. Cmd+C → Cmd+V on empty area - * 5. Pasted circle's fill should be #FFAB00, not the default + * 5. Pasted circle's fill should be #FFF0B3, not the default */ test('pasting a circle with user-modified fill preserves the new fill', async ({ page, }) => { - const NEW_FILL = '#FFAB00' + const NEW_FILL = '#FFF0B3' const box = await getCanvasBox(page) const { cx, cy } = safeArea(box) diff --git a/tests/e2e/group-apply-property.spec.js b/tests/e2e/group-apply-property.spec.js index 1d005b1..cf342c0 100644 --- a/tests/e2e/group-apply-property.spec.js +++ b/tests/e2e/group-apply-property.spec.js @@ -41,7 +41,7 @@ import { // text fill #3A342C). NOTE: the Fill picker uses fillEssentialShades (which // dropped green #36B37E for a transparent swatch), while stroke/text use the // shared essentialShades — so FILL_COLOR must come from fillEssentialShades. -const FILL_COLOR = '#FFAB00' +const FILL_COLOR = '#FFF0B3' const STROKE_COLOR = '#0065FF' const TEXT_COLOR = '#FF5630' From c6bab448d316a6a4901fd8d5b1d4e6562778d1fe Mon Sep 17 00:00:00 2001 From: meetzaveri Date: Sun, 14 Jun 2026 15:31:33 +0530 Subject: [PATCH 8/9] update: persist welcome sketch elements --- src/views/Board/board.tsx | 115 ++++++++++++++++++++------------------ 1 file changed, 61 insertions(+), 54 deletions(-) diff --git a/src/views/Board/board.tsx b/src/views/Board/board.tsx index 2aa9eb9..4d72fd9 100644 --- a/src/views/Board/board.tsx +++ b/src/views/Board/board.tsx @@ -70,7 +70,6 @@ import { import { isWelcomeComponent, playWelcomeSketchEntrance, - playWelcomeSketchExit, } from '../../utils/welcomeSketch' import { useDrawingModes } from '../../hooks/useDrawingModes' import { useElementDefaults } from '../../hooks/useElementDefaults' @@ -220,9 +219,9 @@ const BoardViewPage: React.FC = (props) => { ) // Guards the one-shot welcome-sketch soft-land entrance. const welcomeEntrancePlayedRef = useRef(false) - // Guards the one-shot welcome-sketch exit so a burst of first adds only - // fades the sketch out once. - const welcomeDismissInFlightRef = useRef(false) + // Guards the one-shot welcome-sketch promotion so a burst of first adds only + // promotes the sketch into real content once. + const welcomePromotedRef = useRef(false) // eslint-disable-next-line @typescript-eslint/no-explicit-any const twoJSInstanceRef = useRef(null) // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -588,14 +587,15 @@ const BoardViewPage: React.FC = (props) => { } } - // Gently fades + lifts the welcome-sketch elements out, then clears them - // from the store, on the user's first real interaction. The exit tween runs - // node-direct via the same primitive as the soft-land entrance (and - // supersedes it if still in flight); the store sweep is gated on the tween - // finishing so elements aren't yanked mid-fade. Guarded so a burst of first - // adds only triggers one dismissal. - const dismissWelcomeSketch = (): void => { - if (welcomeDismissInFlightRef.current) return + // On the user's first real interaction, the welcome sketch stops being + // onboarding scaffolding and becomes the user's own content: we strip the + // `isWelcome`/`welcomeRole` tags so the elements are no longer filtered out + // of draft saves + share-time persistence (see useLocalDraftPersistence + + // the persist filter below). They simply stay on the canvas as-is — no fade, + // no removal — and are saved, persisted, and deletable like anything the + // user drew. Guarded so a burst of first adds only promotes once. + const promoteWelcomeSketch = (): void => { + if (welcomePromotedRef.current) return const welcomeIds = Object.keys( stateRefForComponentStore.current ).filter((id) => @@ -603,47 +603,41 @@ const BoardViewPage: React.FC = (props) => { ) if (welcomeIds.length === 0) return - welcomeDismissInFlightRef.current = true + welcomePromotedRef.current = true + // The sketch now lives in the draft, so it must not be re-seeded on the + // next visit. localStorage.setItem(WELCOME_DISMISSED_KEY, '1') - const sweepStore = (): void => { - const two = twoJSInstanceRef.current - const next = { ...stateRefForComponentStore.current } - welcomeIds.forEach((id) => { - delete next[id] - // The exit tween only fades opacity to 0; the node stays in the - // Two.js scene and remains hit-testable. Remove it outright so a - // dismissed welcome element (e.g. the "Drag me" rect) can't be - // clicked after the user draws. The element's React wrapper never - // unmounts, so nothing else removes it from the scene. - if (two) { - const el = two.scene.children.find( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (child: any) => child?.elementData?.id === id - ) - if (el) two.remove(el) - } - window.dispatchEvent( - new CustomEvent('elementRemoved', { detail: { id } }) - ) - }) - stateRefForComponentStore.current = next - setComponentStore(next) - if (two) { - try { - two.update() - } catch { - // See CLAUDE.md "Two.js scene.subtractions Pitfall": if the - // render throws, the bad subtraction stays queued and every - // later two.update() repeats the crash. Clear it so the - // canvas keeps rendering. - two.scene.subtractions.length = 0 - two.scene._flagSubtractions = false - } + const two = twoJSInstanceRef.current + const next = { ...stateRefForComponentStore.current } + welcomeIds.forEach((id) => { + const comp = next[id] + if (!comp) return + // Drop only the welcome tags; everything else (opacity, text + // content, etc.) carries over so the element renders unchanged but + // isWelcomeComponent() no longer matches it. + const { + isWelcome: _isWelcome, + welcomeRole: _welcomeRole, + ...restMeta + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } = (comp.metadata ?? {}) as any + next[id] = { ...comp, metadata: restMeta } + // Keep the live Two.js node's bookkeeping in sync in case anything + // reads the welcome flag off elementData. + const el = two?.scene.children.find( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (child: any) => child?.elementData?.id === id + ) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const meta = (el as any)?.elementData?.metadata + if (meta) { + delete meta.isWelcome + delete meta.welcomeRole } - } - - playWelcomeSketchExit(twoJSInstanceRef.current, welcomeIds, sweepStore) + }) + stateRefForComponentStore.current = next + setComponentStore(next) } // Records ADD action, updates store and syncs to DB @@ -686,11 +680,12 @@ const BoardViewPage: React.FC = (props) => { safeInfo.position = maxPos + 1 } - // User's first real element dismisses the onboarding sketch. Welcome - // elements are seeded via setComponentStore directly (never through - // this path), so any add here is by definition "real" user content. + // User's first real element promotes the onboarding sketch into real, + // persisted content (it stays on the canvas rather than vanishing). + // Welcome elements are seeded via setComponentStore directly (never + // through this path), so any add here is by definition "real" content. if (!isWelcomeComponent(safeInfo as ComponentRecord)) { - dismissWelcomeSketch() + promoteWelcomeSketch() } // Trigger background board creation on first interaction @@ -842,6 +837,12 @@ const BoardViewPage: React.FC = (props) => { skipHistory: boolean = false, syncDefaults: boolean = false ) => { + // Changing a property of a welcome element counts as a first real + // interaction: promote the sketch into persisted content and spin up the + // background board, same as the shapes toolbar. Both are one-shot. + promoteWelcomeSketch() + ensureBackgroundBoard() + const userId = localStorage.getItem('userId') if (!skipHistory) { @@ -898,6 +899,12 @@ const BoardViewPage: React.FC = (props) => { x: number, y: number ) => { + // Dragging a welcome element counts as a first real interaction: promote + // the sketch into persisted content and spin up the background board, + // same as drawing via the shapes toolbar. Both are one-shot/idempotent. + promoteWelcomeSketch() + ensureBackgroundBoard() + const userId = localStorage.getItem('userId') recordToHistoryLog({ From 678260877c5f06172adf449d8b4a710f655eff13 Mon Sep 17 00:00:00 2001 From: meetzaveri Date: Sun, 14 Jun 2026 15:52:08 +0530 Subject: [PATCH 9/9] update: enable pan mode for desktop as well --- src/components/sidebar/shapesToolbar.tsx | 11 ----------- src/newCanvas.tsx | 7 ++++--- src/utils/constants.ts | 2 -- 3 files changed, 4 insertions(+), 16 deletions(-) diff --git a/src/components/sidebar/shapesToolbar.tsx b/src/components/sidebar/shapesToolbar.tsx index 93c2bf6..7fc6294 100644 --- a/src/components/sidebar/shapesToolbar.tsx +++ b/src/components/sidebar/shapesToolbar.tsx @@ -102,17 +102,6 @@ const ShapesToolbar = ({ addElement }: ShapesToolbarProps): ReactElement => { const list = ( isMobile ? allElementsRaw : flattenShapesForDesktop(allElementsRaw) ) - .filter((el) => { - // Pan is normally mobile-only; surface it on desktop too when - // geo objects are enabled so the default tool is reachable. - if (el.mobileOnly) { - return ( - isMobile || - (geoObjectsEnabled && el.elementName === 'pan') - ) - } - return true - }) // Whiteboard shape tools are hidden in geo mode in favour of the // geo toolset (point/area/route/geoText). .filter( diff --git a/src/newCanvas.tsx b/src/newCanvas.tsx index 941790b..54b79b3 100644 --- a/src/newCanvas.tsx +++ b/src/newCanvas.tsx @@ -3232,9 +3232,10 @@ function addZUI( } function mousewheel(e: WheelEvent) { - // Pan mode treats a plain wheel/scroll as zoom (no modifier needed); - // otherwise the wheel pans the surface and shift/meta zooms. - if (e.shiftKey === true || e.metaKey === true || isPanMode()) { + // Wheel/scroll zooms only with a modifier held — cmd (macOS), ctrl + // (Windows; also what trackpad pinch-zoom emits), or shift. A plain + // wheel/scroll always pans the surface, in pan mode and otherwise. + if (e.shiftKey === true || e.metaKey === true || e.ctrlKey === true) { let dy = ((e as WheelEvent & { wheelDeltaY?: number }).wheelDeltaY || -e.deltaY) / 1000 diff --git a/src/utils/constants.ts b/src/utils/constants.ts index c85586e..d648e67 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -133,7 +133,6 @@ export interface PrimaryElement { hasDrawer: boolean noAction: boolean drawerData: DrawerElement[] - mobileOnly?: boolean } export interface PrimarySection { @@ -160,7 +159,6 @@ export const staticPrimaryElementData: PrimarySection[] = [ hasDrawer: false, noAction: true, drawerData: [], - mobileOnly: true, }, { elementName: 'shapes',