diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
index ffd98e9..ecc3632 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,8 @@ 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
+- `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 .
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/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/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-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/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/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/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..9337d72 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 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'
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: BringToFrontIcon,
+ },
+ {
+ op: 'forward',
+ label: 'Bring Forward',
+ shortcut: fmtShortcut(']'),
+ Icon: BringForwardIcon,
+ },
+ {
+ op: 'backward',
+ label: 'Send Backward',
+ shortcut: fmtShortcut('['),
+ Icon: SendBackwardIcon,
+ },
+ {
+ op: 'back',
+ label: 'Send to Back',
+ shortcut: fmtShortcut('[', { cmd: true }),
+ Icon: SendToBackIcon,
+ },
+]
+
/**
* 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)}
+ >
+
setReorderOpen((v) => !v)}
+ className={itemClass}
+ >
+
+
+ Reorder
+
+
+ {/*
+
+ */}
+
+
+ {reorderOpen && (
+
+ {REORDER_ITEMS.map(
+ ({ op, label, shortcut, Icon }) => (
+
onReorder(op)}
+ className={itemClass}
+ >
+
+
+ {label}
+
+
+ {shortcut}
+
+
+ )
+ )}
+
+ )}
+
+
+
+
-
- ⌘⇧D
+
+ {fmtShortcut('D', { cmd: true, shift: true })}
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/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/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 }) => (
+
+ onReorder(op)}
+ className="flex-1 h-8 flex items-center justify-center rounded cursor-pointer transition-colors ease-in-out duration-150 border border-border-card hover:bg-accent/20"
+ >
+
+
+
+ ))}
+
+
+)
+
+// 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 (
+
onChange(w)}
+ className={`flex-1 w-4 h-6 flex items-center justify-center rounded cursor-pointer transition-all ease-in-out duration-200 ${
+ isSelected ? 'bg-accent/20' : 'hover:bg-accent/20'
+ }`}
+ style={{
+ border: isSelected
+ ? '2px solid #C4901A'
+ : '2px solid #C4B89A',
+ }}
+ >
+ {w === 0 ? (
+
+ ) : (
+
+ )}
+
+ )
+ })}
+
+
+)
+
+// 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 (
+ onChange(v)}
+ className={`flex-1 w-4 h-6 flex items-center justify-center rounded cursor-pointer transition-all ease-in-out duration-200 ${
+ isSelected ? 'bg-accent/20' : 'hover:bg-accent/20'
+ }`}
+ style={{
+ border: isSelected
+ ? '2px solid #C4901A'
+ : '1px solid #C4B89A',
+ }}
+ >
+
+ {label}
+
+
+ )
+ })}
+
+
+)
+
+const TextSizeRow = ({
+ value,
+ onChange,
+}: {
+ value: string
+ onChange: (v: string) => void
+}) => (
+
+
Text Size
+
+ {TEXT_SIZES_ARRAY.map(({ label }) => {
+ const isSelected = value === label
+ return (
+ onChange(label)}
+ className={`w-9 h-8 text-xs font-semibold border rounded transition-colors ${
+ isSelected
+ ? 'bg-accent/20 text-accent-dark border-accent-dark border-2'
+ : 'bg-card-bg text-ink-mid border-border-card hover:bg-accent/20'
+ }`}
+ >
+ {label}
+
+ )
+ })}
+
+
+)
+
+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 (
+ onChange(family)}
+ style={{ fontFamily: family }}
+ className={`w-12 h-8 text-sm border rounded transition-colors ${
+ isSelected
+ ? 'bg-accent/20 text-accent-dark border-accent-dark border-2'
+ : 'bg-card-bg text-ink-mid border-border-card hover:bg-accent/20'
+ }`}
+ >
+ Aa
+
+ )
+ })}
+
+
+ )
+}
+
+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 }) => (
+
+ onReorder(op)}
+ className="flex-1 h-8 flex items-center justify-center rounded cursor-pointer transition-colors ease-in-out duration-150 border border-border-card hover:bg-accent/20"
+ >
+
+
+
+ ))}
+
+
+)
+
// 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 3315ef6..cd7709e 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 {
@@ -108,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
@@ -159,6 +166,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 +672,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 +2690,8 @@ function addZUI(
selectionController.currentGroup ||
activeGroupRef.current ||
lastSelectedShape,
+ bringSelectionToFront: () =>
+ selectionController.bringSelectionToFront(),
}
}
@@ -2669,6 +2713,7 @@ const Canvas: React.FC = (props) => {
geoObjectsEnabled,
undoLastAction,
redoLastAction,
+ recordBatchToHistoryLog,
enableTextDrawMode,
createTextAtSurface,
} = useBoardContext()
@@ -2863,6 +2908,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 +3030,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 +3094,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 +3261,88 @@ 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,
+ ]
+ )
+
+ // 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.
@@ -3130,13 +3373,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 +3589,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..f476214 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
@@ -216,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/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..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
@@ -615,6 +629,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.
@@ -1396,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,
@@ -1478,6 +1510,7 @@ const BoardViewPage: React.FC = (props) => {
}
onCameraChange={props.onCameraChange}
renderBackground={props.renderBackground}
+ reorderSelectedRef={reorderSelectedRef}
/>
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'])
+ })
+})