From f4e14cc87262b758554181e52ce92fe7dab7bf26 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 02:46:49 +0000 Subject: [PATCH 1/2] feat(M5): selection transform, inspector, z-order, per-object lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 5, pillars 2 & 3 (v0) — make the selection a first-class, editable, arrangeable, lockable object. Pillar 2 — Select & Style: - Konva Transformer (new TransformLayer) gives resize + rotate handles for box kinds (rect/ellipse/text/asset), gated on the select tool. Renderers now draw in local coords with the ShapesLayer Group carrying x/y/rotation so the transform pivots around the shape's own origin; the transform is baked back to the model on transformend (one undo entry). - SelectionInspector: a Liquid-Glass panel (right dock) with stroke color, fill, opacity, and font-size controls, computing shared/"mixed" values across a multi-selection. Pillar 3 — Arrange & Lock: - Z-order: bringToFront/sendToBack/bringForward/sendBackward on the store; listShapes sorts by z (insertion-order fallback). Buttons + Cmd/Ctrl+]/[ (Shift = front/back). New shapeBase z field. - Per-object lock: wires the existing locked field across select-drag, transform, delete, eraser, marquee, and double-click-edit; lock toggle in the inspector, Cmd/Ctrl+Shift+L, and an amber selection box. New optional shape fields z/opacity (+ rot on text); opacity now applied once on the ShapesLayer Group. All mutations flow through updateShape/ transact so they ride Yjs undo and future realtime. typecheck + build pass across all workspaces. https://claude.ai/code/session_019jiFzJWnoACbYVEURirwxL --- .../features/canvas/SelectionInspector.tsx | 252 ++++++++++++++++++ apps/web/src/features/canvas/ToolPalette.tsx | 4 +- apps/web/src/features/canvas/palette.ts | 11 + apps/web/src/routes/Board.tsx | 4 +- apps/web/src/styles.css | 118 ++++++++ packages/canvas/src/CanvasStage.tsx | 73 ++++- packages/canvas/src/layers/OverlayLayer.tsx | 2 +- packages/canvas/src/layers/ShapesLayer.tsx | 32 ++- packages/canvas/src/layers/TransformLayer.tsx | 105 ++++++++ .../canvas/src/renderers/AssetRefRenderer.tsx | 8 +- .../canvas/src/renderers/EllipseRenderer.tsx | 7 +- .../canvas/src/renderers/RectRenderer.tsx | 7 +- .../canvas/src/renderers/StrokeRenderer.tsx | 1 - .../canvas/src/renderers/TextRenderer.tsx | 5 +- packages/canvas/src/store/shapeStore.ts | 92 ++++++- packages/canvas/src/tools/EraserTool.ts | 2 +- packages/canvas/src/tools/SelectTool.ts | 19 +- packages/types/src/yshape.ts | 5 + 18 files changed, 716 insertions(+), 31 deletions(-) create mode 100644 apps/web/src/features/canvas/SelectionInspector.tsx create mode 100644 apps/web/src/features/canvas/palette.ts create mode 100644 packages/canvas/src/layers/TransformLayer.tsx diff --git a/apps/web/src/features/canvas/SelectionInspector.tsx b/apps/web/src/features/canvas/SelectionInspector.tsx new file mode 100644 index 0000000..8f9ba2c --- /dev/null +++ b/apps/web/src/features/canvas/SelectionInspector.tsx @@ -0,0 +1,252 @@ +import { useMemo } from "react"; +import type { YShape } from "@notux/types"; +import { useShapeStore, useToolStore } from "@notux/canvas"; +import { COLORS } from "./palette"; + +interface Props { + pageId: string; +} + +// The stroke/outline color a shape exposes for editing (asset has none). +function shapeColor(s: YShape): string | undefined { + switch (s.kind) { + case "rect": + case "ellipse": + case "line": + case "arrow": + return s.stroke; + case "text": + case "stroke": + return s.color; + case "asset": + return undefined; + } +} + +function colorPatch(s: YShape, color: string): Partial | null { + switch (s.kind) { + case "rect": + case "ellipse": + case "line": + case "arrow": + return { stroke: color }; + case "text": + case "stroke": + return { color }; + case "asset": + return null; + } +} + +// The common value across the selection, or "mixed" when they differ. +function shared( + items: YShape[], + pick: (s: YShape) => T | undefined, +): T | "mixed" | undefined { + let acc: T | undefined; + let seen = false; + for (const s of items) { + const v = pick(s); + if (v === undefined) continue; + if (!seen) { + acc = v; + seen = true; + } else if (acc !== v) { + return "mixed"; + } + } + return seen ? acc : undefined; +} + +export function SelectionInspector({ pageId }: Props) { + const selection = useToolStore((s) => s.selection); + const revision = useShapeStore((s) => s.revision); + + const selected = useMemo(() => { + const store = useShapeStore.getState(); + return Array.from(selection) + .map((id) => store.getShape(pageId, id)) + .filter((s): s is YShape => !!s); + // revision so the panel reflects model edits / undo immediately. + }, [selection, revision, pageId]); + + if (selected.length === 0) return null; + + const store = useShapeStore.getState(); + const ids = selected.map((s) => s.id); + + function patchEach(make: (s: YShape) => Partial | null) { + store.transact(() => { + for (const s of selected) { + const p = make(s); + if (p) store.updateShape(pageId, s.id, p); + } + }); + } + + const isFillKind = (s: YShape) => s.kind === "rect" || s.kind === "ellipse"; + const hasColor = selected.some((s) => shapeColor(s) !== undefined); + const fillShapes = selected.filter(isFillKind); + const textShapes = selected.filter((s) => s.kind === "text"); + + const currentColor = shared(selected, shapeColor); + const currentFill = shared(fillShapes, (s) => + isFillKind(s) ? (s as { fill: string | null }).fill : undefined, + ); + const currentOpacity = shared(selected, (s) => s.opacity ?? 1); + const currentSize = shared(textShapes, (s) => + s.kind === "text" ? s.size : undefined, + ); + const allLocked = shared(selected, (s) => !!s.locked) === true; + + const opacityValue = + typeof currentOpacity === "number" ? Math.round(currentOpacity * 100) : 100; + + return ( +
+ {hasColor && ( +
+ {COLORS.map((c) => ( +
+ )} + + {fillShapes.length > 0 && ( +
+ Fill +
+
+
+ )} + +
+ Opacity + + patchEach(() => ({ opacity: Number(e.target.value) / 100 })) + } + aria-label="Opacity" + /> +
+ + {textShapes.length > 0 && ( +
+ Size + { + const n = Number(e.target.value); + if (Number.isFinite(n) && n > 0) { + patchEach((s) => (s.kind === "text" ? { size: n } : null)); + } + }} + aria-label="Font size" + /> +
+ )} + +
+ + + + +
+ + +
+ ); +} diff --git a/apps/web/src/features/canvas/ToolPalette.tsx b/apps/web/src/features/canvas/ToolPalette.tsx index 8aba2e2..8688710 100644 --- a/apps/web/src/features/canvas/ToolPalette.tsx +++ b/apps/web/src/features/canvas/ToolPalette.tsx @@ -1,5 +1,6 @@ import type { ToolKind } from "@notux/types"; import { useToolStore } from "@notux/canvas"; +import { COLORS, SIZES } from "./palette"; interface ToolDef { kind: ToolKind; @@ -19,9 +20,6 @@ const TOOLS: ToolDef[] = [ { kind: "text", label: "Text", glyph: "T" }, ]; -const COLORS = ["#ffffff", "#5ac8fa", "#ffd60a", "#ff453a", "#34c759", "#bf5af2"]; -const SIZES = [2, 4, 8, 14]; - // Temporary palette — the real Liquid Glass dock lands in M7. export function ToolPalette() { const tool = useToolStore((s) => s.tool); diff --git a/apps/web/src/features/canvas/palette.ts b/apps/web/src/features/canvas/palette.ts new file mode 100644 index 0000000..01aa732 --- /dev/null +++ b/apps/web/src/features/canvas/palette.ts @@ -0,0 +1,11 @@ +// Shared swatch + stroke-size presets, used by both the ToolPalette and the +// SelectionInspector so they stay visually consistent. +export const COLORS = [ + "#ffffff", + "#5ac8fa", + "#ffd60a", + "#ff453a", + "#34c759", + "#bf5af2", +]; +export const SIZES = [2, 4, 8, 14]; diff --git a/apps/web/src/routes/Board.tsx b/apps/web/src/routes/Board.tsx index c3db482..1b55483 100644 --- a/apps/web/src/routes/Board.tsx +++ b/apps/web/src/routes/Board.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; -import { CanvasStage, useShapeStore } from "@notux/canvas"; +import { CanvasStage, DEFAULT_PAGE_ID, useShapeStore } from "@notux/canvas"; import { SaveStatus } from "../features/canvas/SaveStatus"; +import { SelectionInspector } from "../features/canvas/SelectionInspector"; import { ToolPalette } from "../features/canvas/ToolPalette"; export default function Board() { @@ -47,6 +48,7 @@ export default function Board() {
+ ← Home diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index c0dfec1..3bfe97d 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -264,6 +264,124 @@ a { border-color: rgba(90, 200, 250, 0.55); } +.selection-inspector { + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px; + width: 200px; + border-radius: 18px; + background: var(--glass-tint); + border: 1px solid var(--glass-stroke); + backdrop-filter: blur(28px) saturate(180%); + -webkit-backdrop-filter: blur(28px) saturate(180%); + box-shadow: var(--shadow-lg); + z-index: 5; +} + +.selection-inspector__row { + display: flex; + align-items: center; + gap: 8px; +} + +.selection-inspector__row--swatches { + flex-wrap: wrap; + gap: 6px; + justify-content: space-between; +} + +.selection-inspector__label { + font-size: 12px; + color: var(--fg-1); + width: 52px; + flex: none; +} + +.selection-inspector__swatches { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.selection-inspector__none { + background: + linear-gradient( + 135deg, + transparent 44%, + #ff453a 44%, + #ff453a 56%, + transparent 56% + ), + rgba(0, 0, 0, 0.25); +} + +.selection-inspector__range { + flex: 1; + accent-color: var(--accent); +} + +.selection-inspector__num { + width: 64px; + background: rgba(0, 0, 0, 0.25); + border: 1px solid var(--glass-stroke); + color: var(--fg-0); + border-radius: 8px; + padding: 4px 8px; + font-size: 13px; +} + +.selection-inspector__actions { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.selection-inspector__btn { + appearance: none; + height: 30px; + border-radius: 8px; + border: 1px solid transparent; + background: rgba(255, 255, 255, 0.05); + color: var(--fg-0); + font-size: 15px; + cursor: pointer; + display: grid; + place-items: center; + transition: background 150ms ease; +} + +.selection-inspector__btn:hover { + background: rgba(255, 255, 255, 0.12); +} + +.selection-inspector__lock { + appearance: none; + border: 1px solid var(--glass-stroke); + background: rgba(255, 255, 255, 0.05); + color: var(--fg-0); + border-radius: 999px; + padding: 8px 12px; + font-size: 13px; + cursor: pointer; + transition: background 150ms ease, border-color 150ms ease; +} + +.selection-inspector__lock:hover { + background: rgba(255, 255, 255, 0.12); +} + +.selection-inspector__lock--on { + background: rgba(255, 214, 10, 0.18); + border-color: rgba(255, 214, 10, 0.5); +} + .board__home-link { position: absolute; top: 16px; diff --git a/packages/canvas/src/CanvasStage.tsx b/packages/canvas/src/CanvasStage.tsx index c78142e..ae46e73 100644 --- a/packages/canvas/src/CanvasStage.tsx +++ b/packages/canvas/src/CanvasStage.tsx @@ -7,6 +7,7 @@ import { useUndoManager } from "./hooks/useUndoManager"; import { BackgroundLayer } from "./layers/BackgroundLayer"; import { OverlayLayer } from "./layers/OverlayLayer"; import { ShapesLayer } from "./layers/ShapesLayer"; +import { TransformLayer } from "./layers/TransformLayer"; import { useDraftStore } from "./store/draftStore"; import { DEFAULT_PAGE_ID } from "./store/pageStore"; import { useShapeStore } from "./store/shapeStore"; @@ -164,6 +165,36 @@ export function CanvasStage({ boardId: _boardId, pageId = DEFAULT_PAGE_ID }: Pro redo(); return; } + // Arrange (z-order): Cmd/Ctrl+] forward, +Shift to front; [ backward, + // +Shift to back. Only intercept when something is selected, so the + // browser's history nav still works on an empty canvas. + if (mod && (e.key === "]" || e.key === "[")) { + const ids = Array.from(useToolStore.getState().selection); + if (ids.length === 0) return; + e.preventDefault(); + const store = useShapeStore.getState(); + if (e.key === "]") { + if (e.shiftKey) store.bringToFront(pageId, ids); + else store.bringForward(pageId, ids); + } else if (e.shiftKey) { + store.sendToBack(pageId, ids); + } else { + store.sendBackward(pageId, ids); + } + return; + } + // Lock toggle: Cmd/Ctrl+Shift+L (Shift required to avoid Cmd+L). + if (mod && e.shiftKey && (e.key === "l" || e.key === "L")) { + const ids = Array.from(useToolStore.getState().selection); + if (ids.length === 0) return; + e.preventDefault(); + const store = useShapeStore.getState(); + const allLocked = ids.every((id) => store.getShape(pageId, id)?.locked); + store.transact(() => + ids.forEach((id) => store.setLocked(pageId, id, !allLocked)), + ); + return; + } const handler = toolRef.current.onKeyDown; if (handler) handler(e, buildToolContext()); } @@ -176,7 +207,7 @@ export function CanvasStage({ boardId: _boardId, pageId = DEFAULT_PAGE_ID }: Pro window.removeEventListener("keydown", down); window.removeEventListener("keyup", up); }; - }, [buildToolContext, undo, redo]); + }, [buildToolContext, undo, redo, pageId]); const panRef = useRef<{ active: boolean; lastX: number; lastY: number }>({ active: false, @@ -207,6 +238,19 @@ export function CanvasStage({ boardId: _boardId, pageId = DEFAULT_PAGE_ID }: Pro return; } if (native.button !== 0) return; + // If the pointer landed on a Transformer handle, let Konva drive the + // resize/rotate; don't also start a SelectTool drag on the shape below. + const stage = stageRef.current; + if (stage) { + const rect = containerRef.current?.getBoundingClientRect(); + const sx = native.clientX - (rect?.left ?? 0); + const sy = native.clientY - (rect?.top ?? 0); + let n: Konva.Node | null = stage.getIntersection({ x: sx, y: sy }); + while (n) { + if (n.getClassName() === "Transformer") return; + n = n.getParent(); + } + } evt.currentTarget.setPointerCapture(native.pointerId); toolRef.current.onPointerDown(pointerToToolPoint(native), buildToolContext()); }, @@ -280,7 +324,7 @@ export function CanvasStage({ boardId: _boardId, pageId = DEFAULT_PAGE_ID }: Pro const sy = evt.clientY - rect.top; const w = screenToWorld(viewport, sx, sy); const hit = hitTestWorld(w); - if (hit && hit.kind === "text") { + if (hit && hit.kind === "text" && !hit.locked) { useTextEditStore.getState().begin({ editingId: hit.id, worldX: hit.x, @@ -301,6 +345,21 @@ export function CanvasStage({ boardId: _boardId, pageId = DEFAULT_PAGE_ID }: Pro [shapes, selection], ); + // The Transformer draws handles for transformable, unlocked shapes; the + // dashed OverlayLayer box covers the rest (strokes, lines, arrows) plus any + // locked shape (which the Transformer skips, and which renders amber). + const overlayShapes = useMemo( + () => + selectedShapes.filter( + (s) => + s.locked || + s.kind === "stroke" || + s.kind === "line" || + s.kind === "arrow", + ), + [selectedShapes], + ); + const cursor = spaceHeld ? "grab" : toolCursor(tool); return ( @@ -334,7 +393,15 @@ export function CanvasStage({ boardId: _boardId, pageId = DEFAULT_PAGE_ID }: Pro > - + {tool === "select" && ( + + )} + diff --git a/packages/canvas/src/layers/ShapesLayer.tsx b/packages/canvas/src/layers/ShapesLayer.tsx index 76f85c3..ff07e7d 100644 --- a/packages/canvas/src/layers/ShapesLayer.tsx +++ b/packages/canvas/src/layers/ShapesLayer.tsx @@ -34,6 +34,31 @@ function renderShape(shape: YShape, selected: boolean) { } } +// Box kinds carry their position/rotation on the wrapping Group so the Konva +// Transformer pivots around the shape's own origin. Stroke/line/arrow draw at +// absolute coords inside a Group left at the origin. +function groupTransform(shape: YShape): { + x?: number; + y?: number; + rotation?: number; +} { + switch (shape.kind) { + case "rect": + case "ellipse": + case "text": + case "asset": + return { x: shape.x, y: shape.y, rotation: shape.rot ?? 0 }; + default: + return {}; + } +} + +function groupOpacity(shape: YShape): number { + if (shape.opacity !== undefined) return shape.opacity; + if (shape.kind === "stroke" && shape.tool === "highlighter") return 0.35; + return 1; +} + export const ShapesLayer = forwardRef(function ShapesLayer( { shapes, selection }, ref, @@ -41,7 +66,12 @@ export const ShapesLayer = forwardRef(function ShapesLayer( return ( {shapes.map((shape) => ( - + {renderShape(shape, selection.has(shape.id))} ))} diff --git a/packages/canvas/src/layers/TransformLayer.tsx b/packages/canvas/src/layers/TransformLayer.tsx new file mode 100644 index 0000000..e75b819 --- /dev/null +++ b/packages/canvas/src/layers/TransformLayer.tsx @@ -0,0 +1,105 @@ +import type { RefObject } from "react"; +import { useEffect, useRef } from "react"; +import { Layer, Transformer } from "react-konva"; +import type Konva from "konva"; +import type { YShape } from "@notux/types"; +import { useShapeStore } from "../store/shapeStore"; + +interface Props { + selection: Set; + pageId: string; + // Re-bind the Transformer's nodes whenever shapes change (e.g. z-order). + revision: number; + shapesLayerRef: RefObject; +} + +// v0: only box kinds get resize/rotate handles. Strokes are freehand point +// clouds and line/arrow are endpoint-shaped — both keep move-only (drag) and +// show the dashed OverlayLayer box instead. +function isTransformable(kind: YShape["kind"]): boolean { + return ( + kind === "rect" || kind === "ellipse" || kind === "text" || kind === "asset" + ); +} + +export function TransformLayer({ + selection, + pageId, + revision, + shapesLayerRef, +}: Props) { + const trRef = useRef(null); + + useEffect(() => { + const tr = trRef.current; + const layer = shapesLayerRef.current; + if (!tr || !layer) return; + const store = useShapeStore.getState(); + const nodes: Konva.Node[] = []; + for (const id of selection) { + const shape = store.getShape(pageId, id); + if (!shape || shape.locked || !isTransformable(shape.kind)) continue; + const node = layer.findOne((n: Konva.Node) => n.name() === id); + if (node) nodes.push(node); + } + tr.nodes(nodes); + tr.getLayer()?.batchDraw(); + }, [selection, revision, pageId, shapesLayerRef]); + + // Bake the live Konva transform back into the model, then reset node scale so + // the next gesture starts clean. Nodes live in world space (the Stage carries + // the viewport), so no coordinate conversion is needed. One transact => one + // undo entry for a multi-shape transform. + function onTransformEnd() { + const tr = trRef.current; + if (!tr) return; + const store = useShapeStore.getState(); + store.transact(() => { + for (const node of tr.nodes()) { + const id = node.name(); + const shape = store.getShape(pageId, id); + if (!shape || !isTransformable(shape.kind)) continue; + // Narrowed: rect | ellipse | text | asset — all carry w/h, and the + // Group is anchored at the shape's top-left, so node.x()/y() is the + // new top-left for every kind (ellipse included). + if ( + shape.kind === "rect" || + shape.kind === "ellipse" || + shape.kind === "text" || + shape.kind === "asset" + ) { + const w = Math.max(1, shape.w * node.scaleX()); + const h = Math.max(1, shape.h * node.scaleY()); + store.updateShape(pageId, id, { + x: node.x(), + y: node.y(), + rot: node.rotation(), + w, + h, + ...(shape.kind === "text" + ? { size: Math.max(8, shape.size * node.scaleY()) } + : {}), + }); + } + node.scaleX(1); + node.scaleY(1); + } + }); + } + + return ( + + + newBox.width < 5 || newBox.height < 5 ? oldBox : newBox + } + onTransformEnd={onTransformEnd} + /> + + ); +} diff --git a/packages/canvas/src/renderers/AssetRefRenderer.tsx b/packages/canvas/src/renderers/AssetRefRenderer.tsx index 79d1cb5..7ffbd81 100644 --- a/packages/canvas/src/renderers/AssetRefRenderer.tsx +++ b/packages/canvas/src/renderers/AssetRefRenderer.tsx @@ -9,13 +9,9 @@ interface Props { // Placeholder renderer for M2. M4–M5 (PDF/image import) replaces this with a // real renderer that loads the asset from Supabase Storage. export function AssetRefRenderer({ shape, selected }: Props) { + // Local coords; the ShapesLayer Group carries x/y/rotation. return ( - + 0 ? shape.w : undefined} text={shape.content} fontFamily={shape.font} diff --git a/packages/canvas/src/store/shapeStore.ts b/packages/canvas/src/store/shapeStore.ts index 97c9b90..1cf4c88 100644 --- a/packages/canvas/src/store/shapeStore.ts +++ b/packages/canvas/src/store/shapeStore.ts @@ -33,6 +33,74 @@ interface ShapeStoreState { deleteShape(pageId: string, id: string): void; deleteShapes(pageId: string, ids: Iterable): void; transact(fn: () => void): void; + + // Per-object lock (M5 P3). Locked shapes opt out of select-drag, transform, + // delete, erase, and marquee — see SelectTool / EraserTool / TransformLayer. + setLocked(pageId: string, id: string, locked: boolean): void; + + // Z-order (M5 P3). Each op rewrites a dense 0..n-1 `z` over the page in one + // transaction so values stay compact and ordering is well-defined. + bringToFront(pageId: string, ids: Iterable): void; + sendToBack(pageId: string, ids: Iterable): void; + bringForward(pageId: string, ids: Iterable): void; + sendBackward(pageId: string, ids: Iterable): void; +} + +type ArrangeMode = "front" | "back" | "forward" | "backward"; + +// Pure reorder of a z-sorted array (index 0 = back, last = front). Moves the +// selected ids per `mode`; the caller writes a dense z over the result. +function reorderForArrange( + sorted: YShape[], + selected: Set, + mode: ArrangeMode, +): YShape[] { + const isSel = (s: YShape) => selected.has(s.id); + if (mode === "front") { + return [...sorted.filter((s) => !isSel(s)), ...sorted.filter(isSel)]; + } + if (mode === "back") { + return [...sorted.filter(isSel), ...sorted.filter((s) => !isSel(s))]; + } + const arr = sorted.slice(); + if (mode === "forward") { + // Top-down: swap each selected shape with the unselected neighbor above it. + for (let i = arr.length - 2; i >= 0; i--) { + const a = arr[i]; + const b = arr[i + 1]; + if (a && b && isSel(a) && !isSel(b)) { + arr[i] = b; + arr[i + 1] = a; + } + } + } else { + // backward: bottom-up, swap with the unselected neighbor below. + for (let i = 1; i < arr.length; i++) { + const a = arr[i]; + const b = arr[i - 1]; + if (a && b && isSel(a) && !isSel(b)) { + arr[i] = b; + arr[i - 1] = a; + } + } + } + return arr; +} + +function applyArrange( + get: () => ShapeStoreState, + pageId: string, + idsIter: Iterable, + mode: ArrangeMode, +) { + const ids = new Set(idsIter); + if (ids.size === 0) return; + const next = reorderForArrange(get().listShapes(pageId), ids, mode); + get().transact(() => { + next.forEach((s, i) => { + if ((s.z ?? -1) !== i) get().updateShape(pageId, s.id, { z: i }); + }); + }); } export const useShapeStore = create((set, get) => ({ @@ -81,7 +149,12 @@ export const useShapeStore = create((set, get) => ({ if (!doc) return []; const pageMap = findPageMap(doc, pageId); if (!pageMap) return []; - return Array.from(pageMap.values()); + // Sort by z, falling back to insertion order (Y.Map iterates in insertion + // order). A board with no z fields renders in exactly its insertion order. + return Array.from(pageMap.values()) + .map((s, i) => ({ s, i })) + .sort((a, b) => (a.s.z ?? a.i) - (b.s.z ?? b.i) || a.i - b.i) + .map((e) => e.s); }, getShape(pageId, id) { @@ -137,6 +210,23 @@ export const useShapeStore = create((set, get) => ({ } doc.transact(fn); }, + + setLocked(pageId, id, locked) { + get().updateShape(pageId, id, { locked }); + }, + + bringToFront(pageId, ids) { + applyArrange(get, pageId, ids, "front"); + }, + sendToBack(pageId, ids) { + applyArrange(get, pageId, ids, "back"); + }, + bringForward(pageId, ids) { + applyArrange(get, pageId, ids, "forward"); + }, + sendBackward(pageId, ids) { + applyArrange(get, pageId, ids, "backward"); + }, })); export type ShapeStore = ShapeStoreState; diff --git a/packages/canvas/src/tools/EraserTool.ts b/packages/canvas/src/tools/EraserTool.ts index dd18477..4d8bc34 100644 --- a/packages/canvas/src/tools/EraserTool.ts +++ b/packages/canvas/src/tools/EraserTool.ts @@ -11,7 +11,7 @@ export function makeEraserTool(): Tool { function eraseAt(p: ToolEventPoint, ctx: ToolContext) { const hit = ctx.hitTest({ x: p.x, y: p.y }); - if (hit) { + if (hit && !hit.locked) { ctx.store.transact(() => ctx.store.deleteShape(ctx.pageId, hit.id)); } } diff --git a/packages/canvas/src/tools/SelectTool.ts b/packages/canvas/src/tools/SelectTool.ts index ab98c96..434883f 100644 --- a/packages/canvas/src/tools/SelectTool.ts +++ b/packages/canvas/src/tools/SelectTool.ts @@ -26,7 +26,8 @@ export function makeSelectTool(): Tool { state.dragSnapshot.clear(); for (const id of ctx.getSelection()) { const s = ctx.store.getShape(ctx.pageId, id); - if (s) state.dragSnapshot.set(id, s); + // Locked shapes are never dragged, even within a mixed selection. + if (s && !s.locked) state.dragSnapshot.set(id, s); } } @@ -57,8 +58,14 @@ export function makeSelectTool(): Tool { } else if (!selection.has(hit.id)) { ctx.setSelection([hit.id]); } - state.mode = "drag"; - snapshotSelection(ctx); + // Locked shapes can be selected (so the inspector can offer Unlock) but + // never enter a drag. + if (hit.locked) { + state.mode = "idle"; + } else { + state.mode = "drag"; + snapshotSelection(ctx); + } } else { if (!p.shift) ctx.setSelection([]); state.mode = "marquee"; @@ -89,7 +96,7 @@ export function makeSelectTool(): Tool { if (marquee.w > 2 && marquee.h > 2) { const hits = ctx .rectIntersect(marquee) - .filter((s) => boundsIntersect(shapeBounds(s), marquee)); + .filter((s) => !s.locked && boundsIntersect(shapeBounds(s), marquee)); const next = new Set(p.shift ? ctx.getSelection() : []); for (const s of hits) next.add(s.id); ctx.setSelection(next); @@ -105,7 +112,9 @@ export function makeSelectTool(): Tool { }, onKeyDown(e, ctx) { if (e.key === "Delete" || e.key === "Backspace") { - const ids = Array.from(ctx.getSelection()); + const ids = Array.from(ctx.getSelection()).filter( + (id) => !ctx.store.getShape(ctx.pageId, id)?.locked, + ); if (ids.length === 0) return; e.preventDefault(); ctx.store.transact(() => ctx.store.deleteShapes(ctx.pageId, ids)); diff --git a/packages/types/src/yshape.ts b/packages/types/src/yshape.ts index aec5875..6fcaf52 100644 --- a/packages/types/src/yshape.ts +++ b/packages/types/src/yshape.ts @@ -13,6 +13,10 @@ export interface YShapeBase { id: string; author: string; locked?: boolean; + // Stacking order. undefined falls back to insertion order (see listShapes). + z?: number; + // 0..1. undefined renders fully opaque (highlighter strokes default to 0.35). + opacity?: number; } export interface YStroke extends YShapeBase { @@ -72,6 +76,7 @@ export interface YText extends YShapeBase { y: number; w: number; h: number; + rot?: number; content: string; font: string; size: number; From cfc84bbeb29cc138c79ab5199acebb0ac2292d5f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 03:47:47 +0000 Subject: [PATCH 2/2] ci: remove leftover node.js.yml starter workflow It ran `npm ci` / `npm test` with `cache: npm` in a pnpm monorepo that has no package-lock.json and no test script, so its build (18.x/20.x/22.x) jobs failed on every PR and push. The real typecheck + build gate is ci.yml (pnpm-based), which fully covers this; node.js.yml was redundant. https://claude.ai/code/session_019jiFzJWnoACbYVEURirwxL --- .github/workflows/node.js.yml | 34 ---------------------------------- 1 file changed, 34 deletions(-) delete mode 100644 .github/workflows/node.js.yml diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml deleted file mode 100644 index 6f56ed3..0000000 --- a/.github/workflows/node.js.yml +++ /dev/null @@ -1,34 +0,0 @@ -# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs - -name: Node.js CI - -permissions: - contents: read - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -jobs: - build: - - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [18.x, 20.x, 22.x] - # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ - - steps: - - uses: actions/checkout@v4 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: 'npm' - - run: npm ci - - run: npm run build --if-present - - run: npm test