/**
* @file use-tool-mode.tsx — Phase 4.1 tool-mode store
* @scope apps/studio/use-tool-mode.tsx
* @purpose Context + hook for the active canvas tool. Wired into
* DesignCanvas. Phase 5 will
* register additional tools (pen, circle, arrow, eraser) via
* the same provider — the API is intentionally open.
*
* The router's `onTool` callback (input-router.tsx) writes into this store.
* The ToolPalette + cursor sync read from it. Selecting a tool also mutates
* `document.body.style.cursor` so the affordance matches across the iframe.
*/
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { TOOL_CURSORS } from './canvas-cursors.ts';
import type { Tool } from './input-router.tsx';
import { isReadOnlyCanvas } from './read-only-mode.ts';
// ─────────────────────────────────────────────────────────────────────────────
// Types
export interface ToolDescriptor {
id: Tool;
label: string;
/** Letter-key shortcut shown in the palette tooltip. */
shortcut: string;
/** CSS cursor value applied to
when this tool is active. */
cursor: string;
}
/**
* Phase 24 — the six primitives the single Shape tool can draw. Maps onto the
* stroke model: square/rounded → `rect` (cornerRadius 0 / 8); circle →
* `ellipse`; diamond/triangle/triangle-down → `polygon`.
*/
export type ShapeKind = 'square' | 'rounded' | 'circle' | 'diamond' | 'triangle' | 'triangle-down';
/**
* DDR-223 (issue #93) — the binary canvas mode over the tool set. `preview`
* rests on `browse` (pure pass-through, the mock is alive; annotation tools
* stay usable); `edit` rests on `move` (the Figma select ladder). The mode is
* a store-level layer — `classify()` and every `tool === 'move'` gate are
* untouched (DDR-187's decomposition stands).
*/
export type CanvasMode = 'preview' | 'edit';
/** Document event two provider instances keep one active tool through. */
export const TOOL_SYNC_EVENT = 'maude:tool-sync';
/** The resting tool each mode arms (and returns to via `resetTool`). */
export const MODE_DEFAULT_TOOL: Readonly> = Object.freeze({
preview: 'browse',
edit: 'move',
});
/**
* Mode implied by arming a mode-exclusive resting tool. `browse` and `move`
* are the only mode-carrying tools; everything else (comment, draw set) is
* mode-NEUTRAL — arming pen in preview keeps you in preview (the issue-#93
* "annotations work in preview" contract).
*/
function modeForTool(t: Tool): CanvasMode | null {
if (t === 'browse') return 'preview';
if (t === 'move') return 'edit';
return null;
}
// Phase 21 — every tool ships a custom 32×32 SVG cursor (canvas-cursors.ts)
// with a white outline halo so the glyph reads on any background. The native
// crosshair/text/cell were thin + tiny ("pen almost invisible"); these mirror
// the tool-palette icons. `move` keeps the system arrow on purpose.
export const DEFAULT_TOOLS: readonly ToolDescriptor[] = Object.freeze([
// feature-4 — Browse is the pure pass-through tool: the mock is alive
// (buttons click). Since DDR-223 it is the PREVIEW mode's resting tool (the
// boot default moved to edit/`move`); no letter shortcut — it's armed via
// the Preview toggle / Esc-in-preview.
{ id: 'browse', label: 'Browse', shortcut: '', cursor: TOOL_CURSORS.browse },
{ id: 'move', label: 'Select', shortcut: 'V', cursor: TOOL_CURSORS.move },
{ id: 'hand', label: 'Hand', shortcut: 'H', cursor: TOOL_CURSORS.hand },
{ id: 'comment', label: 'Comment', shortcut: 'C', cursor: TOOL_CURSORS.comment },
{ id: 'pen', label: 'Pen', shortcut: 'B', cursor: TOOL_CURSORS.pen },
// Annotation polish (item 8) — highlighter sits next to the pen.
{ id: 'highlighter', label: 'Highlighter', shortcut: 'I', cursor: TOOL_CURSORS.highlighter },
// Phase 24 — one Shape tool replaces the separate Rect (R) + Ellipse (O)
// buttons; the primitive is chosen from the palette popover.
{ id: 'shape', label: 'Shape', shortcut: 'R', cursor: TOOL_CURSORS.shape },
{ id: 'sticky', label: 'Sticky', shortcut: 'N', cursor: TOOL_CURSORS.sticky },
// FigJam v3 — labelled organizing container.
{ id: 'section', label: 'Section', shortcut: '⇧S', cursor: TOOL_CURSORS.shape },
{ id: 'arrow', label: 'Arrow', shortcut: 'A', cursor: TOOL_CURSORS.arrow },
{ id: 'text', label: 'Text', shortcut: 'T', cursor: TOOL_CURSORS.text },
{ id: 'eraser', label: 'Eraser', shortcut: 'E', cursor: TOOL_CURSORS.eraser },
]);
/**
* Cloud Phase 25 C2 — the tools a READ-ONLY session keeps: navigate and
* inspect, nothing that writes. Everything else (comment included — the cell
* refuses viewer comments until Phase 25 C3 lands them on its allowlist) is
* ABSENT from the palette, its letter shortcut dead, and `setTool` refuses it
* (which also covers the shell's `tool-set` postMessage lane).
*/
const READ_ONLY_TOOL_IDS: ReadonlySet = new Set(['browse', 'move', 'hand']);
/** Pure filter — exported for unit tests. */
export function filterToolsForReadOnly(
tools: readonly ToolDescriptor[],
readOnly: boolean
): readonly ToolDescriptor[] {
return readOnly ? tools.filter((t) => READ_ONLY_TOOL_IDS.has(t.id)) : tools;
}
interface ToolContextValue {
tool: Tool;
setTool: (t: Tool) => void;
tools: readonly ToolDescriptor[];
/** DDR-223 — the binary canvas mode. Kept coherent with `tool` (arming
* `move`/`browse` moves the mode; `setMode` arms the mode's resting tool). */
mode: CanvasMode;
setMode: (m: CanvasMode) => void;
/** Arm the current mode's resting tool — the Esc / post-draw-commit flip.
* `move` in edit (byte-identical to the pre-DDR-223 hardcode), `browse` in
* preview (drawing an annotation never silently exits the alive posture). */
resetTool: () => void;
/** T19 — sticky-tool double-click lock. When `sticky.locked === true` AND
* `sticky.tool === tool`, draw tools stay armed after each shape commit
* (T18 auto-flip is suppressed). Single-click on any other tool clears
* sticky; Esc clears + flips to Move. */
sticky: { tool: Tool | null; locked: boolean };
toggleSticky: (t: Tool) => void;
clearSticky: () => void;
/** Phase 24 — the primitive the Shape tool will draw next. */
shapeKind: ShapeKind;
setShapeKind: (k: ShapeKind) => void;
}
const ToolContext = createContext(null);
// ─────────────────────────────────────────────────────────────────────────────
// Provider
export function ToolProvider({
children,
tools: toolsProp = DEFAULT_TOOLS,
// DDR-223 (issue #93, supersedes DDR-187's boot half) — authoring surfaces
// boot into EDIT with `move` (V) armed: click-selects from the first frame,
// Preview (the alive pass-through posture) is one always-visible toggle
// away. Read-only canvases (cloud viewers) keep the DDR-187 posture and
// boot preview/`browse` — a viewer's job is to use the live mock. The
// comment-mount layer passes `initial='browse'` explicitly so bare DS
// specimens stay alive too.
initial,
initialMode,
}: {
children: ReactNode;
tools?: readonly ToolDescriptor[];
initial?: Tool;
initialMode?: CanvasMode;
}) {
// Cloud Phase 25 C2 — a read-only canvas keeps only navigate/inspect tools.
// Filtering HERE covers every consumer at once: the palette renders from
// `tools`, the input router's letter shortcuts resolve against `tools`, and
// `setTool` below refuses anything outside the list (the shell `tool-set`
// postMessage lane included).
const readOnly = isReadOnlyCanvas();
const tools = useMemo(() => filterToolsForReadOnly(toolsProp, readOnly), [toolsProp, readOnly]);
const bootTool: Tool = initial ?? (readOnly ? 'browse' : 'move');
const bootMode: CanvasMode =
initialMode ?? modeForTool(bootTool) ?? (readOnly ? 'preview' : 'edit');
const [tool, setToolState] = useState(bootTool);
const [mode, setModeState] = useState(bootMode);
const [sticky, setSticky] = useState<{ tool: Tool | null; locked: boolean }>(() => ({
tool: null,
locked: false,
}));
const setTool = useCallback(
(t: Tool) => {
// Refuse tools that aren't offered (read-only filtering above) — a
// shortcut or postMessage can't arm a tool the palette doesn't show.
if (!tools.some((d) => d.id === t)) return;
setToolState(t);
// DDR-223 — mode⇄tool invariant: arming a resting tool moves the mode
// with it, so V, the menubar `tool-set` lane, and the Cmd+click-in-
// browse escape hatch all land in EDIT coherently (and arming browse
// lands in PREVIEW). Annotation tools leave the mode alone. This is
// also what keeps `move` structurally unreachable inside preview — the
// moment it arms, the mode is edit, so no `tool === 'move'` gate can
// ever fire while the UI claims preview.
const m = modeForTool(t);
if (m) setModeState(m);
// Single-click on a different tool clears any sticky lock — sticky is
// a per-tool flag, not global.
setSticky((prev) => (prev.locked && prev.tool === t ? prev : { tool: null, locked: false }));
},
[tools]
);
const setMode = useCallback(
(m: CanvasMode) => {
// Switching mode arms its resting tool (Figma-style: the toggle IS the
// posture). Read-only keeps both browse+move on its allowlist, so the
// guard below never fires there; it exists for exotic custom `tools`.
const rest = MODE_DEFAULT_TOOL[m];
if (!tools.some((d) => d.id === rest)) return;
setModeState(m);
setToolState(rest);
setSticky((prev) =>
prev.locked && prev.tool === rest ? prev : { tool: null, locked: false }
);
},
[tools]
);
const resetTool = useCallback(() => {
setTool(MODE_DEFAULT_TOOL[mode]);
}, [mode, setTool]);
const toggleSticky = useCallback((t: Tool) => {
setSticky((prev) => {
if (prev.locked && prev.tool === t) return { tool: null, locked: false };
return { tool: t, locked: true };
});
setToolState(t);
}, []);
const clearSticky = useCallback(() => {
setSticky({ tool: null, locked: false });
}, []);
// FigJam v3 — soft default: a fresh Shape tool draws ROUNDED squares (the
// FigJam look); sharp squares stay one popover click away.
const [shapeKind, setShapeKind] = useState('rounded');
// ONE ACTIVE TOOL PER CANVAS DOCUMENT, across provider instances.
//
// A UI canvas carries TWO providers: the comment-mount layer's (it owns the
// comment drop) and canvas-lib's own (it owns the palette) — separate
// bundles, so separate contexts. They used to converge only through a tool
// KEYDOWN, which both routers see; a palette CLICK reached canvas-lib's
// provider alone. So "Comment" in the palette lit up while the comment
// layer stayed in browse, and clicking the canvas dropped nothing — you had
// to know to press C (plan T31/L11). A change here is announced on the
// document and every other instance adopts it. The boot posture is NOT
// announced (the two instances deliberately boot differently — see
// `initial`); only a change after mount is.
const syncId = useRef(Math.random().toString(36).slice(2));
const toolRef = useRef(tool);
toolRef.current = tool;
const announced = useRef(false);
useEffect(() => {
if (!announced.current) {
announced.current = true;
return;
}
if (typeof document === 'undefined') return;
document.dispatchEvent(
new CustomEvent(TOOL_SYNC_EVENT, { detail: { tool, from: syncId.current } })
);
}, [tool]);
useEffect(() => {
if (typeof document === 'undefined') return;
const onSync = (e: Event) => {
const d = (e as CustomEvent<{ tool?: unknown; from?: unknown }>).detail;
if (!d || d.from === syncId.current || typeof d.tool !== 'string') return;
// Already there: nothing to do, and — for a sticky-locked tool — not
// touching it keeps the lock.
if (d.tool === toolRef.current) return;
setTool(d.tool as Tool);
};
document.addEventListener(TOOL_SYNC_EVENT, onSync);
return () => document.removeEventListener(TOOL_SYNC_EVENT, onSync);
}, [setTool]);
// Cursor sync — applied inside the canvas (this hook runs in the canvas
// context). The active tool's cursor is set on AND forced across the
// whole canvas working area via an `!important` rule, so the custom cursor
// shows EVERYWHERE — including over artboard CONTENT, whose own `cursor:
// pointer`/`text`/… would otherwise win (Phase 24, the "custom cursors in the
// whole app" requirement; FigJam behaviour). Chrome that lives OUTSIDE
// `.dc-world` (tool palette, context toolbar, resize handles) is intentionally
// NOT matched, so its buttons/handles keep their affordance cursors. The
// viewport-controller still owns the grab/grabbing swap during space-pan.
useEffect(() => {
if (typeof document === 'undefined') return;
const desc = tools.find((t) => t.id === tool);
if (!desc) return;
const prev = document.body.style.cursor;
let styleEl = document.getElementById('dc-tool-cursor') as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = 'dc-tool-cursor';
document.head.appendChild(styleEl);
}
// feature-4 — the browse tool is a pure pass-through: it must NOT force a
// global cursor, or the `* { cursor: … !important }` rule below would beat
// the mock's own affordance cursors (pointer over a button, text over an
// input) and the canvas would stop reading as alive. Clear the forced rule
// and let the body/native cursors win.
if (tool === 'browse') {
document.body.style.cursor = '';
styleEl.textContent = '';
if (typeof window !== 'undefined' && window.parent && window.parent !== window) {
try {
window.parent.postMessage({ dgn: 'tool-cursor', tool }, '*');
} catch {
/* cross-origin parent rejected */
}
}
return () => {
document.body.style.cursor = prev;
};
}
document.body.style.cursor = desc.cursor;
// Truly GLOBAL inside the canvas document — `*` so it covers the empty grid
// host, `.dc-world`, every artboard + its content, AND the floating chrome
// (minimap, toolbar). The earlier `.dc-world`-scoped rule left the empty
// canvas / minimap on their own cursors; the brief is "prostě všude". (Mirrors
// the outer-shell `*` rule so both documents are uniformly covered.)
styleEl.textContent = `* { cursor: ${desc.cursor} !important; }`;
// Phase 24 — broadcast the active tool TOKEN to the OUTER app shell (this
// hook runs in the canvas iframe) so the shell shows the same custom cursor
// across the whole maude UI (sidebar / top bar). We send the tool *id*, NOT
// the cursor string: the shell resolves it against its own trusted
// TOOL_CURSORS copy (resolveToolCursor), so an untrusted synced canvas
// (DDR-054) can only pick a known, always-visible glyph — it can't inject an
// invisible/displaced cursor as a clickjacking aid (phase-24 ethical-hacker
// Finding 2; DDR-067).
if (typeof window !== 'undefined' && window.parent && window.parent !== window) {
try {
window.parent.postMessage({ dgn: 'tool-cursor', tool }, '*');
} catch {
/* cross-origin parent rejected — shell keeps its default cursor */
}
}
return () => {
document.body.style.cursor = prev;
const el = document.getElementById('dc-tool-cursor');
if (el) el.textContent = '';
};
}, [tool, tools]);
const value = useMemo(
() => ({
tool,
setTool,
tools,
mode,
setMode,
resetTool,
sticky,
toggleSticky,
clearSticky,
shapeKind,
setShapeKind,
}),
[tool, setTool, tools, mode, setMode, resetTool, sticky, toggleSticky, clearSticky, shapeKind]
);
return {children};
}
/**
* Mount a `ToolProvider` only when none exists above us. When the shell-owned
* comment mount layer (canvas-comment-mount.tsx) already provides one,
* `DesignCanvas` consumes that instance instead of double-mounting. The hook
* is called unconditionally; only the returned tree branches (hook rules).
*/
export function MaybeToolProvider({
children,
initial,
initialMode,
}: {
children: ReactNode;
/** DDR-223 — forwarded so the comment-mount layer can pin bare DS
* specimens to the preview/`browse` posture (see canvas-comment-mount). */
initial?: Tool;
initialMode?: CanvasMode;
}) {
const outer = useContext(ToolContext);
if (outer) return <>{children}>;
return (
{children}
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Hook
export function useToolMode(): ToolContextValue {
const ctx = useContext(ToolContext);
if (!ctx) {
throw new Error('useToolMode must be used inside ');
}
return ctx;
}
/**
* Read-only variant — returns `null` when no provider mounted. Used by
* components that can render outside a ToolProvider tree (the input
* router's optional path).
*/
export function useToolModeOptional(): ToolContextValue | null {
return useContext(ToolContext);
}