From 075cab5b39f61f625764856c93cae4f89ca6d6d6 Mon Sep 17 00:00:00 2001 From: meetzaveri Date: Sun, 7 Jun 2026 12:15:55 +0530 Subject: [PATCH 1/3] update: add reordering (z-order positioning) feature for elements in craftbase --- .claude/CLAUDE.md | 4 +- index.html | 2 +- src/assets/cards.svg | 1 + src/assets/chevron-down.svg | 1 + src/assets/chevron-right.svg | 1 + src/assets/chevron-up.svg | 1 + src/assets/chevrons-down.svg | 1 + src/assets/chevrons-up.svg | 1 + src/assets/layers.svg | 1 + src/canvas/selectionController.ts | 11 + src/components/canvasContextMenu.tsx | 181 ++++++++++++++-- src/components/elements/groupobject.tsx | 32 +++ src/newCanvas.tsx | 274 +++++++++++++++++++++++- src/schema/queries/index.ts | 2 + src/types/board.ts | 8 + src/utils/misc.ts | 13 ++ src/views/Board/board.tsx | 17 ++ tests/e2e/reorder.spec.js | 182 ++++++++++++++++ 18 files changed, 713 insertions(+), 20 deletions(-) create mode 100644 src/assets/cards.svg create mode 100644 src/assets/chevron-down.svg create mode 100644 src/assets/chevron-right.svg create mode 100644 src/assets/chevron-up.svg create mode 100644 src/assets/chevrons-down.svg create mode 100644 src/assets/chevrons-up.svg create mode 100644 src/assets/layers.svg create mode 100644 tests/e2e/reorder.spec.js diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index ffd98e9..923b7a9 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -134,7 +134,7 @@ Reusable React UI components. - `userDetailsPopup.tsx` - User information popup - `sidebar.css` - Sidebar styles - **Element defaults vs. selected-shape edits:** `src/utils/applyProperty.ts` (`createApplyProperty`) is the single mutation path behind `elementProperties.tsx`. Every property change (1) updates the matching default via `useElementDefaults` setters, then (2) if a shape is selected, applies the same change to that shape. So editing a property with nothing selected just sets the default; editing with a shape selected sets both. Defaults store `null` for `strokeType: 'solid'` (matching what `primary.tsx` feeds new shapes); DB rows store the literal `'solid'`/`'dashed'`/`'dotted'`. + **Element defaults vs. selected-shape edits:** `src/utils/applyProperty.ts` (`createApplyProperty`) is the single mutation path behind `elementProperties.tsx`. Every property change (1) updates the matching default via `useElementDefaults` setters, then (2) if a shape is selected, applies the same change to that shape. So editing a property with nothing selected just sets the default; editing with a shape selected sets both. Defaults store `null` for `strokeType: 'solid'` (matching what `primary.tsx` feeds new shapes); DB rows store the literal `'solid'`/`'dashed'`/`'dotted'`. - **`common/`**: Shared utility components - `button.tsx` - Base button component @@ -302,7 +302,7 @@ See detailed notes in `.claude/context/` for feature-specific implementation det - `.claude/context/floating-toolbar.md` - Floating toolbar activation and structure - `.claude/context/undo-history.md` - Undo/history stack: action entry shapes, `recordToHistoryLog`, and `undoLastAction()` as the canonical rollback for any failed mutation - `.claude/context/responsive-design.md` - When to use Tailwind responsive prefixes vs `useMediaQueryUtils` hook; breakpoint values for both; the core decision rule -- `.claude/context/font-guide.md` - Font system: Geist (UI chrome), Fraunces (branding/headings), Caveat (canvas sketch); CSS variables, Tailwind config, and usage rules per area +- `.claude/context/font-guide.md` - Font system: Geist (UI chrome), Fraunces (branding/headings), Caveat Brush (canvas sketch); CSS variables, Tailwind config, and usage rules per area ### Component schema (from DB) diff --git a/index.html b/index.html index 982af9c..3041554 100644 --- a/index.html +++ b/index.html @@ -23,7 +23,7 @@ Craftbase - minimal whiteboard for builders diff --git a/src/assets/cards.svg b/src/assets/cards.svg new file mode 100644 index 0000000..24f4cff --- /dev/null +++ b/src/assets/cards.svg @@ -0,0 +1 @@ + Cards \ No newline at end of file diff --git a/src/assets/chevron-down.svg b/src/assets/chevron-down.svg new file mode 100644 index 0000000..fa3c294 --- /dev/null +++ b/src/assets/chevron-down.svg @@ -0,0 +1 @@ + Chevron Down \ No newline at end of file diff --git a/src/assets/chevron-right.svg b/src/assets/chevron-right.svg new file mode 100644 index 0000000..eb7b08e --- /dev/null +++ b/src/assets/chevron-right.svg @@ -0,0 +1 @@ + Right \ No newline at end of file diff --git a/src/assets/chevron-up.svg b/src/assets/chevron-up.svg new file mode 100644 index 0000000..8f222fb --- /dev/null +++ b/src/assets/chevron-up.svg @@ -0,0 +1 @@ + Chevron Up \ No newline at end of file diff --git a/src/assets/chevrons-down.svg b/src/assets/chevrons-down.svg new file mode 100644 index 0000000..c8c2d49 --- /dev/null +++ b/src/assets/chevrons-down.svg @@ -0,0 +1 @@ + Chevrons Down \ No newline at end of file diff --git a/src/assets/chevrons-up.svg b/src/assets/chevrons-up.svg new file mode 100644 index 0000000..d46c978 --- /dev/null +++ b/src/assets/chevrons-up.svg @@ -0,0 +1 @@ + Chevrons Up \ No newline at end of file diff --git a/src/assets/layers.svg b/src/assets/layers.svg new file mode 100644 index 0000000..beb513b --- /dev/null +++ b/src/assets/layers.svg @@ -0,0 +1 @@ + Layers \ No newline at end of file diff --git a/src/canvas/selectionController.ts b/src/canvas/selectionController.ts index 7b92044..a5a07fd 100644 --- a/src/canvas/selectionController.ts +++ b/src/canvas/selectionController.ts @@ -313,6 +313,17 @@ export default class SelectionController { } } + /** + * Public re-assert of the selection overlay to the top of the scene. + * Called by the z-order reconcile after it re-sorts element groups, so the + * selection box never gets buried beneath a just-reordered element. No-op + * when nothing is selected. + */ + bringSelectionToFront(): void { + if (!this.currentGroup) return + this._bringToFront() + } + attach(group: GroupLike, shape?: ShapeLike): boolean { const type = group?.elementData?.componentType const adapter = SHAPE_ADAPTERS[type] diff --git a/src/components/canvasContextMenu.tsx b/src/components/canvasContextMenu.tsx index 905a0ec..75ff285 100644 --- a/src/components/canvasContextMenu.tsx +++ b/src/components/canvasContextMenu.tsx @@ -1,31 +1,102 @@ import { useEffect, useRef, useState } from 'react' -import type { ReactElement } from 'react' +import type { ReactElement, FunctionComponent, SVGProps } from 'react' import Portal from './common/portal' +import { isMac } from '../utils/misc' +import LayersIcon from '../assets/layers.svg?react' +import ChevronUpIcon from '../assets/chevron-up.svg?react' +import ChevronsUpIcon from '../assets/chevrons-up.svg?react' +import ChevronDownIcon from '../assets/chevron-down.svg?react' +import ChevronsDownIcon from '../assets/chevrons-down.svg?react' +import ChevronRightIcon from '../assets/chevron-right.svg?react' + +export type ReorderOp = 'front' | 'forward' | 'backward' | 'back' interface CanvasContextMenuProps { x: number y: number onClose: () => void onExportSvg: () => void + onReorder: (op: ReorderOp) => void } const MENU_WIDTH = 220 +const SUBMENU_WIDTH = 212 const MENU_MARGIN = 8 +// Shared icon tone — matches the menu's ink-mid text. The source SVGs hardcode +// a blue stroke; SVGR spreads props after the original attrs, so this wins. +const ICON_STROKE = '#8C7E6A' + +const itemClass = + `w-full flex items-center justify-between px-3 py-2 mx-0 text-sm text-ink-mid ` + + `hover:bg-accent/30 rounded cursor-pointer transition-colors ease-in-out duration-150` + +const shortcutClass = 'text-[10px] text-ink-muted tracking-wide' + +// Format a shortcut for the host OS: compact symbols on mac (⌘]), the +// conventional spelled-out form elsewhere (Ctrl+]). Kept in sync with the +// keyboard handler in newCanvas.tsx, which acts on metaKey on mac / ctrlKey on +// the rest. Reorder uses bare brackets for forward/back-one and ⌘/Ctrl+bracket +// for to-front/to-back (no Shift — ⌘⇧[/] are reserved tab-switch on mac Chrome). +export const fmtShortcut = ( + key: string, + { cmd = false, shift = false }: { cmd?: boolean; shift?: boolean } = {} +): string => + isMac + ? `${cmd ? '⌘' : ''}${shift ? '⇧' : ''}${key}` + : `${cmd ? 'Ctrl+' : ''}${shift ? 'Shift+' : ''}${key}` + +type ReorderItem = { + op: ReorderOp + label: string + shortcut: string + Icon: FunctionComponent> +} + +const REORDER_ITEMS: ReorderItem[] = [ + { + op: 'front', + label: 'Bring to Front', + shortcut: fmtShortcut(']', { cmd: true }), + Icon: ChevronsUpIcon, + }, + { + op: 'forward', + label: 'Bring Forward', + shortcut: fmtShortcut(']'), + Icon: ChevronUpIcon, + }, + { + op: 'backward', + label: 'Send Backward', + shortcut: fmtShortcut('['), + Icon: ChevronDownIcon, + }, + { + op: 'back', + label: 'Send to Back', + shortcut: fmtShortcut('[', { cmd: true }), + Icon: ChevronsDownIcon, + }, +] + /** * Small fixed-position menu opened on canvas right-click / two-finger tap. * Closes on outside click or Escape. Positioned at the cursor and clamped to - * the viewport so it never overflows off-screen. + * the viewport so it never overflows off-screen. The Reorder entry opens a + * flyout submenu to the right (or left, near the screen edge). */ const CanvasContextMenu = ({ x, y, onClose, onExportSvg, + onReorder, }: CanvasContextMenuProps): ReactElement => { const refNode = useRef(null) const [height, setHeight] = useState(0) + const [reorderOpen, setReorderOpen] = useState(false) useEffect(() => { if (refNode.current) setHeight(refNode.current.offsetHeight) @@ -48,10 +119,13 @@ const CanvasContextMenu = ({ }, [onClose]) const left = Math.min(x, window.innerWidth - MENU_WIDTH - MENU_MARGIN) - const top = Math.min( - y, - window.innerHeight - (height || 60) - MENU_MARGIN - ) + const top = Math.min(y, window.innerHeight - (height || 60) - MENU_MARGIN) + const clampedLeft = Math.max(MENU_MARGIN, left) + + // Flip the flyout to the left when there isn't room on the right. + const openLeft = + clampedLeft + MENU_WIDTH + SUBMENU_WIDTH + MENU_MARGIN > + window.innerWidth return ( @@ -59,17 +133,96 @@ const CanvasContextMenu = ({ ref={refNode} className="fixed z-[100] bg-card-bg border border-border-panel rounded-lg shadow-lg py-1" style={{ - left: Math.max(MENU_MARGIN, left), + left: clampedLeft, top: Math.max(MENU_MARGIN, top), width: MENU_WIDTH, }} > +
setReorderOpen(true)} + onMouseLeave={() => setReorderOpen(false)} + > + + + {reorderOpen && ( +
+ {REORDER_ITEMS.map( + ({ op, label, shortcut, Icon }) => ( + + ) + )} +
+ )} +
+ +
+
diff --git a/src/components/elements/groupobject.tsx b/src/components/elements/groupobject.tsx index e216764..ea8abdf 100644 --- a/src/components/elements/groupobject.tsx +++ b/src/components/elements/groupobject.tsx @@ -15,6 +15,37 @@ type ElementProps = any // eslint-disable-next-line @typescript-eslint/no-explicit-any type ShapeLike = any +// Child element factories load asynchronously, so group.add() can fire out of +// array order on first grouping. Re-sort the group's children by their stored +// z-order `position` (back→front) after every add so the within-group stacking +// always matches the canvas — independent of load timing. The transparent +// selector rectangle (no elementData) is pinned to the back. group.children +// .sort fires Two.js's 'order' event so the SVG nodes physically reorder. +const orderGroupChildrenByZ = (group: ShapeLike): void => { + group.children.sort((a: ShapeLike, b: ShapeLike) => { + const aHas = !!a?.elementData + const bHas = !!b?.elementData + if (aHas !== bHas) return aHas ? 1 : -1 + const pa = Number.isFinite(a?.elementData?.position) + ? a.elementData.position + : 0 + const pb = Number.isFinite(b?.elementData?.position) + ? b.elementData.position + : 0 + if (pa !== pb) return pa - pb + const ca = Number.isFinite(a?.elementData?.createdAt) + ? a.elementData.createdAt + : 0 + const cb = Number.isFinite(b?.elementData?.createdAt) + ? b.elementData.createdAt + : 0 + if (ca !== cb) return ca - cb + return String(a?.elementData?.id ?? '').localeCompare( + String(b?.elementData?.id ?? '') + ) + }) +} + function GroupedObjectWrapper(props: ElementProps): ReactElement { const { addToLocalComponentStore, @@ -387,6 +418,7 @@ function GroupedObjectWrapper(props: ElementProps): ReactElement { coreObject.elementData = item group.add(coreObject) + orderGroupChildrenByZ(group) two.update() }) } diff --git a/src/newCanvas.tsx b/src/newCanvas.tsx index 3315ef6..f8b36a0 100644 --- a/src/newCanvas.tsx +++ b/src/newCanvas.tsx @@ -80,6 +80,7 @@ import { growShapeToFitText, usableTextWidth } from './utils/shapeTextFit' import { isSelectPanMode, isPanMode } from './utils/drawModeUtils' import { createDiamondPath } from './factory/diamond' import { useCanvasClipboard } from './hooks/useCanvasClipboard' +import type { HistoryEntry } from './hooks/useComponentHistory' import { exportSelectionAsSvg } from './utils/exportSelectionAsSvg' import CanvasContextMenu from './components/canvasContextMenu' import { @@ -159,6 +160,40 @@ let defaultStrokeColorValue: string = PENCIL_DEFAULT_COLOR // (same stale-closure escape hatch as defaultLinewidthValue). let defaultTextSizeValue: number = DEFAULT_TEXT_SIZE +// --- z-order reconcile helpers ------------------------------------------ +// +// The persistable element groups inside `two.scene.children` are the ones we +// reorder. Everything else (selection-box overlay, preview dots/lines, the +// transient `groupobject` group) must be left untouched. An element group is +// identified by carrying `elementData.id` that maps to a live store record and +// is not the transient GROUP_COMPONENT. The selection overlay is a plain group +// with no `elementData`, so it's excluded automatically. +const isReorderableElementChild = ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + child: any, + store: ComponentStore +): boolean => { + const id = child?.elementData?.id + if (!id) return false + if (child.elementData.componentType === GROUP_COMPONENT) return false + return Object.prototype.hasOwnProperty.call(store, id) +} + +// Stable ordering key for a record: position asc (back→front), tie-broken by +// createdAt then id so legacy/duplicate positions still sort deterministically +// (and neighbour-swap stays meaningful). Used by both the reconcile comparator +// and the reorder handlers so they agree on "the element above/below". +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const compareByZOrder = (a: any, b: any): number => { + const pa = Number.isFinite(a?.position) ? a.position : 0 + const pb = Number.isFinite(b?.position) ? b.position : 0 + if (pa !== pb) return pa - pb + const ca = Number.isFinite(a?.createdAt) ? a.createdAt : 0 + const cb = Number.isFinite(b?.createdAt) ? b.createdAt : 0 + if (ca !== cb) return ca - cb + return String(a?.id ?? '').localeCompare(String(b?.id ?? '')) +} + function addZUI( props: CanvasProps, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -631,7 +666,8 @@ function addZUI( input.style.padding = `${vertPad}px 8px` input.style.color = textStyle.fill || '#3A342C' input.style.fontSize = `${cssFontSize}px` - input.style.fontFamily = textStyle.family || DEFAULT_TEXT_FONT_FAMILY + input.style.fontFamily = + textStyle.family || DEFAULT_TEXT_FONT_FAMILY input.style.fontWeight = String(textStyle.weight ?? 'normal') input.style.lineHeight = `${lineH}px` input.style.letterSpacing = '0px' @@ -2648,6 +2684,8 @@ function addZUI( selectionController.currentGroup || activeGroupRef.current || lastSelectedShape, + bringSelectionToFront: () => + selectionController.bringSelectionToFront(), } } @@ -2669,6 +2707,7 @@ const Canvas: React.FC = (props) => { geoObjectsEnabled, undoLastAction, redoLastAction, + recordBatchToHistoryLog, enableTextDrawMode, createTextAtSurface, } = useBoardContext() @@ -2863,6 +2902,112 @@ const Canvas: React.FC = (props) => { } }, []) + // Handle for an in-flight z-order reconcile poll so a new store change can + // cancel a stale one instead of stacking rAF loops. + const zOrderPollRef = useRef(null) + + // Deterministically re-sort the element groups inside two.scene.children by + // their store `position` (back→front). Element mounting is async (React.lazy + // + Suspense), so groups land in the scene in unpredictable order — this is + // the single source of truth that fixes the post-refresh z-order. Reads live + // state from refs (stale-closure rule); idempotent and safe to re-run. + const reconcileZOrder = useCallback(() => { + const two = twoJSInstance + const store = stateRefForComponentStore.current + if (!two?.scene || !store) return + const scene = two.scene + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const children = scene.children as any[] + + // Build the desired final order: element groups sorted by their store + // position (back→front) dropped back into the index slots they already + // occupy, while non-element children (selection box, previews) keep + // their slots. A rank map gives a *total* order — returning 0 for + // mixed pairs would break sort's transitivity contract and could + // mis-order elements separated by an overlay. + const sortedEls = children + .filter((c) => isReorderableElementChild(c, store)) + .sort((a, b) => + compareByZOrder( + store[a.elementData.id], + store[b.elementData.id] + ) + ) + let e = 0 + const desired = children.map((c) => + isReorderableElementChild(c, store) ? sortedEls[e++] : c + ) + const rank = new Map() + desired.forEach((c, i) => rank.set(c, i)) + + // Collection.sort fires the 'order' event that flags the SVG renderer + // to physically reorder the nodes — a bare splice would NOT. + children.sort((a, b) => (rank.get(a) ?? 0) - (rank.get(b) ?? 0)) + + // The just-sorted elements may have buried the selection overlay — lift + // it back on top so the active selection box stays visible. + zuiInstanceRef.current?.bringSelectionToFront?.() + + try { + two.update() + } catch (err) { + // A concurrent mount/cleanup could leave a stale subtraction queued; + // clear it so future updates don't keep retrying the broken op (see + // the scene.subtractions pitfall in CLAUDE.md). + console.warn('reconcileZOrder two.update failed', err) + scene.subtractions.length = 0 + scene._flagSubtractions = false + } + }, [twoJSInstance]) + + // Element groups appear in the scene over several frames as their lazy + // chunks resolve. Poll a few frames, reconciling each tick, until every + // expected element group is present (or a frame cap as a safety stop). + const startZOrderReconcilePoll = useCallback(() => { + if (zOrderPollRef.current !== null) { + cancelAnimationFrame(zOrderPollRef.current) + zOrderPollRef.current = null + } + const two = twoJSInstance + const store = stateRefForComponentStore.current + if (!two?.scene || !store) return + + // Expected = store records that are reorderable AND map to a known + // element module (others are skipped by handleSetComponentsToRender). + const expected = Object.values(store).filter( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (r: any) => + r?.componentType !== GROUP_COMPONENT && + !!elementModules[ + `./components/elements/${r?.componentType}.tsx` + ] + ).length + + let frame = 0 + const MAX_FRAMES = 90 + const tick = (): void => { + zOrderPollRef.current = null + reconcileZOrder() + const present = (two.scene.children as unknown[]).filter((c) => + isReorderableElementChild(c, store) + ).length + frame += 1 + if (present < expected && frame < MAX_FRAMES) { + zOrderPollRef.current = requestAnimationFrame(tick) + } + } + zOrderPollRef.current = requestAnimationFrame(tick) + }, [twoJSInstance, reconcileZOrder]) + + useEffect( + () => () => { + if (zOrderPollRef.current !== null) { + cancelAnimationFrame(zOrderPollRef.current) + } + }, + [] + ) + useEffect(() => { stateRefForComponentStore.current = props.componentStore if (twoJSInstance !== null && zuiInstance !== null) { @@ -2879,6 +3024,9 @@ const Canvas: React.FC = (props) => { if (Object.values(props.componentStore).length > 0 && twoJSInstance) { handleSetComponentsToRender(Object.values(props.componentStore)) + // Re-assert deterministic z-order once the (async) element mounts + // settle — this is what fixes the post-refresh ordering bug. + startZOrderReconcilePoll() } }, [props.componentStore]) @@ -2940,8 +3088,15 @@ const Canvas: React.FC = (props) => { const newChildren: any[] = [] const selectedComponentArr: string[] = [] + // Iterate in global z-order (position asc = back→front) so the + // group's internal child order mirrors the canvas stacking. The + // group adds children in array order (groupobject.tsx) where + // index 0 is the backmost, so feeding them sorted keeps grouped + // elements visually consistent with their ungrouped positions. const allComponentCoords = stateRefForComponentStore.current - ? Object.values(stateRefForComponentStore.current) + ? Object.values(stateRefForComponentStore.current).sort( + compareByZOrder + ) : [] allComponentCoords.forEach((item) => { if ( @@ -3100,6 +3255,78 @@ const Canvas: React.FC = (props) => { } }, []) + // Change the z-order of the currently-selected element. We move by *index* + // in the deterministic sorted order, then renumber every row to a dense, + // distinct position (0..n-1). The old approach swapped position *values*, + // which silently no-ops whenever neighbours tie — and most legacy rows share + // position 0 (position is only assigned to newly-created elements), so once a + // shape stepped into the 0-block it could never come back. Renumbering + // self-heals that degeneracy; only rows whose position actually changes are + // written, so steady-state single-step moves touch just the couple that + // shifted. The whole renumber is recorded as one BATCH = one undo step. + const reorderSelected = useCallback( + (op: 'front' | 'forward' | 'backward' | 'back') => { + const store = stateRefForComponentStore.current + if (!store) return + const id = + zuiInstanceRef.current?.getSelectedGroup?.()?.elementData?.id + if (!id || !store[id]) return + + const sorted = Object.values(store) + .filter((r) => r.componentType !== GROUP_COMPONENT) + .sort(compareByZOrder) + const n = sorted.length + if (n === 0) return + const idx = sorted.findIndex((r) => r.id === id) + if (idx === -1) return + + // Target slot for the selected element in the final back→front order. + const target = + op === 'front' + ? n - 1 + : op === 'back' + ? 0 + : op === 'forward' + ? idx + 1 + : idx - 1 // backward + if (target < 0 || target > n - 1 || target === idx) return // at edge + + // Rebuild the order with the selected element moved to `target`. + const newOrder = sorted.slice() + const [moved] = newOrder.splice(idx, 1) + if (!moved) return + newOrder.splice(target, 0, moved) + + // Assign dense distinct positions; write + record only what changed. + const batch: HistoryEntry[] = [] + newOrder.forEach((r, i) => { + const prev = Number.isFinite(r.position) + ? (r.position as number) + : 0 + if (prev === i) return + updateComponentBulkPropertiesInLocalStore( + r.id, + { position: i }, + true + ) + batch.push({ + action: 'UPDATE_BULK', + id: r.id, + prevProps: { position: prev }, + bulkObj: { position: i }, + }) + }) + if (batch.length > 0) recordBatchToHistoryLog(batch) + + reconcileZOrder() + }, + [ + updateComponentBulkPropertiesInLocalStore, + recordBatchToHistoryLog, + reconcileZOrder, + ] + ) + // Right-click (mouse) and two-finger trackpad tap both fire the native // 'contextmenu' event. Suppress the OS menu; if something is selected, open // our menu at the cursor. Cmd/Ctrl+Shift+D triggers the same export. @@ -3130,13 +3357,50 @@ const Canvas: React.FC = (props) => { void exportActiveSelection() } + // Reorder shortcuts: + // [ / ] → backward / forward (one step) + // ⌘[ / ⌘] → to back / to front + // We deliberately avoid ⌘⇧[/⌘⇧] here: on macOS Chrome those are the + // reserved "switch tab" accelerators (native app-menu key equivalents) + // and preventDefault() can't cancel them — the page never wins. Detect + // brackets via code OR key so non-US layouts resolve too. Only hijack + // the key when a shape is actually selected, so bare [ /] stay inert and + // ⌘[ /⌘] keep their browser history-nav behaviour on an empty selection. + const onReorderKeyDown = (evt: KeyboardEvent) => { + if (evt.shiftKey || evt.altKey) return + const isRight = + evt.code === 'BracketRight' || + evt.key === ']' || + evt.key === '}' + const isLeft = + evt.code === 'BracketLeft' || evt.key === '[' || evt.key === '{' + if (!isRight && !isLeft) return + const el = document.activeElement as HTMLElement | null + const tag = el?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA' || el?.isContentEditable) + return + if (!zuiInstanceRef.current?.getSelectedGroup?.()) return + evt.preventDefault() + const withCmd = evt.metaKey || evt.ctrlKey + const op: 'front' | 'forward' | 'backward' | 'back' = isRight + ? withCmd + ? 'front' + : 'forward' + : withCmd + ? 'back' + : 'backward' + reorderSelected(op) + } + root.addEventListener('contextmenu', onContextMenu) window.addEventListener('keydown', onExportKeyDown) + window.addEventListener('keydown', onReorderKeyDown) return () => { root.removeEventListener('contextmenu', onContextMenu) window.removeEventListener('keydown', onExportKeyDown) + window.removeEventListener('keydown', onReorderKeyDown) } - }, [exportActiveSelection]) + }, [exportActiveSelection, reorderSelected]) // eslint-disable-next-line @typescript-eslint/no-explicit-any const setOnGroupHandler = (obj: any) => { @@ -3309,6 +3573,10 @@ const Canvas: React.FC = (props) => { setCtxMenu(null) void exportActiveSelection() }} + onReorder={(op) => { + setCtxMenu(null) + reorderSelected(op) + }} /> )} diff --git a/src/schema/queries/index.ts b/src/schema/queries/index.ts index 963657f..c495df4 100644 --- a/src/schema/queries/index.ts +++ b/src/schema/queries/index.ts @@ -49,6 +49,7 @@ export const GET_COMPONENTS_FOR_BOARD_QUERY: TypedDocumentNode< query getComponentsForBoard($boardId: uuid = "") { components: components_component( where: { boardId: { _eq: $boardId } } + order_by: { position: asc } ) { id componentType @@ -69,6 +70,7 @@ export const GET_COMPONENTS_FOR_BOARD_QUERY: TypedDocumentNode< linewidth strokeType textColor + position } } ` diff --git a/src/types/board.ts b/src/types/board.ts index c4569db..a0019b6 100644 --- a/src/types/board.ts +++ b/src/types/board.ts @@ -46,6 +46,14 @@ export interface ComponentRecord { isDummy: boolean | null updatedBy: string | null createdAt: number | null + /** + * Z-order key (back→front). Lower draws first (behind), higher draws on + * top — matching Two.js `scene.children` where index 0 is the back. New + * elements get `max(position)+1` (assigned in addToLocalComponentStore). + * Optional/nullable: legacy DB rows and directly-seeded records (e.g. the + * welcome sketch) may omit it; the z-order reconcile treats absent as 0. + */ + position?: number | null } export type ComponentStore = Record diff --git a/src/utils/misc.ts b/src/utils/misc.ts index 2a63d11..01e1159 100644 --- a/src/utils/misc.ts +++ b/src/utils/misc.ts @@ -1,5 +1,18 @@ import type { RandomUsername } from '../types/board' +// True on macOS/iOS, where ⌘ (metaKey) is the primary shortcut modifier; Ctrl +// elsewhere. Prefer the modern userAgentData.platform, fall back to the legacy +// navigator.platform. Computed once at module load — the OS doesn't change. +export const isMac: boolean = + typeof navigator !== 'undefined' && + /mac|iphone|ipad|ipod/i.test( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (navigator as any).userAgentData?.platform || navigator.platform || '' + ) + +/** Display label for the primary shortcut modifier: '⌘' on mac, 'Ctrl' else. */ +export const PRIMARY_MOD_LABEL: string = isMac ? '⌘' : 'Ctrl' + export function strokeTypeToDashes(strokeType: string | null | undefined): number[] { if (strokeType === 'dashed') return [8] if (strokeType === 'dotted') return [4] diff --git a/src/views/Board/board.tsx b/src/views/Board/board.tsx index 6111abf..cef834e 100644 --- a/src/views/Board/board.tsx +++ b/src/views/Board/board.tsx @@ -615,6 +615,23 @@ const BoardViewPage: React.FC = (props) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any } = (componentInfo ?? {}) as any + // Assign a z-order position so the element renders deterministically + // (and survives a refresh). New elements go on top: max(position)+1. + // Computed from the synchronous ref — not `componentStore` state — + // so back-to-back adds (rapid drawing, multi-paste) get increasing + // positions instead of colliding. A pre-set position is preserved so + // undo-of-delete and clipboard paste keep their original stacking. + if (safeInfo.position == null) { + const maxPos = Object.values( + stateRefForComponentStore.current + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ).reduce((m: number, c: any) => { + const p = c?.position + return Number.isFinite(p) ? Math.max(m, p) : m + }, 0) + 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. diff --git a/tests/e2e/reorder.spec.js b/tests/e2e/reorder.spec.js new file mode 100644 index 0000000..9dec558 --- /dev/null +++ b/tests/e2e/reorder.spec.js @@ -0,0 +1,182 @@ +import { test, expect } from './helpers/test.js' +import { + setupLocalBoard, + getCanvasBox, + drawShape, + clickPointerTool, +} from './helpers/index.js' + +/** + * Z-order reorder suite (Bring Forward / Bring to Front / Send Backward / + * Send to Back). + * + * Every test seeds the SAME three non-overlapping shapes, inserted in this + * order: circle (1st) → rectangle (2nd) → diamond (3rd). New elements stack on + * top (position = max+1), so the initial z-order, back→front, is: + * + * [circle, rectangle, diamond] + * + * We assert against the RENDERED SVG, not the store/draft. Each shape group is + * a sibling under the Two.js scene root (), + * and reconcileZOrder physically reorders those sibling nodes. So the document + * order of [data-component-id] nodes IS the z-order (back→front) — exactly the + * nested- approach described in the hint. + * + * All reorder operations are performed on the FIRST element (circle) only. The + * circle starts backmost, so Send Backward / Send to Back would be no-ops on + * it; those two tests first bring the circle to front (still a circle-only op) + * to create a meaningful starting state, then apply the operation under test. + * + * Shortcuts (post tab-switch-collision fix): bare [ /] = backward/forward, + * ⌘[ /⌘] = to back / to front. The handler accepts metaKey OR ctrlKey, so + * Meta+[ / Meta+] work on Linux/CI too. + */ + +// Draw the three shapes spread across the lower band so each is clickable on +// its own centre (z-order is independent of x/y, but non-overlap guarantees a +// centre-click selects the intended shape). Kept clear of the top toolbar +// (~y < 60) and the left Defaults panel (~x < 160). +async function setupThreeShapes(page) { + const box = await getCanvasBox(page) + const cy = box.y + box.height * 0.6 + const half = 40 + + const centres = { + circle: box.x + box.width * 0.3, + rectangle: box.x + box.width * 0.5, + diamond: box.x + box.width * 0.7, + } + + const drawAt = (type) => + drawShape(page, type, { + startX: centres[type] - half, + startY: cy - half, + endX: centres[type] + half, + endY: cy + half, + }) + + // Insert in the required order: circle → rectangle → diamond. + const circle = await drawAt('circle') + const rectangle = await drawAt('rectangle') + const diamond = await drawAt('diamond') + + const ids = { + circle: await circle.getAttribute('data-component-id'), + rectangle: await rectangle.getAttribute('data-component-id'), + diamond: await diamond.getAttribute('data-component-id'), + } + return { handles: { circle, rectangle, diamond }, ids } +} + +// Document (z) order of our three shapes, back→front. Filtered to the known +// ids so any selection-overlay nodes are ignored. +async function getZOrder(page, ids) { + const order = await page.$$eval( + '#main-two-root svg [data-component-id]', + (els) => els.map((e) => e.getAttribute('data-component-id')) + ) + const known = new Set(Object.values(ids)) + return order.filter((id) => known.has(id)) +} + +// Select a single shape by clicking its centre in Pointer mode. Waits for the +// floating toolbar so we know selectionController.currentGroup is set (which is +// what getSelectedGroup — and therefore the reorder shortcuts — read). +async function selectShape(page, handle) { + await clickPointerTool(page) + const b = await handle.boundingBox() + await page.mouse.click(b.x + b.width / 2, b.y + b.height / 2) + await page.waitForSelector('#floating-toolbar') +} + +// Map our three ids to readable labels for nicer assertion diffs. +function asLabels(order, ids) { + const byId = { + [ids.circle]: 'circle', + [ids.rectangle]: 'rectangle', + [ids.diamond]: 'diamond', + } + return order.map((id) => byId[id]) +} + +test.describe('Reorder — z-order operations on the first element (circle)', () => { + test.beforeEach(async ({ page }) => { + await setupLocalBoard(page) + }) + + test('initial insertion order is circle → rectangle → diamond (back→front)', async ({ + page, + }) => { + const { ids } = await setupThreeShapes(page) + const order = await getZOrder(page, ids) + expect(asLabels(order, ids)).toEqual(['circle', 'rectangle', 'diamond']) + }) + + test('Bring Forward (]) moves circle ahead of rectangle but behind diamond', async ({ + page, + }) => { + const { handles, ids } = await setupThreeShapes(page) + await selectShape(page, handles.circle) + + await page.keyboard.press(']') + + await expect + .poll(async () => asLabels(await getZOrder(page, ids), ids)) + .toEqual(['rectangle', 'circle', 'diamond']) + }) + + test('Bring to Front (⌘]) moves circle ahead of both rectangle and diamond', async ({ + page, + }) => { + const { handles, ids } = await setupThreeShapes(page) + await selectShape(page, handles.circle) + + await page.keyboard.press('Meta+]') + + await expect + .poll(async () => asLabels(await getZOrder(page, ids), ids)) + .toEqual(['rectangle', 'diamond', 'circle']) + }) + + test('Send Backward ([) moves circle behind diamond but ahead of rectangle', async ({ + page, + }) => { + const { handles, ids } = await setupThreeShapes(page) + await selectShape(page, handles.circle) + + // Setup: bring circle to front so a backward step is meaningful. + // [circle, rectangle, diamond] -> [rectangle, diamond, circle] + await page.keyboard.press('Meta+]') + await expect + .poll(async () => asLabels(await getZOrder(page, ids), ids)) + .toEqual(['rectangle', 'diamond', 'circle']) + + // Operation under test: one step back. + // [rectangle, diamond, circle] -> [rectangle, circle, diamond] + await page.keyboard.press('[') + await expect + .poll(async () => asLabels(await getZOrder(page, ids), ids)) + .toEqual(['rectangle', 'circle', 'diamond']) + }) + + test('Send to Back (⌘[) moves circle behind both rectangle and diamond', async ({ + page, + }) => { + const { handles, ids } = await setupThreeShapes(page) + await selectShape(page, handles.circle) + + // Setup: bring circle to front so a send-to-back is meaningful. + // [circle, rectangle, diamond] -> [rectangle, diamond, circle] + await page.keyboard.press('Meta+]') + await expect + .poll(async () => asLabels(await getZOrder(page, ids), ids)) + .toEqual(['rectangle', 'diamond', 'circle']) + + // Operation under test: send all the way to the back. + // [rectangle, diamond, circle] -> [circle, rectangle, diamond] + await page.keyboard.press('Meta+[') + await expect + .poll(async () => asLabels(await getZOrder(page, ids), ids)) + .toEqual(['circle', 'rectangle', 'diamond']) + }) +}) From f07e2e1a56f542892fe20b2c2ad99178697730d8 Mon Sep 17 00:00:00 2001 From: meetzaveri Date: Sun, 7 Jun 2026 14:08:35 +0530 Subject: [PATCH 2/3] update: add reorder option to edit toolbar (elementProperties) --- src/assets/bring-forward.svg | 1 + src/assets/bring-to-front.svg | 1 + src/assets/chevron-down.svg | 1 - src/assets/chevron-up.svg | 1 - src/assets/chevrons-down.svg | 1 - src/assets/chevrons-up.svg | 1 - src/assets/send-backward.svg | 1 + src/assets/send-to-back.svg | 1 + src/components/canvasContextMenu.tsx | 16 +- src/components/common/tooltip.tsx | 193 +++++ .../sidebar/elementProperties copy.tsx | 727 ++++++++++++++++++ src/components/sidebar/elementProperties.tsx | 58 ++ src/newCanvas.tsx | 16 + src/types/board.ts | 5 + src/views/Board/board.tsx | 16 + 15 files changed, 1027 insertions(+), 12 deletions(-) create mode 100644 src/assets/bring-forward.svg create mode 100644 src/assets/bring-to-front.svg delete mode 100644 src/assets/chevron-down.svg delete mode 100644 src/assets/chevron-up.svg delete mode 100644 src/assets/chevrons-down.svg delete mode 100644 src/assets/chevrons-up.svg create mode 100644 src/assets/send-backward.svg create mode 100644 src/assets/send-to-back.svg create mode 100644 src/components/common/tooltip.tsx create mode 100644 src/components/sidebar/elementProperties copy.tsx diff --git a/src/assets/bring-forward.svg b/src/assets/bring-forward.svg new file mode 100644 index 0000000..91e16ef --- /dev/null +++ b/src/assets/bring-forward.svg @@ -0,0 +1 @@ + Bring Forward 1 \ No newline at end of file diff --git a/src/assets/bring-to-front.svg b/src/assets/bring-to-front.svg new file mode 100644 index 0000000..b778cd3 --- /dev/null +++ b/src/assets/bring-to-front.svg @@ -0,0 +1 @@ + Bring to Front N \ No newline at end of file diff --git a/src/assets/chevron-down.svg b/src/assets/chevron-down.svg deleted file mode 100644 index fa3c294..0000000 --- a/src/assets/chevron-down.svg +++ /dev/null @@ -1 +0,0 @@ - Chevron Down \ No newline at end of file diff --git a/src/assets/chevron-up.svg b/src/assets/chevron-up.svg deleted file mode 100644 index 8f222fb..0000000 --- a/src/assets/chevron-up.svg +++ /dev/null @@ -1 +0,0 @@ - Chevron Up \ No newline at end of file diff --git a/src/assets/chevrons-down.svg b/src/assets/chevrons-down.svg deleted file mode 100644 index c8c2d49..0000000 --- a/src/assets/chevrons-down.svg +++ /dev/null @@ -1 +0,0 @@ - Chevrons Down \ No newline at end of file diff --git a/src/assets/chevrons-up.svg b/src/assets/chevrons-up.svg deleted file mode 100644 index d46c978..0000000 --- a/src/assets/chevrons-up.svg +++ /dev/null @@ -1 +0,0 @@ - Chevrons Up \ No newline at end of file diff --git a/src/assets/send-backward.svg b/src/assets/send-backward.svg new file mode 100644 index 0000000..38bc83e --- /dev/null +++ b/src/assets/send-backward.svg @@ -0,0 +1 @@ + Send Backward 1 \ No newline at end of file diff --git a/src/assets/send-to-back.svg b/src/assets/send-to-back.svg new file mode 100644 index 0000000..e080ffe --- /dev/null +++ b/src/assets/send-to-back.svg @@ -0,0 +1 @@ + Send to Back N \ No newline at end of file diff --git a/src/components/canvasContextMenu.tsx b/src/components/canvasContextMenu.tsx index 75ff285..9337d72 100644 --- a/src/components/canvasContextMenu.tsx +++ b/src/components/canvasContextMenu.tsx @@ -4,10 +4,10 @@ import type { ReactElement, FunctionComponent, SVGProps } from 'react' import Portal from './common/portal' import { isMac } from '../utils/misc' import LayersIcon from '../assets/layers.svg?react' -import ChevronUpIcon from '../assets/chevron-up.svg?react' -import ChevronsUpIcon from '../assets/chevrons-up.svg?react' -import ChevronDownIcon from '../assets/chevron-down.svg?react' -import ChevronsDownIcon from '../assets/chevrons-down.svg?react' +import BringToFrontIcon from '../assets/bring-to-front.svg?react' +import BringForwardIcon from '../assets/bring-forward.svg?react' +import SendBackwardIcon from '../assets/send-backward.svg?react' +import SendToBackIcon from '../assets/send-to-back.svg?react' import ChevronRightIcon from '../assets/chevron-right.svg?react' export type ReorderOp = 'front' | 'forward' | 'backward' | 'back' @@ -59,25 +59,25 @@ const REORDER_ITEMS: ReorderItem[] = [ op: 'front', label: 'Bring to Front', shortcut: fmtShortcut(']', { cmd: true }), - Icon: ChevronsUpIcon, + Icon: BringToFrontIcon, }, { op: 'forward', label: 'Bring Forward', shortcut: fmtShortcut(']'), - Icon: ChevronUpIcon, + Icon: BringForwardIcon, }, { op: 'backward', label: 'Send Backward', shortcut: fmtShortcut('['), - Icon: ChevronDownIcon, + Icon: SendBackwardIcon, }, { op: 'back', label: 'Send to Back', shortcut: fmtShortcut('[', { cmd: true }), - Icon: ChevronsDownIcon, + Icon: SendToBackIcon, }, ] diff --git a/src/components/common/tooltip.tsx b/src/components/common/tooltip.tsx new file mode 100644 index 0000000..4279e8b --- /dev/null +++ b/src/components/common/tooltip.tsx @@ -0,0 +1,193 @@ +import { + cloneElement, + useCallback, + useLayoutEffect, + useRef, + useState, +} from 'react' +import type { + FocusEvent, + MouseEvent, + ReactElement, + ReactNode, + Ref, +} from 'react' + +import Portal from './portal' + +export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right' + +interface TooltipProps { + /** Content shown on hover / keyboard focus. Falsy → tooltip is suppressed. */ + label: ReactNode + /** Exactly one hoverable/focusable trigger element. */ + children: ReactElement + placement?: TooltipPlacement + /** + * ms to wait before showing. Defaults to 0 so the hint appears effectively + * instantly (a short opacity fade still smooths it out). Bump it if you want + * a hover-intent delay. + */ + delay?: number + disabled?: boolean +} + +// Gap in px between the trigger and the tooltip bubble. +const GAP = 8 +// Min distance the bubble keeps from the viewport edges when clamped. +const EDGE_MARGIN = 6 + +interface Pos { + top: number + left: number +} + +/** + * Lightweight, dependency-free tooltip. + * + * - Wraps a SINGLE trigger and shows `label` on hover or keyboard focus. + * - Clones the child (no extra wrapper DOM) so the trigger keeps its slot in + * flex/grid layouts. + * - Renders the bubble through a Portal, so it is never clipped by an ancestor's + * `overflow` (e.g. the scrollable floating toolbar) and positions it with + * `position: fixed` off the trigger's viewport rect, clamped on-screen. + * + * Reuse anywhere a control needs a hint: + * + */ +const Tooltip = ({ + label, + children, + placement = 'top', + delay = 0, + disabled = false, +}: TooltipProps): ReactElement => { + const triggerRef = useRef(null) + const bubbleRef = useRef(null) + const timerRef = useRef | null>(null) + + // `anchor` holds the trigger rect while the tooltip is open; `pos` is the + // measured/clamped bubble position. Two phases avoid an off-screen flash: + // render hidden at the anchor, measure, then place + fade in. + const [anchor, setAnchor] = useState(null) + const [pos, setPos] = useState(null) + + const show = useCallback((): void => { + if (disabled || !label) return + const open = (): void => { + const el = triggerRef.current + if (el) setAnchor(el.getBoundingClientRect()) + } + if (delay <= 0) { + open() + return + } + timerRef.current = setTimeout(open, delay) + }, [delay, disabled, label]) + + const hide = useCallback((): void => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + setAnchor(null) + setPos(null) + }, []) + + useLayoutEffect(() => { + if (!anchor) return + const bubble = bubbleRef.current + if (!bubble) return + const { width: bw, height: bh } = bubble.getBoundingClientRect() + + let top: number + let left: number + switch (placement) { + case 'bottom': + top = anchor.bottom + GAP + left = anchor.left + anchor.width / 2 - bw / 2 + break + case 'left': + top = anchor.top + anchor.height / 2 - bh / 2 + left = anchor.left - GAP - bw + break + case 'right': + top = anchor.top + anchor.height / 2 - bh / 2 + left = anchor.right + GAP + break + case 'top': + default: + top = anchor.top - GAP - bh + left = anchor.left + anchor.width / 2 - bw / 2 + } + + // Keep the bubble fully on-screen. + left = Math.max( + EDGE_MARGIN, + Math.min(left, window.innerWidth - bw - EDGE_MARGIN) + ) + top = Math.max( + EDGE_MARGIN, + Math.min(top, window.innerHeight - bh - EDGE_MARGIN) + ) + setPos({ top, left }) + }, [anchor, placement]) + + // Merge our ref + open/close handlers onto the child, preserving any the + // caller already passed. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const child = children as ReactElement & { ref?: Ref } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const childProps: any = child.props + + const trigger = cloneElement(child, { + ref: (node: HTMLElement | null): void => { + triggerRef.current = node + const r = child.ref + if (typeof r === 'function') r(node) + else if (r && typeof r === 'object') + (r as { current: HTMLElement | null }).current = node + }, + onMouseEnter: (e: MouseEvent): void => { + show() + childProps.onMouseEnter?.(e) + }, + onMouseLeave: (e: MouseEvent): void => { + hide() + childProps.onMouseLeave?.(e) + }, + onFocus: (e: FocusEvent): void => { + show() + childProps.onFocus?.(e) + }, + onBlur: (e: FocusEvent): void => { + hide() + childProps.onBlur?.(e) + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) + + return ( + <> + {trigger} + {anchor && ( + +
+ {label} +
+
+ )} + + ) +} + +export default Tooltip diff --git a/src/components/sidebar/elementProperties copy.tsx b/src/components/sidebar/elementProperties copy.tsx new file mode 100644 index 0000000..7293007 --- /dev/null +++ b/src/components/sidebar/elementProperties copy.tsx @@ -0,0 +1,727 @@ +import React, { Fragment, useEffect, useMemo, useState } from 'react' + +import { useBoardContext } from '../../views/Board/boardContext' +import { useMediaQueryUtils } from '../../constants/exportHooks' +import ColorPicker from '../utils/colorPicker' +import OpacitySlider from '../utils/opacitySlider' +import Tooltip from '../common/tooltip' +import { TEXT_SIZES_ARRAY, fillEssentialShades } from '../../utils/constants' +import { MIXED, inspectGroupValues } from '../../utils/groupInspect' +import { isStandaloneTextType } from '../../constants/misc' +import type { ReorderOp } from '../canvasContextMenu' +import BringToFrontIcon from '../../assets/bring-to-front.svg?react' +import BringForwardIcon from '../../assets/bring-forward.svg?react' +import SendBackwardIcon from '../../assets/send-backward.svg?react' +import SendToBackIcon from '../../assets/send-to-back.svg?react' + +// Reorder icon tone — matches the toolbar's ink-mid text. The source SVGs +// hardcode a blue stroke; SVGR spreads props after the original attrs so this +// override wins (same trick as canvasContextMenu). +const REORDER_ICON_STROKE = '#8C7E6A' + +const REORDER_BUTTONS: { + op: ReorderOp + label: string + Icon: React.FunctionComponent> +}[] = [ + { op: 'front', label: 'Bring to Front', Icon: BringToFrontIcon }, + { op: 'forward', label: 'Bring Forward', Icon: BringForwardIcon }, + { op: 'backward', label: 'Send Backward', Icon: SendBackwardIcon }, + { op: 'back', label: 'Send to Back', Icon: SendToBackIcon }, +] + +const STROKE_TYPES = [ + { label: '—', value: 'solid' }, + { label: '- -', value: 'dashed' }, + { label: '...', value: 'dotted' }, +] + +const STROKE_WIDTHS = [ + { label: '0', value: 0, strokeHeight: '0px' }, + { label: '2', value: 2, strokeHeight: '2px' }, + { label: '4', value: 4, strokeHeight: '4px' }, + { label: '6', value: 6, strokeHeight: '6px' }, +] + +// What sections each "set" should render, in display order. +const SETS = { + SHAPE: ['fill', 'stroke', 'strokeWidth', 'strokeType', 'opacity'], + ARROW: ['stroke', 'strokeWidth', 'strokeType', 'opacity'], + PENCIL: ['stroke', 'strokeWidth', 'strokeType'], + TEXT: ['textColor', 'textSize', 'textFont', 'opacity'], + // Geo objects: stroke-centric. Area's fill is auto-derived from stroke, so + // no fill control — but its outline still takes width/type like a route. + // Point has no edit-area set: its category is chosen from the point drawer + // in the shapes toolbar (resolveSetKey returns null for points). + GEO_AREA: ['stroke', 'strokeWidth', 'strokeType'], + GEO_ROUTE: ['stroke', 'strokeWidth', 'strokeType'], + RECT_WITH_TEXT: [ + 'fill', + 'stroke', + 'strokeWidth', + 'strokeType', + 'opacity', + 'textColor', + 'textSize', + 'textFont', + ], + // GROUP: union of every property — toolbar shows them all when a group + // is focused. applyGroupProperty silently skips children whose element + // type doesn't accept a given property. + GROUP: [ + 'fill', + 'stroke', + 'strokeWidth', + 'strokeType', + 'opacity', + 'textColor', + 'textSize', + 'textFont', + ], +} + +const SET_LABELS = { + SHAPE: 'Shape', + ARROW: 'Arrow', + PENCIL: 'Pencil', + TEXT: 'Text', + RECT_WITH_TEXT: 'Shape', + GROUP: 'Group', + GEO_AREA: 'Area', + GEO_ROUTE: 'Route', +} + +interface ResolveSetKeyOptions { + isRubberMode: boolean + isPencilMode: boolean + // eslint-disable-next-line @typescript-eslint/no-explicit-any + selectedComponent: any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + selectedGroup: any + isTextDrawMode: boolean + // Active tool from the toolbar (e.g. 'route'/'area'/'point' while a geo + // draw is in progress, before any vertex/element is selected). + currentElement: string | null +} + +function resolveSetKey({ + isRubberMode, + isPencilMode, + selectedComponent, + selectedGroup, + isTextDrawMode, + currentElement, +}: ResolveSetKeyOptions): string | null { + // A focused group beats every other mode — show the union toolbar. + if (selectedGroup) return 'GROUP' + if (isRubberMode) return null + if (isPencilMode) return 'PENCIL' + if (selectedComponent) { + const shapeType = selectedComponent?.shape?.type + const hasText = typeof selectedComponent?.text?.data?.value === 'string' + const elementType = + selectedComponent?.group?.data?.elementData?.componentType + // Geo objects checked first: area/route are path-typed and would + // otherwise fall into the SHAPE branch below. Points have no edit-area + // panel — their category is set from the toolbar drawer. + if (elementType === 'point') return null + if (elementType === 'area') return 'GEO_AREA' + if (elementType === 'route') return 'GEO_ROUTE' + // rectangle/diamond/circle all carry text the same way — show the + // shape+text toolbar (text size/color/font) for any of them. + const isShapeWithText = + hasText && + (shapeType === 'rectangle' || + elementType === 'rectangle' || + elementType === 'diamond' || + elementType === 'circle') + if (isShapeWithText) return 'RECT_WITH_TEXT' + if ( + shapeType === 'rectangle' || + shapeType === 'circle' || + shapeType === 'ellipse' || + shapeType === 'diamond' || + shapeType === 'path' || + shapeType === 'rounded-rectangle' + ) + return 'SHAPE' + if (shapeType === 'arrowLine') return 'ARROW' + if (isStandaloneTextType(shapeType)) return 'TEXT' + // Diamond is a custom Path; the elementData carries the type. + if ( + elementType === 'diamond' || + elementType === 'rectangle' || + elementType === 'circle' + ) + return 'SHAPE' + if (elementType === 'arrowLine') return 'ARROW' + if (isStandaloneTextType(elementType)) return 'TEXT' + if (elementType === 'pencil') return 'PENCIL' + return 'SHAPE' + } + // Arrow intentionally has no armed-mode panel: the ARROW toolbar appears + // only once a drawn arrow is selected (handled above), so merely picking the + // arrow tool doesn't surface the edit toolbar. + if (isTextDrawMode) return 'TEXT' + // Geo draw modes: the toolbar tool is active but nothing is selected yet. + // Surface the geo property panel so stroke edits seed the next draw — the + // same way pencil mode shows the pencil panel before the first stroke. + // Point is excluded: its category lives in the toolbar drawer, not here. + if (currentElement === 'area') return 'GEO_AREA' + if (currentElement === 'route') return 'GEO_ROUTE' + // No selection and no active tool — hide the panel. Defaults still apply + // to the next-created shape (the `useElementDefaults` state is unchanged); + // users edit them by selecting a shape, which auto-syncs the default per + // createApplyProperty. + return null +} + +interface ReadEffectiveValuesOptions { + setKey: string | null + // eslint-disable-next-line @typescript-eslint/no-explicit-any + selectedComponent: any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + selectedGroup: any + isMobile: boolean + // eslint-disable-next-line @typescript-eslint/no-explicit-any + defaults: any +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function readEffectiveValues({ + setKey, + selectedComponent, + selectedGroup, + isMobile, + defaults, +}: ReadEffectiveValuesOptions): any { + // Group mode: walk the group's children and report common values, falling + // back to defaults when no child carries the property and to MIXED when + // children disagree. + if (setKey === 'GROUP' && selectedGroup) { + const inspected = inspectGroupValues(selectedGroup, defaults) + // textSize is stored numeric in metadata; map to the toolbar label. + let textSizeOut = inspected.textSize + if (textSizeOut !== MIXED) { + const match = TEXT_SIZES_ARRAY.find((s) => + isMobile + ? s.mobileValue === textSizeOut + : s.value === textSizeOut + ) + textSizeOut = match?.label ?? defaults.defaultTextSize + } + return { ...inspected, textSize: textSizeOut } + } + if (!selectedComponent) { + // Pure default mode — pencil/arrow/shape all share the same defaults. + return { + fill: defaults.defaultFill, + stroke: defaults.defaultStrokeColor, + linewidth: defaults.defaultLinewidth, + strokeType: defaults.defaultStrokeType ?? 'solid', + opacity: defaults.defaultOpacity ?? 1, + textColor: defaults.defaultTextColor, + textSize: defaults.defaultTextSize, + textFontFamily: defaults.defaultTextFontFamily, + } + } + + const shapeData = selectedComponent?.shape?.data + const elementData = selectedComponent?.group?.data?.elementData + const textData = selectedComponent?.text?.data + + // For rectangle-with-text + plain text, the text properties live in + // different places. Resolve here so the rest is symmetric. + const isText = isStandaloneTextType(selectedComponent?.shape?.type) + const textNode = isText ? shapeData : textData + + const textSizeNumeric = textNode?.size + const textSizeLabel = + TEXT_SIZES_ARRAY.find((s) => + isMobile + ? s.mobileValue === textSizeNumeric + : s.value === textSizeNumeric + )?.label || defaults.defaultTextSize + + return { + fill: shapeData?.fill ?? defaults.defaultFill, + stroke: shapeData?.stroke ?? defaults.defaultStrokeColor, + linewidth: shapeData?.linewidth ?? defaults.defaultLinewidth, + strokeType: elementData?.strokeType ?? 'solid', + opacity: elementData?.metadata?.opacity ?? 1, + textColor: isText + ? (shapeData?.fill ?? defaults.defaultTextColor) + : (textData?.fill ?? defaults.defaultTextColor), + textSize: textSizeLabel, + textFontFamily: textNode?.family ?? defaults.defaultTextFontFamily, + } +} + +const SectionLabel = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+) + +const ReorderRow = ({ onReorder }: { onReorder: (op: ReorderOp) => void }) => ( +
+ Reorder +
+ {REORDER_BUTTONS.map(({ op, label, Icon }) => ( + + + + ))} +
+
+) + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const StrokeWidthRow = ({ + value, + onChange, +}: { + value: any + onChange: (v: number) => void +}) => ( +
+ Stroke Width +
+ {STROKE_WIDTHS.map(({ value: w, strokeHeight }) => { + const isSelected = value === w + return ( + + ) + })} +
+
+) + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const StrokeTypeRow = ({ + value, + onChange, +}: { + value: any + onChange: (v: string) => void +}) => ( +
+ Stroke Type +
+ {STROKE_TYPES.map(({ label, value: v }) => { + const isSelected = (value ?? 'solid') === v + return ( + + ) + })} +
+
+) + +const TextSizeRow = ({ + value, + onChange, +}: { + value: string + onChange: (v: string) => void +}) => ( +
+ Text Size +
+ {TEXT_SIZES_ARRAY.map(({ label }) => { + const isSelected = value === label + return ( + + ) + })} +
+
+) + +const FontFamilyRow = ({ + value, + onChange, +}: { + value: string + onChange: (v: string) => void +}) => { + const families = [ + { label: 'Caveat', family: 'Caveat' }, + { label: 'Geist', family: 'Geist' }, + { label: 'Caveat Brush', family: 'Caveat Brush' }, + ] + return ( +
+ Font +
+ {families.map(({ family }) => { + const isSelected = value === family + return ( + + ) + })} +
+
+ ) +} + +const ElementPropertiesToolbar = () => { + const ctx = useBoardContext() + + const { + isPencilMode, + isTextDrawMode, + isRubberMode, + selectedComponent, + selectedGroup, + currentElement, + applyProperty, + applyGroupProperty, + reorderSelected, + showMobileToolbarPanel, + // defaults + defaultFill, + defaultStrokeColor, + defaultLinewidth, + defaultStrokeType, + defaultOpacity, + defaultTextColor, + defaultTextSize, + defaultTextFontFamily, + } = ctx + const { isMobile } = useMediaQueryUtils() + + const setKey = useMemo( + () => + resolveSetKey({ + isRubberMode, + isPencilMode, + selectedComponent, + selectedGroup, + isTextDrawMode, + currentElement, + }), + [ + isRubberMode, + isPencilMode, + selectedComponent, + selectedGroup, + isTextDrawMode, + currentElement, + ] + ) + + const defaults = { + defaultFill, + defaultStrokeColor, + defaultLinewidth, + defaultStrokeType, + defaultOpacity, + defaultTextColor, + defaultTextSize, + defaultTextFontFamily, + } + + const [values, setValues] = useState(() => + readEffectiveValues({ + setKey: setKey || 'SHAPE', + selectedComponent, + selectedGroup, + isMobile, + defaults, + }) + ) + + const [expandedSection, setExpandedSection] = useState(null) + + const toggleSection = (key: string): void => + setExpandedSection((prev) => (prev === key ? null : key)) + + // Collapse any open color picker when context changes (new selection, mode switch). + useEffect(() => { + setExpandedSection(null) + }, [setKey, selectedComponent, selectedGroup]) + + // Re-sync local UI state whenever the source of truth changes (selection, + // mode, or any default). Property mutations bump a default, which flows + // through this effect to refresh the readouts. + useEffect(() => { + if (!setKey) return + setValues( + readEffectiveValues({ + setKey, + selectedComponent, + selectedGroup, + isMobile, + defaults, + }) + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + setKey, + selectedComponent, + selectedGroup, + isMobile, + defaultFill, + defaultStrokeColor, + defaultLinewidth, + defaultStrokeType, + defaultOpacity, + defaultTextColor, + defaultTextSize, + defaultTextFontFamily, + ]) + + if (!setKey) return null + if (isMobile && !showMobileToolbarPanel) return null + + const sections = SETS[setKey as keyof typeof SETS] + // Reorder controls apply to an actually-selected element (single shape or + // group). Hidden in pencil mode (armed pencil has no selection to reorder) + // and in the default/armed-tool panels where nothing is selected yet. + const showReorder = + Boolean(selectedComponent || selectedGroup) && !isPencilMode + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handle = + (key: string, opts?: { preview?: boolean }) => + (val: any): void => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setValues((prev: any) => ({ ...prev, [key]: val })) + if (selectedGroup) { + // textSize comes through as a label (e.g. 'M'); resolve to the + // numeric Two.js size before forwarding to the bulk apply path. + // The single-element path goes through handleTextSizeChange which + // does this conversion internally; the group path doesn't, so we + // do it here. + if (key === 'textSize') { + const entry = TEXT_SIZES_ARRAY.find((s) => s.label === val) + const numeric = entry + ? isMobile + ? entry.mobileValue + : entry.value + : val + applyGroupProperty?.(key, numeric, opts) + } else { + applyGroupProperty?.(key, val, opts) + } + return + } + applyProperty?.(key, val, opts) + } + + return ( +
{ + if (!selectedGroup) return + const tag = (e.target as HTMLElement | null)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + e.preventDefault() + }} + className="secondary-sidebar-content fixed bg-card-bg block text-left pb-4 rounded-card shadow-card border border-border-panel overflow-y-auto tablet:max-h-128" + style={ + isMobile + ? { + bottom: '60px', + right: '10px', + width: '208px', + zIndex: 20, + } + : { left: '10px', top: '56px', width: '13rem' } + } + > +
+ {SET_LABELS[setKey as keyof typeof SET_LABELS]} +
+ + {sections.includes('fill') && ( +
+ toggleSection('fill')} + essentialColors={fillEssentialShades} + /> +
+ )} + + {sections.includes('stroke') && ( +
+ toggleSection('stroke')} + /> +
+ )} + + {sections.includes('strokeWidth') && ( + + )} + + {sections.includes('strokeType') && ( + + )} + + {sections.includes('textColor') && ( +
+ toggleSection('textColor')} + /> +
+ )} + + {sections.includes('textSize') && ( + + )} + + {sections.includes('textFont') && ( + + )} + + {sections.includes('opacity') && ( +
+ Opacity + + // Live preview while dragging: applies to the scene + // only (no store/history write) so the element fades + // in real time. The release (handleOnChange) commits. + handle('opacity', { preview: true })(arr[0]) + } + handleOnChange={(arr) => handle('opacity')(arr[0])} + /> +
+ )} + + {showReorder && } +
+ ) +} + +export default ElementPropertiesToolbar diff --git a/src/components/sidebar/elementProperties.tsx b/src/components/sidebar/elementProperties.tsx index cb03252..7293007 100644 --- a/src/components/sidebar/elementProperties.tsx +++ b/src/components/sidebar/elementProperties.tsx @@ -4,9 +4,31 @@ import { useBoardContext } from '../../views/Board/boardContext' import { useMediaQueryUtils } from '../../constants/exportHooks' import ColorPicker from '../utils/colorPicker' import OpacitySlider from '../utils/opacitySlider' +import Tooltip from '../common/tooltip' import { TEXT_SIZES_ARRAY, fillEssentialShades } from '../../utils/constants' import { MIXED, inspectGroupValues } from '../../utils/groupInspect' import { isStandaloneTextType } from '../../constants/misc' +import type { ReorderOp } from '../canvasContextMenu' +import BringToFrontIcon from '../../assets/bring-to-front.svg?react' +import BringForwardIcon from '../../assets/bring-forward.svg?react' +import SendBackwardIcon from '../../assets/send-backward.svg?react' +import SendToBackIcon from '../../assets/send-to-back.svg?react' + +// Reorder icon tone — matches the toolbar's ink-mid text. The source SVGs +// hardcode a blue stroke; SVGR spreads props after the original attrs so this +// override wins (same trick as canvasContextMenu). +const REORDER_ICON_STROKE = '#8C7E6A' + +const REORDER_BUTTONS: { + op: ReorderOp + label: string + Icon: React.FunctionComponent> +}[] = [ + { op: 'front', label: 'Bring to Front', Icon: BringToFrontIcon }, + { op: 'forward', label: 'Bring Forward', Icon: BringForwardIcon }, + { op: 'backward', label: 'Send Backward', Icon: SendBackwardIcon }, + { op: 'back', label: 'Send to Back', Icon: SendToBackIcon }, +] const STROKE_TYPES = [ { label: '—', value: 'solid' }, @@ -241,6 +263,34 @@ const SectionLabel = ({ children }: { children: React.ReactNode }) => ( ) +const ReorderRow = ({ onReorder }: { onReorder: (op: ReorderOp) => void }) => ( +
+ Reorder +
+ {REORDER_BUTTONS.map(({ op, label, Icon }) => ( + + + + ))} +
+
+) + // eslint-disable-next-line @typescript-eslint/no-explicit-any const StrokeWidthRow = ({ value, @@ -417,6 +467,7 @@ const ElementPropertiesToolbar = () => { currentElement, applyProperty, applyGroupProperty, + reorderSelected, showMobileToolbarPanel, // defaults defaultFill, @@ -515,6 +566,11 @@ const ElementPropertiesToolbar = () => { if (isMobile && !showMobileToolbarPanel) return null const sections = SETS[setKey as keyof typeof SETS] + // Reorder controls apply to an actually-selected element (single shape or + // group). Hidden in pencil mode (armed pencil has no selection to reorder) + // and in the default/armed-tool panels where nothing is selected yet. + const showReorder = + Boolean(selectedComponent || selectedGroup) && !isPencilMode // eslint-disable-next-line @typescript-eslint/no-explicit-any const handle = (key: string, opts?: { preview?: boolean }) => @@ -662,6 +718,8 @@ const ElementPropertiesToolbar = () => { /> )} + + {showReorder && } ) } diff --git a/src/newCanvas.tsx b/src/newCanvas.tsx index f8b36a0..cd7709e 100644 --- a/src/newCanvas.tsx +++ b/src/newCanvas.tsx @@ -109,6 +109,12 @@ interface CanvasProps { defaultTextSize: number onCameraChange?: (event: CameraChangeEvent) => void renderBackground?: () => ReactNode + // Bridge: Canvas owns reorderSelected (needs reconcileZOrder + live zui + // selection); it publishes the function here so board.tsx can expose a + // stable wrapper through BoardContext for the properties toolbar. + reorderSelectedRef?: MutableRefObject< + ((op: 'front' | 'forward' | 'backward' | 'back') => void) | null + > } // Shape of the handle addZUI returns and Canvas stores in state. The @@ -3327,6 +3333,16 @@ const Canvas: React.FC = (props) => { ] ) + // Publish reorderSelected up to board.tsx so the properties toolbar can + // trigger it through BoardContext (see the reorderSelectedRef bridge). + useEffect(() => { + const ref = props.reorderSelectedRef + if (ref) ref.current = reorderSelected + return () => { + if (ref) ref.current = null + } + }, [props.reorderSelectedRef, reorderSelected]) + // Right-click (mouse) and two-finger trackpad tap both fire the native // 'contextmenu' event. Suppress the OS menu; if something is selected, open // our menu at the cursor. Cmd/Ctrl+Shift+D triggers the same export. diff --git a/src/types/board.ts b/src/types/board.ts index a0019b6..f476214 100644 --- a/src/types/board.ts +++ b/src/types/board.ts @@ -224,6 +224,11 @@ export interface BoardContextValue { opts?: { preview?: boolean } ) => void + // Z-order of the currently-selected element. Bridged up from newCanvas via + // a ref (the implementation lives there alongside reconcileZOrder); a no-op + // until Canvas has mounted and populated it. + reorderSelected: (op: 'front' | 'forward' | 'backward' | 'back') => void + // Element defaults (read sites: ElementPropertiesToolbar, primary sidebar, factories) defaultFill: string defaultStrokeColor: string diff --git a/src/views/Board/board.tsx b/src/views/Board/board.tsx index cef834e..f1708be 100644 --- a/src/views/Board/board.tsx +++ b/src/views/Board/board.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, + useCallback, type ReactNode, } from 'react' import { useMutation, useQuery } from '@apollo/client' @@ -203,6 +204,19 @@ const BoardViewPage: React.FC = (props) => { const { isDesktop, isMobile, isLaptop, isTablet } = useMediaQueryUtils() const stateRefForComponentStore = useRef({}) + // newCanvas owns reorderSelected (it needs reconcileZOrder + the live zui + // selection). It populates this ref on mount; the context exposes a stable + // wrapper so the properties toolbar can trigger reordering too. No-op until + // Canvas mounts. + const reorderSelectedRef = useRef< + ((op: 'front' | 'forward' | 'backward' | 'back') => void) | null + >(null) + const reorderSelected = useCallback( + (op: 'front' | 'forward' | 'backward' | 'back'): void => { + reorderSelectedRef.current?.(op) + }, + [] + ) // 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 @@ -1413,6 +1427,7 @@ const BoardViewPage: React.FC = (props) => { applyProperty, selectedGroup, applyGroupProperty, + reorderSelected, // Defaults — read by ElementPropertiesToolbar, also still exposed // individually so legacy primary.js / Canvas reads keep working. defaultFill, @@ -1495,6 +1510,7 @@ const BoardViewPage: React.FC = (props) => { } onCameraChange={props.onCameraChange} renderBackground={props.renderBackground} + reorderSelectedRef={reorderSelectedRef} /> From 2df5babd52d07aff3632407a4ffafea7eb3bdc6f Mon Sep 17 00:00:00 2001 From: meetzaveri Date: Sun, 7 Jun 2026 14:28:28 +0530 Subject: [PATCH 3/3] update: add reorder section to claude context --- .claude/CLAUDE.md | 1 + .claude/context/reorder.md | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 .claude/context/reorder.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 923b7a9..ecc3632 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -303,6 +303,7 @@ See detailed notes in `.claude/context/` for feature-specific implementation det - `.claude/context/undo-history.md` - Undo/history stack: action entry shapes, `recordToHistoryLog`, and `undoLastAction()` as the canonical rollback for any failed mutation - `.claude/context/responsive-design.md` - When to use Tailwind responsive prefixes vs `useMediaQueryUtils` hook; breakpoint values for both; the core decision rule - `.claude/context/font-guide.md` - Font system: Geist (UI chrome), Fraunces (branding/headings), Caveat Brush (canvas sketch); CSS variables, Tailwind config, and usage rules per area +- `claude/context/reorder.md` - How reording/positioning of elements in Z-Axis (Z-order) works in craftbase ### Component schema (from DB) diff --git a/.claude/context/reorder.md b/.claude/context/reorder.md new file mode 100644 index 0000000..cf7122c --- /dev/null +++ b/.claude/context/reorder.md @@ -0,0 +1,26 @@ +## Reorder/Positioning of elements + +In 2d space, we need to adjust how elements behave on z-axis. For that we need to reorder children of two group to achieve such expectations. + +In craftbase, we have four options + +- Bring to front (Brings it to the foremost top of the order, at [N]) +- Bring forward (Brings 1 order up , at[current+1]) +- Send Backward (Sends 1 order down, at [current-1]) +- Send to Back (Sends to last of the order, at [0]) + +This is being triggered by three inputs from user + +- Keyboard shortcuts +- Context Menu (opens on right click) +- Element Properties Toolbar or edit toolbar + +Shortcuts are + +``` +`]` = Bring Forward, `[` = Send Backward, `⌘` + `]` = Bring to Front, `⌘` + `[` = Send to Back (and Ctrl+… on Windows/Linux) +``` + +## Business logic + +We attach a property to each component called `position` which determines its position in Z-Axis or two's scene. The core logic is implemented at `reorderSelected` fn of newCanvas.tsx file .