@1agh/maude 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +166 -0
- package/cli/bin/maude.exe +15 -0
- package/cli/bin/maude.mjs +45 -0
- package/cli/bin/mdcc.exe +10 -0
- package/cli/bin/mdcc.mjs +7 -0
- package/cli/cli-wrapper.cjs +67 -0
- package/cli/commands/config.mjs +94 -0
- package/cli/commands/design.mjs +386 -0
- package/cli/commands/help.mjs +57 -0
- package/cli/commands/init.mjs +178 -0
- package/cli/commands/version.mjs +7 -0
- package/cli/install.cjs +113 -0
- package/cli/lib/argv.mjs +37 -0
- package/cli/lib/argv.test.mjs +46 -0
- package/cli/lib/copy-tree.mjs +78 -0
- package/package.json +94 -0
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +397 -0
- package/plugins/design/dev-server/annotations-layer.tsx +1717 -0
- package/plugins/design/dev-server/api.ts +674 -0
- package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +50 -0
- package/plugins/design/dev-server/bin/bootstrap-check.sh +83 -0
- package/plugins/design/dev-server/bin/canvas-edit.sh +48 -0
- package/plugins/design/dev-server/bin/handoff.sh +27 -0
- package/plugins/design/dev-server/bin/screenshot.sh +232 -0
- package/plugins/design/dev-server/bin/server-up.sh +135 -0
- package/plugins/design/dev-server/bin/slug.sh +22 -0
- package/plugins/design/dev-server/bin/smoke.sh +272 -0
- package/plugins/design/dev-server/build.ts +267 -0
- package/plugins/design/dev-server/canvas-build.ts +219 -0
- package/plugins/design/dev-server/canvas-edit.ts +388 -0
- package/plugins/design/dev-server/canvas-header.ts +165 -0
- package/plugins/design/dev-server/canvas-icons.tsx +131 -0
- package/plugins/design/dev-server/canvas-lib-inline.ts +260 -0
- package/plugins/design/dev-server/canvas-lib-resolver.ts +85 -0
- package/plugins/design/dev-server/canvas-lib.tsx +1995 -0
- package/plugins/design/dev-server/canvas-meta.schema.json +181 -0
- package/plugins/design/dev-server/canvas-pipeline.ts +270 -0
- package/plugins/design/dev-server/canvas-shell.tsx +813 -0
- package/plugins/design/dev-server/client/app.jsx +2027 -0
- package/plugins/design/dev-server/client/hmr.mjs +85 -0
- package/plugins/design/dev-server/client/iframe-lazy.mjs +121 -0
- package/plugins/design/dev-server/client/index.html +15 -0
- package/plugins/design/dev-server/client/styles/0-reset.css +18 -0
- package/plugins/design/dev-server/client/styles/1-tokens.css +297 -0
- package/plugins/design/dev-server/client/styles/2-layout.css +35 -0
- package/plugins/design/dev-server/client/styles/3-shell.css +906 -0
- package/plugins/design/dev-server/client/styles/4-components.css +1268 -0
- package/plugins/design/dev-server/client/styles/5-utilities.css +4 -0
- package/plugins/design/dev-server/client/styles/_index.css +24 -0
- package/plugins/design/dev-server/client/styles.css +1419 -0
- package/plugins/design/dev-server/config.schema.json +147 -0
- package/plugins/design/dev-server/context-menu.tsx +343 -0
- package/plugins/design/dev-server/context.ts +173 -0
- package/plugins/design/dev-server/dist/client.bundle.js +20323 -0
- package/plugins/design/dev-server/dist/styles.css +2875 -0
- package/plugins/design/dev-server/examples/README.md +9 -0
- package/plugins/design/dev-server/examples/perf-100-artboards.tsx +113 -0
- package/plugins/design/dev-server/fs-watch.ts +63 -0
- package/plugins/design/dev-server/handoff.ts +721 -0
- package/plugins/design/dev-server/history.ts +125 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +114 -0
- package/plugins/design/dev-server/http.ts +413 -0
- package/plugins/design/dev-server/input-router.tsx +485 -0
- package/plugins/design/dev-server/inspect.ts +365 -0
- package/plugins/design/dev-server/locator.ts +159 -0
- package/plugins/design/dev-server/mem.ts +97 -0
- package/plugins/design/dev-server/runtime-bundle.ts +235 -0
- package/plugins/design/dev-server/server.mjs +1246 -0
- package/plugins/design/dev-server/server.ts +131 -0
- package/plugins/design/dev-server/test/_helpers.ts +81 -0
- package/plugins/design/dev-server/test/active-state.test.ts +145 -0
- package/plugins/design/dev-server/test/annotations-api.test.ts +146 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +419 -0
- package/plugins/design/dev-server/test/binary-smoke.test.ts +47 -0
- package/plugins/design/dev-server/test/bundle-smoke.test.ts +29 -0
- package/plugins/design/dev-server/test/canvas-build.test.ts +78 -0
- package/plugins/design/dev-server/test/canvas-edit.test.ts +139 -0
- package/plugins/design/dev-server/test/canvas-header.test.ts +127 -0
- package/plugins/design/dev-server/test/canvas-lib-inline.test.ts +146 -0
- package/plugins/design/dev-server/test/canvas-lib-resolver.test.ts +112 -0
- package/plugins/design/dev-server/test/canvas-meta-api.test.ts +236 -0
- package/plugins/design/dev-server/test/canvas-pipeline.test.ts +180 -0
- package/plugins/design/dev-server/test/canvas-route.test.ts +176 -0
- package/plugins/design/dev-server/test/fs-watch.test.ts +41 -0
- package/plugins/design/dev-server/test/handoff-static-frames.test.ts +215 -0
- package/plugins/design/dev-server/test/handoff.test.ts +281 -0
- package/plugins/design/dev-server/test/history-rollback.test.ts +62 -0
- package/plugins/design/dev-server/test/hmr-broadcast.test.ts +108 -0
- package/plugins/design/dev-server/test/input-router.test.ts +316 -0
- package/plugins/design/dev-server/test/locator.test.ts +214 -0
- package/plugins/design/dev-server/test/perf-harness.ts +193 -0
- package/plugins/design/dev-server/test/phase-3.6-smoke.test.ts +77 -0
- package/plugins/design/dev-server/test/runtime-bundle.test.ts +69 -0
- package/plugins/design/dev-server/test/server-lifecycle.test.ts +28 -0
- package/plugins/design/dev-server/test/tool-palette.test.tsx +55 -0
- package/plugins/design/dev-server/test/use-annotation-selection.test.tsx +77 -0
- package/plugins/design/dev-server/test/use-artboard-drag.test.ts +325 -0
- package/plugins/design/dev-server/test/use-selection-set.test.tsx +166 -0
- package/plugins/design/dev-server/test/use-snap-guides.test.ts +190 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +93 -0
- package/plugins/design/dev-server/test/ws-handshake.test.ts +33 -0
- package/plugins/design/dev-server/tool-palette.tsx +278 -0
- package/plugins/design/dev-server/tsconfig.json +26 -0
- package/plugins/design/dev-server/use-annotation-selection.tsx +92 -0
- package/plugins/design/dev-server/use-annotations-visibility.tsx +43 -0
- package/plugins/design/dev-server/use-artboard-drag.tsx +445 -0
- package/plugins/design/dev-server/use-selection-set.tsx +224 -0
- package/plugins/design/dev-server/use-snap-guides.tsx +215 -0
- package/plugins/design/dev-server/use-tool-mode.tsx +114 -0
- package/plugins/design/dev-server/ws.ts +90 -0
- package/plugins/design/templates/_shell.html +177 -0
- package/plugins/design/templates/canvas.tsx.template +54 -0
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +277 -0
- package/plugins/design/templates/design-system-inspiration/_README.md +71 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-banner.html +68 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-empty-state-generous.html +39 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-feature-grid.html +62 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-marketing-card.html +63 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-testimonial.html +80 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-code-block.html +71 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-diff-view.html +65 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-log-stream.html +62 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-monospace-table.html +57 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-terminal-pane.html +67 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/type-mono.html +57 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/colors-presence.html +74 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-command-palette.html +90 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-keyboard.html +51 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-list.html +89 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-shortcuts-overlay.html +85 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-toast-menu.html +80 -0
- package/plugins/design/templates/design-system-inspiration/core/INDEX.md.tpl +25 -0
- package/plugins/design/templates/design-system-inspiration/core/README.orchestration.md.tpl +71 -0
- package/plugins/design/templates/design-system-inspiration/core/README.philosophy.md.tpl +77 -0
- package/plugins/design/templates/design-system-inspiration/core/SKILL.md.tpl +50 -0
- package/plugins/design/templates/design-system-inspiration/core/colors_and_type.css.tpl +111 -0
- package/plugins/design/templates/design-system-inspiration/core/config.json.tpl +28 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/_layout.css +113 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/colors-accent.html +48 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/colors-surfaces.html +49 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/colors-text.html +52 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/components-buttons.html +83 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/components-cards.html +79 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/components-inputs.html +82 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/motion.html +66 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/spacing-scale.html +53 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/type-scale.html +62 -0
- package/plugins/design/templates/design-system-inspiration/foundations/borders.html +39 -0
- package/plugins/design/templates/design-system-inspiration/foundations/elevation.html +46 -0
- package/plugins/design/templates/design-system-inspiration/foundations/focus.html +48 -0
- package/plugins/design/templates/design-system-inspiration/foundations/grid.html +51 -0
- package/plugins/design/templates/design-system-inspiration/foundations/iconography.html +73 -0
- package/plugins/design/templates/design-system-inspiration/foundations/opacity.html +45 -0
- package/plugins/design/templates/design-system-inspiration/foundations/radii.html +40 -0
- package/plugins/design/templates/design-system-inspiration/foundations/selection.html +45 -0
- package/plugins/design/templates/design-system-inspiration/meta/accessibility.html +62 -0
- package/plugins/design/templates/design-system-inspiration/meta/i18n.html +73 -0
- package/plugins/design/templates/design-system-inspiration/meta/presence-multiplayer.html +71 -0
- package/plugins/design/templates/design-system-inspiration/meta/tokens-index.html +80 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-auth.html +78 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-data-density.html +61 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-error-pages.html +70 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-form-layouts.html +70 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-onboarding.html +71 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-pricing.html +83 -0
- package/plugins/design/templates/design-system-inspiration/platform-desktop/components-resize-panels.html +63 -0
- package/plugins/design/templates/design-system-inspiration/platform-desktop/ui_kits-desktop-index.html +55 -0
- package/plugins/design/templates/design-system-inspiration/platform-desktop/ui_kits-desktop-showcase.html +302 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/components-bottom-sheet.html +63 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/components-pull-to-refresh.html +74 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/components-segmented-control.html +51 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/components-tab-bar.html +57 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/ui_kits-mobile-index.html +58 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/ui_kits-mobile-showcase.html +237 -0
- package/plugins/design/templates/design-system-inspiration/status/colors-status.html +49 -0
- package/plugins/design/templates/design-system-inspiration/status/components-status.html +63 -0
- package/plugins/design/templates/design-system-inspiration/status/skeletons.html +74 -0
- package/plugins/design/templates/design-system-inspiration/theme-both/colors-themes-side-by-side.html +59 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-callout.html +74 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-dialogs.html +81 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-tables.html +101 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-toggles.html +74 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-tooltips.html +74 -0
- package/plugins/design/templates/design-system-inspiration/universal/empty-state.html.tpl +34 -0
- package/plugins/design/templates/design-system-inspiration/universal/logo.html +42 -0
- package/plugins/design/templates/ds-specimen.tsx.template +59 -0
- package/plugins/flow/.claude-plugin/config.schema.json +398 -0
- package/plugins/flow/README.md +45 -0
- package/plugins/flow/templates/ai-skeleton/INDEX.md +50 -0
- package/plugins/flow/templates/ai-skeleton/README.md +46 -0
- package/plugins/flow/templates/ai-skeleton/browser/har/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/browser/snapshots/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/business/README.md +12 -0
- package/plugins/flow/templates/ai-skeleton/context/README.md +12 -0
- package/plugins/flow/templates/ai-skeleton/decisions/README.md +20 -0
- package/plugins/flow/templates/ai-skeleton/design-import/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/dev-logs/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/device/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/docs/README.md +5 -0
- package/plugins/flow/templates/ai-skeleton/logs/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/logs/README.md +13 -0
- package/plugins/flow/templates/ai-skeleton/plans/README.md +16 -0
- package/plugins/flow/templates/ai-skeleton/plans/archive/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/release-guide.md +35 -0
- package/plugins/flow/templates/ai-skeleton/reviews/README.md +15 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +26 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/_lib/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/state/STATE.md +25 -0
- package/plugins/flow/templates/ai-skeleton/templates/HANDOFF.md +32 -0
- package/plugins/flow/templates/ai-skeleton/workflows.config.json +74 -0
|
@@ -0,0 +1,1995 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file canvas-lib.tsx — dev-server-bundled canvas library
|
|
3
|
+
* @scope plugins/design/dev-server/canvas-lib.tsx
|
|
4
|
+
* Ships with the dev-server install; resolved at canvas build time
|
|
5
|
+
* via the `@maude/canvas-lib` virtual specifier. Per DDR-025, this
|
|
6
|
+
* is the single source of truth — no project-side copy.
|
|
7
|
+
* @purpose Shared primitives + helpers + hooks for every TSX canvas
|
|
8
|
+
* (UI mocks + DS specimens). Imported via the virtual module
|
|
9
|
+
* specifier `@maude/canvas-lib`, which the dev-server's Bun.build
|
|
10
|
+
* resolver maps to this file. On /design:handoff the used exports
|
|
11
|
+
* are AST-inlined into the emitted registry-item so the consumer
|
|
12
|
+
* never sees the `@maude/canvas-lib` specifier.
|
|
13
|
+
*
|
|
14
|
+
* Exports (cold-reader cheat sheet):
|
|
15
|
+
*
|
|
16
|
+
* Frame envelope ─────────────────────────────────────────────────────────
|
|
17
|
+
* DesignCanvas Root wrapper. <div class="dc-canvas"> holding a
|
|
18
|
+
* transformable <div class="dc-world"> world plane.
|
|
19
|
+
* DCArtboard children inside the world are absolutely
|
|
20
|
+
* positioned in world coords; pan/zoom (Phase 4 T2)
|
|
21
|
+
* applies a single transform to the world plane.
|
|
22
|
+
* DCSection Group label. Inside DesignCanvas it collapses to a
|
|
23
|
+
* transparent wrapper (DCArtboard children take their
|
|
24
|
+
* own world coords); standalone it keeps the legacy
|
|
25
|
+
* <section>/<header><h2> chrome for specimens.
|
|
26
|
+
* DCArtboard Bordered artboard with SKU strip header. Inside
|
|
27
|
+
* DesignCanvas it absolutely-positions itself in world
|
|
28
|
+
* coords resolved from meta.layout (or default grid).
|
|
29
|
+
* Standalone it renders a fixed-size block at its given
|
|
30
|
+
* width/height (specimens / legacy uses).
|
|
31
|
+
* DCPostIt <aside class="dc-postit"> — sticky-note annotation.
|
|
32
|
+
*
|
|
33
|
+
* Specimen helpers ───────────────────────────────────────────────────────
|
|
34
|
+
* SpecimenHeader The .specimen-hd row (sku + crumbs + ThemeToggle).
|
|
35
|
+
* SpecimenMeta <dl class="specimen-meta"> ladder from entries[].
|
|
36
|
+
* KbdHint <kbd> chrome.
|
|
37
|
+
* TokenChip Inline visualiser for a var(--*) value.
|
|
38
|
+
* ColorSwatch Square + label for a color token.
|
|
39
|
+
* TypeScaleRow One row of a type-ladder specimen.
|
|
40
|
+
* ThemeToggle Light/dark <button> group writing data-theme on <html>.
|
|
41
|
+
*
|
|
42
|
+
* Hooks ──────────────────────────────────────────────────────────────────
|
|
43
|
+
* useTokens(prefix?) Resolves CSS custom properties from <html> computed style.
|
|
44
|
+
* useTheme() Current theme + setter, syncs to <html data-theme>.
|
|
45
|
+
* useArtboardBounds(ref) ResizeObserver wrapper returning {width,height}.
|
|
46
|
+
*
|
|
47
|
+
* Authoring vocabulary. Lift these before re-implementing equivalents. The
|
|
48
|
+
* surface intentionally mirrors the .html-era specimen idioms one-for-one
|
|
49
|
+
* (`.specimen-hd`, `.specimen-meta`, `.sku`, `.swatch`, `.stamp`) so existing
|
|
50
|
+
* `_components.css` rules still target them.
|
|
51
|
+
*
|
|
52
|
+
* data-cd-id IDs are injected by canvas-pipeline.ts pass 1 — including on the
|
|
53
|
+
* primitives below. That's fine; pipeline IDs change every time the lib
|
|
54
|
+
* changes. Don't pin lib-internal IDs in tests.
|
|
55
|
+
*
|
|
56
|
+
* Phase 4 (2026-05-19) — DesignCanvas became a transformable world plane.
|
|
57
|
+
* The engine is always on; a single-artboard canvas just defaults to
|
|
58
|
+
* fit-to-screen and looks identical to pre-Phase 4. Layout + viewport state
|
|
59
|
+
* live in `<file>.meta.json` via `window.__canvas_meta__` (T5 wiring).
|
|
60
|
+
*
|
|
61
|
+
* Phase 4.0.5 (2026-05-19) — relocated from `<designRoot>/_lib/canvas-lib.tsx`
|
|
62
|
+
* per DDR-025; single source in dev-server. No project-side copy is scaffolded
|
|
63
|
+
* anymore; legacy `_lib/` directories in downstream projects get a one-cycle
|
|
64
|
+
* deprecation log at dev-server boot.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
import {
|
|
68
|
+
type CSSProperties,
|
|
69
|
+
type ReactNode,
|
|
70
|
+
type PointerEvent as ReactPointerEvent,
|
|
71
|
+
type RefObject,
|
|
72
|
+
createContext,
|
|
73
|
+
isValidElement,
|
|
74
|
+
useCallback,
|
|
75
|
+
useContext,
|
|
76
|
+
useEffect,
|
|
77
|
+
useLayoutEffect,
|
|
78
|
+
useMemo,
|
|
79
|
+
useRef,
|
|
80
|
+
useState,
|
|
81
|
+
} from 'react';
|
|
82
|
+
|
|
83
|
+
import { CanvasShell } from './canvas-shell.tsx';
|
|
84
|
+
import { type DragState, useArtboardDrag } from './use-artboard-drag.tsx';
|
|
85
|
+
import { useSelectionSetOptional } from './use-selection-set.tsx';
|
|
86
|
+
import { ToolProvider, useToolModeOptional } from './use-tool-mode.tsx';
|
|
87
|
+
|
|
88
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
89
|
+
// Module constants
|
|
90
|
+
|
|
91
|
+
const ZOOM_MIN = 0.1;
|
|
92
|
+
const ZOOM_MAX = 4.0;
|
|
93
|
+
const ZOOM_STEP_IN = 1.2;
|
|
94
|
+
const ZOOM_STEP_OUT = 1 / 1.2;
|
|
95
|
+
const WHEEL_ZOOM_K = 0.0015; // larger = more sensitive wheel
|
|
96
|
+
const SETTLE_MS = 500;
|
|
97
|
+
const PUBLISH_MS = 50;
|
|
98
|
+
|
|
99
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
100
|
+
// Engine CSS (Phase 4) — injected once per iframe inside DesignCanvas's mount.
|
|
101
|
+
// The visual chrome of `.dc-artboard` (borders, label strip, SKU type) still
|
|
102
|
+
// lives in the DS's _components.css. Engine CSS ONLY covers positioning + the
|
|
103
|
+
// world transform. Idempotent via the `dc-engine-css` id check.
|
|
104
|
+
|
|
105
|
+
const ENGINE_CSS = `
|
|
106
|
+
.dc-canvas {
|
|
107
|
+
position: absolute;
|
|
108
|
+
inset: 0;
|
|
109
|
+
overflow: hidden;
|
|
110
|
+
outline: none;
|
|
111
|
+
background-color: var(--bg-1, #f4f1ea);
|
|
112
|
+
background-image:
|
|
113
|
+
linear-gradient(var(--border-subtle, rgba(0,0,0,0.08)) 1px, transparent 1px),
|
|
114
|
+
linear-gradient(90deg, var(--border-subtle, rgba(0,0,0,0.08)) 1px, transparent 1px);
|
|
115
|
+
background-size: 24px 24px;
|
|
116
|
+
}
|
|
117
|
+
.dc-canvas:focus { outline: none; }
|
|
118
|
+
.dc-world {
|
|
119
|
+
position: absolute;
|
|
120
|
+
top: 0;
|
|
121
|
+
left: 0;
|
|
122
|
+
/* CSS zoom drives the scale; transform handles pan. transform-origin is
|
|
123
|
+
irrelevant under zoom (zoom anchors top-left of the box). will-change
|
|
124
|
+
hints to the compositor that this layer changes often. */
|
|
125
|
+
will-change: transform;
|
|
126
|
+
}
|
|
127
|
+
.dc-section-collapsed { display: contents; }
|
|
128
|
+
|
|
129
|
+
.dc-canvas .dc-artboard {
|
|
130
|
+
background: var(--bg-0, #ffffff);
|
|
131
|
+
color: var(--fg-0, #2a2520);
|
|
132
|
+
border: 1px solid var(--fg-0, #2a2520);
|
|
133
|
+
box-shadow: 6px 6px 0 var(--fg-0, #2a2520);
|
|
134
|
+
display: flex;
|
|
135
|
+
flex-direction: column;
|
|
136
|
+
overflow: hidden;
|
|
137
|
+
}
|
|
138
|
+
.dc-canvas .dc-artboard.dc-positioned { position: absolute; }
|
|
139
|
+
.dc-canvas .dc-artboard-label {
|
|
140
|
+
flex-shrink: 0;
|
|
141
|
+
background: var(--bg-2, #e8e3d8);
|
|
142
|
+
border-bottom: 1px solid var(--fg-0, #2a2520);
|
|
143
|
+
padding: 6px 14px;
|
|
144
|
+
font-size: 10px;
|
|
145
|
+
letter-spacing: 0.06em;
|
|
146
|
+
text-transform: uppercase;
|
|
147
|
+
color: var(--fg-1, #4a3f30);
|
|
148
|
+
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
149
|
+
}
|
|
150
|
+
.dc-canvas .dc-artboard-body {
|
|
151
|
+
flex: 1;
|
|
152
|
+
position: relative;
|
|
153
|
+
overflow: hidden;
|
|
154
|
+
}
|
|
155
|
+
button.dc-artboard-label {
|
|
156
|
+
appearance: none;
|
|
157
|
+
border-width: 0 0 1px 0;
|
|
158
|
+
font: inherit;
|
|
159
|
+
cursor: pointer;
|
|
160
|
+
text-align: left;
|
|
161
|
+
display: block;
|
|
162
|
+
width: 100%;
|
|
163
|
+
}
|
|
164
|
+
button.dc-artboard-label:focus-visible { outline: 2px solid var(--accent, #d63b1f); outline-offset: -2px; }
|
|
165
|
+
/* Active-artboard ring is in canvas-shell HALO_CSS (subtle 1 px tint). */
|
|
166
|
+
/* Phase 4.2 — drag chrome. */
|
|
167
|
+
.dc-canvas[data-active-tool="move"] .dc-artboard-label { cursor: grab; }
|
|
168
|
+
.dc-canvas[data-active-tool="move"] .dc-artboard-label:active { cursor: grabbing; }
|
|
169
|
+
.dc-canvas .dc-artboard.dc-dragging { opacity: 0.3; }
|
|
170
|
+
.dc-canvas .dc-artboard-ghost {
|
|
171
|
+
position: absolute;
|
|
172
|
+
pointer-events: none;
|
|
173
|
+
opacity: 0.5;
|
|
174
|
+
background: var(--bg-0, #ffffff);
|
|
175
|
+
border: 1px solid var(--fg-0, #2a2520);
|
|
176
|
+
box-shadow: 6px 6px 0 var(--fg-0, #2a2520);
|
|
177
|
+
z-index: 4;
|
|
178
|
+
}
|
|
179
|
+
.dc-canvas .dc-artboard-ghost-label {
|
|
180
|
+
background: var(--bg-2, #e8e3d8);
|
|
181
|
+
border-bottom: 1px solid var(--fg-0, #2a2520);
|
|
182
|
+
padding: 6px 14px;
|
|
183
|
+
font-size: 10px;
|
|
184
|
+
letter-spacing: 0.06em;
|
|
185
|
+
text-transform: uppercase;
|
|
186
|
+
color: var(--fg-1, #4a3f30);
|
|
187
|
+
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
188
|
+
}
|
|
189
|
+
`.trim();
|
|
190
|
+
|
|
191
|
+
function ensureEngineStyles(): void {
|
|
192
|
+
if (typeof document === 'undefined') return;
|
|
193
|
+
if (document.getElementById('dc-engine-css')) return;
|
|
194
|
+
const s = document.createElement('style');
|
|
195
|
+
s.id = 'dc-engine-css';
|
|
196
|
+
s.textContent = ENGINE_CSS;
|
|
197
|
+
document.head.appendChild(s);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
201
|
+
// World context — published by DesignCanvas. Consumed by DCArtboard (for
|
|
202
|
+
// world-coord positioning) and by DCSection (which collapses inside the
|
|
203
|
+
// canvas) and by future T3 components (MiniMap, ZoomToolbar).
|
|
204
|
+
|
|
205
|
+
export interface ArtboardRect {
|
|
206
|
+
id: string;
|
|
207
|
+
x: number;
|
|
208
|
+
y: number;
|
|
209
|
+
w: number;
|
|
210
|
+
h: number;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface ViewportState {
|
|
214
|
+
x: number;
|
|
215
|
+
y: number;
|
|
216
|
+
zoom: number;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface WorldContextValue {
|
|
220
|
+
/** Look up a DCArtboard's world-coord rect by its `id` prop. */
|
|
221
|
+
rectFor: (id: string) => ArtboardRect | null;
|
|
222
|
+
/** All artboards in render order, with resolved world-coord positions. */
|
|
223
|
+
artboards: ArtboardRect[];
|
|
224
|
+
/** Current pan/zoom state. `null` until the first useLayoutEffect runs. */
|
|
225
|
+
viewport: ViewportState | null;
|
|
226
|
+
/** id of the artboard closest to the viewport center (recomputed on settle). */
|
|
227
|
+
activeArtboardId: string | null;
|
|
228
|
+
/** The fixed-bleed `.dc-canvas` host (visible iframe area). */
|
|
229
|
+
hostRef: RefObject<HTMLDivElement | null>;
|
|
230
|
+
/** The transformable `.dc-world` element. */
|
|
231
|
+
worldRef: RefObject<HTMLDivElement | null>;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const WorldContext = createContext<WorldContextValue | null>(null);
|
|
235
|
+
|
|
236
|
+
function useWorldContext(): WorldContextValue | null {
|
|
237
|
+
return useContext(WorldContext);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Phase 5.1: annotations-layer needs the world `<div>` to portal a stroke SVG
|
|
241
|
+
// inside it (so CSS zoom + translate apply natively, no per-frame projection
|
|
242
|
+
// math). Expose only the ref — the rest of WorldContextValue stays internal.
|
|
243
|
+
export function useWorldRefContext(): RefObject<HTMLDivElement | null> | null {
|
|
244
|
+
const ctx = useContext(WorldContext);
|
|
245
|
+
return ctx?.worldRef ?? null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
249
|
+
// Layout synthesis. Default grid + fit-to-screen compute. Phase 4 T2 will
|
|
250
|
+
// replace the "always re-fit on resize" useLayoutEffect with the
|
|
251
|
+
// useViewportController hook; for T1 we hold the world at fit-to-screen
|
|
252
|
+
// (or at meta.viewport if seeded) and don't yet expose pan/zoom inputs.
|
|
253
|
+
|
|
254
|
+
const VP_GRID = { cols: 3, w: 1280, h: 820, gutter: 80 } as const;
|
|
255
|
+
|
|
256
|
+
interface ArtboardSeed {
|
|
257
|
+
id: string;
|
|
258
|
+
w: number;
|
|
259
|
+
h: number;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Walk a children subtree harvesting DCArtboard descriptors in render order.
|
|
264
|
+
* DCSection (and any other wrapper) is traversed but doesn't appear in the
|
|
265
|
+
* harvest. Identity-matches against the DCArtboard reference so renamed
|
|
266
|
+
* imports don't trip it; falls back to displayName for minified builds.
|
|
267
|
+
*/
|
|
268
|
+
function harvestArtboards(children: ReactNode): ArtboardSeed[] {
|
|
269
|
+
const out: ArtboardSeed[] = [];
|
|
270
|
+
let auto = 0;
|
|
271
|
+
function visit(node: ReactNode): void {
|
|
272
|
+
if (node == null || typeof node === 'boolean') return;
|
|
273
|
+
if (Array.isArray(node)) {
|
|
274
|
+
for (const c of node) visit(c);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (!isValidElement(node)) return;
|
|
278
|
+
const type = node.type;
|
|
279
|
+
const isArtboard =
|
|
280
|
+
type === DCArtboard ||
|
|
281
|
+
(typeof type === 'function' &&
|
|
282
|
+
(type as { displayName?: string }).displayName === 'DCArtboard');
|
|
283
|
+
if (isArtboard) {
|
|
284
|
+
const props = node.props as { id?: string; width?: number; height?: number };
|
|
285
|
+
out.push({
|
|
286
|
+
id: typeof props.id === 'string' && props.id.length > 0 ? props.id : `__ab_${auto}`,
|
|
287
|
+
w: typeof props.width === 'number' ? props.width : VP_GRID.w,
|
|
288
|
+
h: typeof props.height === 'number' ? props.height : VP_GRID.h,
|
|
289
|
+
});
|
|
290
|
+
auto++;
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const childProp = (node.props as { children?: ReactNode } | null | undefined)?.children;
|
|
294
|
+
if (childProp != null) visit(childProp);
|
|
295
|
+
}
|
|
296
|
+
visit(children);
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function synthDefaultGrid(seeds: ArtboardSeed[]): ArtboardRect[] {
|
|
301
|
+
// Render order (the order DCArtboards appear in JSX), not alphabetical —
|
|
302
|
+
// authors label artboards DS-01 / DS-02 / CV-01 etc. and expect that
|
|
303
|
+
// numeric order to show top-left → bottom-right, but their ids are usually
|
|
304
|
+
// semantic (`landing`, `docs-article`, `cmd-k`, `about`) which would
|
|
305
|
+
// shuffle the numeric order.
|
|
306
|
+
// Column / row size come from the largest artboard so canvases with mixed
|
|
307
|
+
// dimensions (width=1440 on Docs Site, width=1280 elsewhere) don't bleed
|
|
308
|
+
// past a 1280-step grid.
|
|
309
|
+
if (seeds.length === 0) return [];
|
|
310
|
+
const cellW = seeds.reduce((m, s) => Math.max(m, s.w), 0) || VP_GRID.w;
|
|
311
|
+
const cellH = seeds.reduce((m, s) => Math.max(m, s.h), 0) || VP_GRID.h;
|
|
312
|
+
return seeds.map((seed, i) => {
|
|
313
|
+
const col = i % VP_GRID.cols;
|
|
314
|
+
const row = Math.floor(i / VP_GRID.cols);
|
|
315
|
+
return {
|
|
316
|
+
id: seed.id,
|
|
317
|
+
x: col * (cellW + VP_GRID.gutter),
|
|
318
|
+
y: row * (cellH + VP_GRID.gutter),
|
|
319
|
+
w: seed.w,
|
|
320
|
+
h: seed.h,
|
|
321
|
+
};
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function computeFit(rects: ArtboardRect[], hostEl: HTMLElement, pad = 24): ViewportState {
|
|
326
|
+
if (rects.length === 0) return { x: 0, y: 0, zoom: 1 };
|
|
327
|
+
let xMin = Number.POSITIVE_INFINITY;
|
|
328
|
+
let yMin = Number.POSITIVE_INFINITY;
|
|
329
|
+
let xMax = Number.NEGATIVE_INFINITY;
|
|
330
|
+
let yMax = Number.NEGATIVE_INFINITY;
|
|
331
|
+
for (const r of rects) {
|
|
332
|
+
if (r.x < xMin) xMin = r.x;
|
|
333
|
+
if (r.y < yMin) yMin = r.y;
|
|
334
|
+
if (r.x + r.w > xMax) xMax = r.x + r.w;
|
|
335
|
+
if (r.y + r.h > yMax) yMax = r.y + r.h;
|
|
336
|
+
}
|
|
337
|
+
const bw = xMax - xMin;
|
|
338
|
+
const bh = yMax - yMin;
|
|
339
|
+
const vw = hostEl.clientWidth;
|
|
340
|
+
const vh = hostEl.clientHeight;
|
|
341
|
+
if (!vw || !vh || bw <= 0 || bh <= 0) return { x: 0, y: 0, zoom: 1 };
|
|
342
|
+
const zoom = Math.min((vw - pad * 2) / bw, (vh - pad * 2) / bh, 1.0);
|
|
343
|
+
const x = (vw - bw * zoom) / 2 - xMin * zoom;
|
|
344
|
+
const y = (vh - bh * zoom) / 2 - yMin * zoom;
|
|
345
|
+
return { x, y, zoom };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Read the canvas-meta sidecar that the dev-server's `_shell.html` injects
|
|
350
|
+
* on `window.__canvas_meta__` (Phase 4 T5). Returns undefined if the canvas
|
|
351
|
+
* is mounted outside the shell (specimens / unit tests).
|
|
352
|
+
*/
|
|
353
|
+
function readCanvasMeta():
|
|
354
|
+
| {
|
|
355
|
+
layout?: { artboards?: ArtboardRect[] };
|
|
356
|
+
viewport?: ViewportState;
|
|
357
|
+
}
|
|
358
|
+
| undefined {
|
|
359
|
+
if (typeof window === 'undefined') return undefined;
|
|
360
|
+
const w = window as unknown as {
|
|
361
|
+
__canvas_meta__?: {
|
|
362
|
+
layout?: { artboards?: ArtboardRect[] };
|
|
363
|
+
viewport?: ViewportState;
|
|
364
|
+
};
|
|
365
|
+
};
|
|
366
|
+
return w.__canvas_meta__;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Returns the repo-relative path the shell stashed alongside the meta so
|
|
371
|
+
* onSettle PATCHes know which sidecar to write back to.
|
|
372
|
+
*/
|
|
373
|
+
function readCanvasMetaFile(): string | null {
|
|
374
|
+
if (typeof window === 'undefined') return null;
|
|
375
|
+
const w = window as unknown as { __canvas_meta_file__?: string };
|
|
376
|
+
return typeof w.__canvas_meta_file__ === 'string' ? w.__canvas_meta_file__ : null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* PATCH the canvas-meta sidecar with `{ viewport }` or `{ layout }`. Best-effort
|
|
381
|
+
* fire-and-forget — failures are logged but don't disrupt the canvas.
|
|
382
|
+
*
|
|
383
|
+
* Phase 4.2 (DDR-027): artboard `w`/`h` is JSX-authoritative. The writer
|
|
384
|
+
* strips any `w`/`h` keys from `layout.artboards[]` before PATCH so a drag
|
|
385
|
+
* commit only persists the position pair `{ id, x, y }`. The reader stays
|
|
386
|
+
* tolerant of legacy entries that still carry `w`/`h` (Phase 4 default-grid
|
|
387
|
+
* snapshots remain readable until the next drag overwrites them).
|
|
388
|
+
*/
|
|
389
|
+
/**
|
|
390
|
+
* Wire shape persisted to `meta.layout.artboards[]`. DDR-027: positions only,
|
|
391
|
+
* size is JSX-authoritative. Distinct from the in-memory `ArtboardRect` which
|
|
392
|
+
* still carries `w`/`h` for layout math.
|
|
393
|
+
*/
|
|
394
|
+
interface PersistedArtboardLayout {
|
|
395
|
+
id: string;
|
|
396
|
+
x: number;
|
|
397
|
+
y: number;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function patchCanvasMeta(patch: {
|
|
401
|
+
viewport?: ViewportState;
|
|
402
|
+
layout?: { artboards: ArtboardRect[] };
|
|
403
|
+
}): void {
|
|
404
|
+
if (typeof window === 'undefined' || typeof fetch === 'undefined') return;
|
|
405
|
+
const file = readCanvasMetaFile();
|
|
406
|
+
if (!file) return;
|
|
407
|
+
const sanitized: {
|
|
408
|
+
viewport?: ViewportState;
|
|
409
|
+
layout?: { artboards: PersistedArtboardLayout[] };
|
|
410
|
+
} = {};
|
|
411
|
+
if (patch.viewport) sanitized.viewport = patch.viewport;
|
|
412
|
+
if (patch.layout?.artboards) {
|
|
413
|
+
sanitized.layout = {
|
|
414
|
+
artboards: patch.layout.artboards.map((r) => ({
|
|
415
|
+
id: r.id,
|
|
416
|
+
x: r.x,
|
|
417
|
+
y: r.y,
|
|
418
|
+
})),
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
fetch('/_api/canvas-meta', {
|
|
422
|
+
method: 'PATCH',
|
|
423
|
+
headers: { 'content-type': 'application/json' },
|
|
424
|
+
body: JSON.stringify({ file, patch: sanitized }),
|
|
425
|
+
}).catch((err) => {
|
|
426
|
+
console.warn('[canvas-lib] persist viewport failed:', err);
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
431
|
+
// useViewportController (Phase 4 T2)
|
|
432
|
+
//
|
|
433
|
+
// Owns the canvas's pan/zoom/pinch/spacebar-drag/middle-mouse-drag/Cmd shortcuts.
|
|
434
|
+
// Scoped to the canvas iframe via hostRef — no shell-level keyboard leaks.
|
|
435
|
+
// While the user is actively gesturing, the hook writes the world transform
|
|
436
|
+
// straight to `worldRef.current.style.transform` for a 60 fps path; React state
|
|
437
|
+
// publishes on a 50 ms throttle so MiniMap + ZoomToolbar consumers re-render
|
|
438
|
+
// at a comfortable cadence. `onSettle` fires 500 ms after the last input so
|
|
439
|
+
// T5 can persist the viewport without flooding writes during a drag.
|
|
440
|
+
|
|
441
|
+
export interface ViewportControllerOptions {
|
|
442
|
+
hostRef: RefObject<HTMLDivElement | null>;
|
|
443
|
+
worldRef: RefObject<HTMLDivElement | null>;
|
|
444
|
+
/** Computes a fit-to-screen viewport for the current artboard set. */
|
|
445
|
+
computeFit: () => ViewportState;
|
|
446
|
+
/**
|
|
447
|
+
* Initial viewport. Read once at mount. Return `null` to defer until the
|
|
448
|
+
* host has a measured size (the hook will re-init on the first ResizeObserver
|
|
449
|
+
* tick that produces a non-zero host).
|
|
450
|
+
*/
|
|
451
|
+
getInitial: () => ViewportState | null;
|
|
452
|
+
/** Called debounced ~500 ms after the last input. */
|
|
453
|
+
onSettle?: (v: ViewportState) => void;
|
|
454
|
+
/**
|
|
455
|
+
* Jump-target rects (in world coords) for Cmd+Option+1..9. The N-th entry
|
|
456
|
+
* fits that rect inside the host. Provided by DesignCanvas which has the
|
|
457
|
+
* artboard list. Optional — keyboard jumps no-op when omitted.
|
|
458
|
+
*/
|
|
459
|
+
jumpTargets?: ArtboardRect[];
|
|
460
|
+
/**
|
|
461
|
+
* Phase 4.1 hand-tool support. When this predicate returns `true`, bare
|
|
462
|
+
* left-button pointerdown initiates a pan drag (no Space required). The
|
|
463
|
+
* predicate is read per-event so the consumer can return the live tool
|
|
464
|
+
* state. Omit / return `false` to keep the Phase-4 behavior (drag only
|
|
465
|
+
* with Space or middle-mouse).
|
|
466
|
+
*/
|
|
467
|
+
isPanDragActive?: () => boolean;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export interface ViewportControllerHandle {
|
|
471
|
+
/** Current viewport state. Throttled — for tight render loops use the ref. */
|
|
472
|
+
viewport: ViewportState;
|
|
473
|
+
/** Snap to an arbitrary viewport (used by MiniMap drag, T3). */
|
|
474
|
+
setViewport: (v: ViewportState) => void;
|
|
475
|
+
/** Pan by (dx, dy) screen px. */
|
|
476
|
+
panBy: (dx: number, dy: number) => void;
|
|
477
|
+
/** Multiply zoom by factor, preserving (cx, cy) screen px under the cursor. */
|
|
478
|
+
zoomAt: (factor: number, cx: number, cy: number) => void;
|
|
479
|
+
/** Cmd+0 — fit-to-screen on the artboard union. */
|
|
480
|
+
fit: () => void;
|
|
481
|
+
/** Cmd+1 — actual size (zoom = 1.0), recentered on viewport midpoint. */
|
|
482
|
+
reset: () => void;
|
|
483
|
+
/** Cmd+= — zoom in 1.2× at host center. */
|
|
484
|
+
zoomIn: () => void;
|
|
485
|
+
/** Cmd+- — zoom out at host center. */
|
|
486
|
+
zoomOut: () => void;
|
|
487
|
+
/** Jump to the rect at `index` with smooth fit (used by T4 click-to-focus). */
|
|
488
|
+
jumpTo: (rect: ArtboardRect) => void;
|
|
489
|
+
/** Animate to a target viewport over `durationMs` (reduced-motion = instant). */
|
|
490
|
+
animateTo: (target: ViewportState, durationMs?: number) => void;
|
|
491
|
+
/** True while the user is actively gesturing (drag / wheel run). */
|
|
492
|
+
isInteracting: boolean;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function clampZoom(z: number): number {
|
|
496
|
+
if (!Number.isFinite(z)) return 1;
|
|
497
|
+
return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z));
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function prefersReducedMotion(): boolean {
|
|
501
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
|
|
502
|
+
try {
|
|
503
|
+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
504
|
+
} catch {
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function fitRectIntoHost(rect: ArtboardRect, hostEl: HTMLElement, pad = 24): ViewportState {
|
|
510
|
+
return computeFit([rect], hostEl, pad);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export function useViewportController(opts: ViewportControllerOptions): ViewportControllerHandle {
|
|
514
|
+
const {
|
|
515
|
+
hostRef,
|
|
516
|
+
worldRef,
|
|
517
|
+
computeFit: computeFitFn,
|
|
518
|
+
getInitial,
|
|
519
|
+
onSettle,
|
|
520
|
+
jumpTargets,
|
|
521
|
+
isPanDragActive,
|
|
522
|
+
} = opts;
|
|
523
|
+
const isPanDragActiveRef = useRef<(() => boolean) | undefined>(isPanDragActive);
|
|
524
|
+
isPanDragActiveRef.current = isPanDragActive;
|
|
525
|
+
|
|
526
|
+
// Canonical viewport in a ref — synchronous, drives the world transform.
|
|
527
|
+
const vpRef = useRef<ViewportState>({ x: 0, y: 0, zoom: 1 });
|
|
528
|
+
const [viewport, setViewportPublished] = useState<ViewportState>({ x: 0, y: 0, zoom: 1 });
|
|
529
|
+
const [isInteracting, setIsInteracting] = useState(false);
|
|
530
|
+
const interactingRef = useRef(false);
|
|
531
|
+
const isInteractingStateRef = useRef(false);
|
|
532
|
+
|
|
533
|
+
// Throttle / settle timers.
|
|
534
|
+
const publishTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
535
|
+
const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
536
|
+
const interactEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
537
|
+
|
|
538
|
+
// Stable refs to options that may change between renders.
|
|
539
|
+
const computeFitRef = useRef(computeFitFn);
|
|
540
|
+
computeFitRef.current = computeFitFn;
|
|
541
|
+
const onSettleRef = useRef(onSettle);
|
|
542
|
+
onSettleRef.current = onSettle;
|
|
543
|
+
const jumpTargetsRef = useRef(jumpTargets);
|
|
544
|
+
jumpTargetsRef.current = jumpTargets;
|
|
545
|
+
|
|
546
|
+
// worldRef is stable across renders — read inside callbacks lazily, no dep.
|
|
547
|
+
// Use CSS `zoom` (not `transform: scale`) for the scale dimension. `zoom`
|
|
548
|
+
// re-flows layout at the new size so the browser re-rasterizes text at the
|
|
549
|
+
// target resolution — text stays crisp at any zoom level. `transform: scale`
|
|
550
|
+
// upscales a cached layer, which produces the pixelation users see at
|
|
551
|
+
// zoom > ~1.5. CSS `zoom` is supported in Chrome / Safari / Edge (always)
|
|
552
|
+
// and Firefox 126+; for a dev-server design tool that's full coverage.
|
|
553
|
+
//
|
|
554
|
+
// ! Subtle: CSS `zoom: N` makes `transform: translate(Xpx, Ypx)` translate by
|
|
555
|
+
// ! N×X / N×Y screen pixels (translate is in the *pre-zoom* coord space, then
|
|
556
|
+
// ! the whole layer is zoomed). Our controller's `vpRef` holds the translate
|
|
557
|
+
// ! in *screen* pixels (the same convention as `transform: scale(N)
|
|
558
|
+
// ! translate(...)` had), so we divide by zoom at write time to convert into
|
|
559
|
+
// ! the CSS-zoom world. The data model stays simple and pan/zoom math (in
|
|
560
|
+
// ! particular zoom-around-cursor) keeps using screen-px throughout.
|
|
561
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: refs only — stable identity by design.
|
|
562
|
+
const writeTransform = useCallback((v: ViewportState) => {
|
|
563
|
+
const el = worldRef.current;
|
|
564
|
+
if (!el) return;
|
|
565
|
+
const z = v.zoom || 1;
|
|
566
|
+
el.style.transform = `translate(${v.x / z}px, ${v.y / z}px)`;
|
|
567
|
+
el.style.zoom = String(z);
|
|
568
|
+
el.style.visibility = 'visible';
|
|
569
|
+
}, []);
|
|
570
|
+
|
|
571
|
+
const schedulePublish = useCallback(() => {
|
|
572
|
+
if (publishTimerRef.current != null) return;
|
|
573
|
+
publishTimerRef.current = setTimeout(() => {
|
|
574
|
+
publishTimerRef.current = null;
|
|
575
|
+
setViewportPublished({ ...vpRef.current });
|
|
576
|
+
}, PUBLISH_MS);
|
|
577
|
+
}, []);
|
|
578
|
+
|
|
579
|
+
const scheduleSettle = useCallback(() => {
|
|
580
|
+
if (settleTimerRef.current != null) clearTimeout(settleTimerRef.current);
|
|
581
|
+
settleTimerRef.current = setTimeout(() => {
|
|
582
|
+
settleTimerRef.current = null;
|
|
583
|
+
const cb = onSettleRef.current;
|
|
584
|
+
if (cb) cb({ ...vpRef.current });
|
|
585
|
+
}, SETTLE_MS);
|
|
586
|
+
}, []);
|
|
587
|
+
|
|
588
|
+
// Read the interacting flag from a ref so this callback identity stays
|
|
589
|
+
// stable across renders — otherwise applyViewport (and the listeners that
|
|
590
|
+
// close over it) get torn down on every state update, eating mid-gesture
|
|
591
|
+
// pointer events.
|
|
592
|
+
const markInteracting = useCallback(() => {
|
|
593
|
+
interactingRef.current = true;
|
|
594
|
+
if (!isInteractingStateRef.current) {
|
|
595
|
+
isInteractingStateRef.current = true;
|
|
596
|
+
setIsInteracting(true);
|
|
597
|
+
}
|
|
598
|
+
if (interactEndTimerRef.current != null) clearTimeout(interactEndTimerRef.current);
|
|
599
|
+
interactEndTimerRef.current = setTimeout(() => {
|
|
600
|
+
interactingRef.current = false;
|
|
601
|
+
isInteractingStateRef.current = false;
|
|
602
|
+
setIsInteracting(false);
|
|
603
|
+
interactEndTimerRef.current = null;
|
|
604
|
+
}, 220);
|
|
605
|
+
}, []);
|
|
606
|
+
|
|
607
|
+
const applyViewport = useCallback(
|
|
608
|
+
(next: ViewportState) => {
|
|
609
|
+
const clamped: ViewportState = {
|
|
610
|
+
x: Number.isFinite(next.x) ? next.x : 0,
|
|
611
|
+
y: Number.isFinite(next.y) ? next.y : 0,
|
|
612
|
+
zoom: clampZoom(next.zoom),
|
|
613
|
+
};
|
|
614
|
+
vpRef.current = clamped;
|
|
615
|
+
writeTransform(clamped);
|
|
616
|
+
schedulePublish();
|
|
617
|
+
scheduleSettle();
|
|
618
|
+
markInteracting();
|
|
619
|
+
},
|
|
620
|
+
[writeTransform, schedulePublish, scheduleSettle, markInteracting]
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
// Imperative API ------------------------------------------------------------
|
|
624
|
+
|
|
625
|
+
const setViewport = useCallback((v: ViewportState) => applyViewport(v), [applyViewport]);
|
|
626
|
+
|
|
627
|
+
const panBy = useCallback(
|
|
628
|
+
(dx: number, dy: number) => {
|
|
629
|
+
const v = vpRef.current;
|
|
630
|
+
applyViewport({ x: v.x + dx, y: v.y + dy, zoom: v.zoom });
|
|
631
|
+
},
|
|
632
|
+
[applyViewport]
|
|
633
|
+
);
|
|
634
|
+
|
|
635
|
+
const zoomAt = useCallback(
|
|
636
|
+
(factor: number, cx: number, cy: number) => {
|
|
637
|
+
const v = vpRef.current;
|
|
638
|
+
const newZoom = clampZoom(v.zoom * factor);
|
|
639
|
+
// World coord under (cx, cy) before the zoom change.
|
|
640
|
+
const wx = (cx - v.x) / v.zoom;
|
|
641
|
+
const wy = (cy - v.y) / v.zoom;
|
|
642
|
+
const next: ViewportState = {
|
|
643
|
+
x: cx - wx * newZoom,
|
|
644
|
+
y: cy - wy * newZoom,
|
|
645
|
+
zoom: newZoom,
|
|
646
|
+
};
|
|
647
|
+
applyViewport(next);
|
|
648
|
+
},
|
|
649
|
+
[applyViewport]
|
|
650
|
+
);
|
|
651
|
+
|
|
652
|
+
const fit = useCallback(() => {
|
|
653
|
+
const next = computeFitRef.current();
|
|
654
|
+
applyViewport(next);
|
|
655
|
+
}, [applyViewport]);
|
|
656
|
+
|
|
657
|
+
const reset = useCallback(() => {
|
|
658
|
+
const host = hostRef.current;
|
|
659
|
+
if (!host) {
|
|
660
|
+
applyViewport({ x: 0, y: 0, zoom: 1 });
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const cx = host.clientWidth / 2;
|
|
664
|
+
const cy = host.clientHeight / 2;
|
|
665
|
+
zoomAt(1 / vpRef.current.zoom, cx, cy);
|
|
666
|
+
}, [hostRef, applyViewport, zoomAt]);
|
|
667
|
+
|
|
668
|
+
const zoomIn = useCallback(() => {
|
|
669
|
+
const host = hostRef.current;
|
|
670
|
+
if (!host) return;
|
|
671
|
+
zoomAt(ZOOM_STEP_IN, host.clientWidth / 2, host.clientHeight / 2);
|
|
672
|
+
}, [hostRef, zoomAt]);
|
|
673
|
+
|
|
674
|
+
const zoomOut = useCallback(() => {
|
|
675
|
+
const host = hostRef.current;
|
|
676
|
+
if (!host) return;
|
|
677
|
+
zoomAt(ZOOM_STEP_OUT, host.clientWidth / 2, host.clientHeight / 2);
|
|
678
|
+
}, [hostRef, zoomAt]);
|
|
679
|
+
|
|
680
|
+
// Animation — rAF-driven ease-out cubic, falls through to apply on each
|
|
681
|
+
// frame so MiniMap / ZoomToolbar follow the trajectory live.
|
|
682
|
+
const animationRef = useRef<number | null>(null);
|
|
683
|
+
|
|
684
|
+
const animateTo = useCallback(
|
|
685
|
+
(target: ViewportState, durationMs = 240) => {
|
|
686
|
+
if (animationRef.current != null) {
|
|
687
|
+
cancelAnimationFrame(animationRef.current);
|
|
688
|
+
animationRef.current = null;
|
|
689
|
+
}
|
|
690
|
+
const dur = prefersReducedMotion() ? 0 : Math.max(0, durationMs);
|
|
691
|
+
if (dur === 0) {
|
|
692
|
+
applyViewport(target);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
const start: ViewportState = { ...vpRef.current };
|
|
696
|
+
const t0 =
|
|
697
|
+
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
|
698
|
+
? performance.now()
|
|
699
|
+
: Date.now();
|
|
700
|
+
const tick = (now: number) => {
|
|
701
|
+
const t = Math.min(1, (now - t0) / dur);
|
|
702
|
+
const e = 1 - (1 - t) ** 3; // ease-out cubic
|
|
703
|
+
applyViewport({
|
|
704
|
+
x: start.x + (target.x - start.x) * e,
|
|
705
|
+
y: start.y + (target.y - start.y) * e,
|
|
706
|
+
zoom: clampZoom(start.zoom + (target.zoom - start.zoom) * e),
|
|
707
|
+
});
|
|
708
|
+
if (t < 1) {
|
|
709
|
+
animationRef.current = requestAnimationFrame(tick);
|
|
710
|
+
} else {
|
|
711
|
+
animationRef.current = null;
|
|
712
|
+
}
|
|
713
|
+
};
|
|
714
|
+
animationRef.current = requestAnimationFrame(tick);
|
|
715
|
+
},
|
|
716
|
+
[applyViewport]
|
|
717
|
+
);
|
|
718
|
+
|
|
719
|
+
const jumpTo = useCallback(
|
|
720
|
+
(rect: ArtboardRect) => {
|
|
721
|
+
const host = hostRef.current;
|
|
722
|
+
if (!host) return;
|
|
723
|
+
animateTo(fitRectIntoHost(rect, host));
|
|
724
|
+
},
|
|
725
|
+
[hostRef, animateTo]
|
|
726
|
+
);
|
|
727
|
+
|
|
728
|
+
// Mount / event wiring ------------------------------------------------------
|
|
729
|
+
|
|
730
|
+
// Initial viewport. Intentionally one-shot — caller drives re-fit via the `fit()` handle.
|
|
731
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: one-shot mount; caller controls re-fits.
|
|
732
|
+
useLayoutEffect(() => {
|
|
733
|
+
const initial = getInitial();
|
|
734
|
+
if (initial) {
|
|
735
|
+
vpRef.current = { ...initial };
|
|
736
|
+
writeTransform(vpRef.current);
|
|
737
|
+
setViewportPublished({ ...vpRef.current });
|
|
738
|
+
}
|
|
739
|
+
// If host has no size yet, refit when ResizeObserver delivers one.
|
|
740
|
+
const host = hostRef.current;
|
|
741
|
+
if (!host || typeof ResizeObserver === 'undefined') return;
|
|
742
|
+
let hadSize = host.clientWidth > 0 && host.clientHeight > 0;
|
|
743
|
+
const ro = new ResizeObserver(() => {
|
|
744
|
+
if (interactingRef.current) return; // never re-fit during a gesture
|
|
745
|
+
if (!hadSize && host.clientWidth > 0 && host.clientHeight > 0) {
|
|
746
|
+
hadSize = true;
|
|
747
|
+
const refit = computeFitRef.current();
|
|
748
|
+
vpRef.current = { ...refit };
|
|
749
|
+
writeTransform(vpRef.current);
|
|
750
|
+
setViewportPublished({ ...vpRef.current });
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
ro.observe(host);
|
|
754
|
+
return () => ro.disconnect();
|
|
755
|
+
}, []);
|
|
756
|
+
|
|
757
|
+
// Pointer + wheel + key listeners — all scoped to hostRef so the shell
|
|
758
|
+
// keyboard and other iframes stay quiet.
|
|
759
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: pan/zoom callbacks are useCallback-stable; listeners mount once on host.
|
|
760
|
+
useEffect(() => {
|
|
761
|
+
const host = hostRef.current;
|
|
762
|
+
if (!host) return;
|
|
763
|
+
|
|
764
|
+
// Auto-focus on pointer enter — without this, keyboard shortcuts (Space
|
|
765
|
+
// for pan, Cmd+0/1/+/-) silently fail until the user clicks inside the
|
|
766
|
+
// iframe. Focusing the host element pulls keyboard focus into this
|
|
767
|
+
// iframe's contentWindow so the window-scoped keydown listener below
|
|
768
|
+
// receives events natively.
|
|
769
|
+
const onPointerEnter = () => {
|
|
770
|
+
try {
|
|
771
|
+
if (typeof window !== 'undefined' && document.activeElement !== host) {
|
|
772
|
+
host.focus({ preventScroll: true });
|
|
773
|
+
}
|
|
774
|
+
} catch {
|
|
775
|
+
/* ignore */
|
|
776
|
+
}
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
const spaceHeld = { current: false };
|
|
780
|
+
const panState: {
|
|
781
|
+
active: boolean;
|
|
782
|
+
pointerId: number;
|
|
783
|
+
lastX: number;
|
|
784
|
+
lastY: number;
|
|
785
|
+
} = { active: false, pointerId: -1, lastX: 0, lastY: 0 };
|
|
786
|
+
|
|
787
|
+
const onWheel = (e: WheelEvent) => {
|
|
788
|
+
e.preventDefault();
|
|
789
|
+
const rect = host.getBoundingClientRect();
|
|
790
|
+
const cx = e.clientX - rect.left;
|
|
791
|
+
const cy = e.clientY - rect.top;
|
|
792
|
+
// Mac trackpad pinch fires wheel with ctrlKey:true automatically, even
|
|
793
|
+
// without a physical Ctrl press — so the same branch covers both
|
|
794
|
+
// Ctrl+wheel (mouse) and pinch-zoom (trackpad).
|
|
795
|
+
if (e.ctrlKey || e.metaKey) {
|
|
796
|
+
const factor = Math.exp(-e.deltaY * WHEEL_ZOOM_K);
|
|
797
|
+
zoomAt(factor, cx, cy);
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
// Shift+wheel → horizontal pan. Some browsers / OSes auto-swap
|
|
801
|
+
// deltaX↔deltaY when shift is held (Chromium on Linux does, macOS
|
|
802
|
+
// doesn't, Safari sometimes does); some don't. Read whichever axis
|
|
803
|
+
// actually carries energy so the gesture lands horizontally either way.
|
|
804
|
+
if (e.shiftKey) {
|
|
805
|
+
const d = Math.abs(e.deltaY) >= Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
|
|
806
|
+
panBy(-d, 0);
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
// Default: trackpad two-finger scroll → 2D pan. The negation keeps the
|
|
810
|
+
// "content follows your fingers" mapping (Mac natural scroll). Mouse
|
|
811
|
+
// wheels with only deltaY pan vertically.
|
|
812
|
+
panBy(-e.deltaX, -e.deltaY);
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
const onPointerDown = (e: PointerEvent) => {
|
|
816
|
+
const isMiddle = e.button === 1;
|
|
817
|
+
const isLeftWithSpace = e.button === 0 && spaceHeld.current;
|
|
818
|
+
// Phase 4.1 hand tool: bare left-button initiates pan when the consumer
|
|
819
|
+
// signals hand-mode via `isPanDragActive`. Read per-event so the live
|
|
820
|
+
// tool state controls the gate.
|
|
821
|
+
const isLeftWithHandTool =
|
|
822
|
+
e.button === 0 && !spaceHeld.current && !!isPanDragActiveRef.current?.();
|
|
823
|
+
if (!isMiddle && !isLeftWithSpace && !isLeftWithHandTool) return;
|
|
824
|
+
e.preventDefault();
|
|
825
|
+
try {
|
|
826
|
+
host.setPointerCapture(e.pointerId);
|
|
827
|
+
} catch {
|
|
828
|
+
/* ignore */
|
|
829
|
+
}
|
|
830
|
+
panState.active = true;
|
|
831
|
+
panState.pointerId = e.pointerId;
|
|
832
|
+
panState.lastX = e.clientX;
|
|
833
|
+
panState.lastY = e.clientY;
|
|
834
|
+
host.style.cursor = 'grabbing';
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
const onPointerMove = (e: PointerEvent) => {
|
|
838
|
+
if (!panState.active || e.pointerId !== panState.pointerId) return;
|
|
839
|
+
const dx = e.clientX - panState.lastX;
|
|
840
|
+
const dy = e.clientY - panState.lastY;
|
|
841
|
+
panState.lastX = e.clientX;
|
|
842
|
+
panState.lastY = e.clientY;
|
|
843
|
+
panBy(dx, dy);
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
const endPan = (e: PointerEvent) => {
|
|
847
|
+
if (!panState.active || e.pointerId !== panState.pointerId) return;
|
|
848
|
+
try {
|
|
849
|
+
host.releasePointerCapture(e.pointerId);
|
|
850
|
+
} catch {
|
|
851
|
+
/* ignore */
|
|
852
|
+
}
|
|
853
|
+
panState.active = false;
|
|
854
|
+
panState.pointerId = -1;
|
|
855
|
+
host.style.cursor = spaceHeld.current ? 'grab' : '';
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
859
|
+
// Spacebar pan affordance — only when no input is focused.
|
|
860
|
+
if (e.code === 'Space' && !isEditableTarget(e.target)) {
|
|
861
|
+
spaceHeld.current = true;
|
|
862
|
+
host.style.cursor = panState.active ? 'grabbing' : 'grab';
|
|
863
|
+
e.preventDefault();
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
const mod = e.metaKey || e.ctrlKey;
|
|
867
|
+
if (!mod) return;
|
|
868
|
+
// Cmd+Option+1..9 → jump to artboard N (Option avoids Chrome's
|
|
869
|
+
// Cmd+1..9 tab-switching shortcut).
|
|
870
|
+
if (e.altKey && /^Digit[1-9]$/.test(e.code)) {
|
|
871
|
+
const n = Number(e.code.slice(-1));
|
|
872
|
+
const target = jumpTargetsRef.current?.[n - 1];
|
|
873
|
+
if (target) {
|
|
874
|
+
e.preventDefault();
|
|
875
|
+
jumpTo(target);
|
|
876
|
+
}
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
if (e.altKey) return;
|
|
880
|
+
switch (e.key) {
|
|
881
|
+
case '0':
|
|
882
|
+
e.preventDefault();
|
|
883
|
+
fit();
|
|
884
|
+
return;
|
|
885
|
+
case '1':
|
|
886
|
+
e.preventDefault();
|
|
887
|
+
reset();
|
|
888
|
+
return;
|
|
889
|
+
case '=':
|
|
890
|
+
case '+':
|
|
891
|
+
e.preventDefault();
|
|
892
|
+
zoomIn();
|
|
893
|
+
return;
|
|
894
|
+
case '-':
|
|
895
|
+
e.preventDefault();
|
|
896
|
+
zoomOut();
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
};
|
|
900
|
+
const onKeyUp = (e: KeyboardEvent) => {
|
|
901
|
+
if (e.code === 'Space') {
|
|
902
|
+
spaceHeld.current = false;
|
|
903
|
+
host.style.cursor = panState.active ? 'grabbing' : '';
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
|
|
907
|
+
host.tabIndex = host.tabIndex >= 0 ? host.tabIndex : 0; // focusable for kbd
|
|
908
|
+
|
|
909
|
+
// Wheel listener lives on the document at the CAPTURE phase. Bubble-phase
|
|
910
|
+
// on `host` is too late — an inner scrollable element (e.g. CSS
|
|
911
|
+
// `overflow: auto` somewhere in an artboard's content tree) consumes the
|
|
912
|
+
// wheel first and the bubble never reaches us, which is why
|
|
913
|
+
// shift+wheel-for-horizontal-pan would silently drop on some pages.
|
|
914
|
+
// We still check the event target is inside this canvas before acting,
|
|
915
|
+
// so wheels happening in shell chrome or another iframe pass through.
|
|
916
|
+
const doc = host.ownerDocument || document;
|
|
917
|
+
const captureWheel = (e: WheelEvent) => {
|
|
918
|
+
if (!host.contains(e.target as Node)) return;
|
|
919
|
+
onWheel(e);
|
|
920
|
+
};
|
|
921
|
+
const captureKeyDown = (e: KeyboardEvent) => {
|
|
922
|
+
// Don't intercept keyboard events from input fields anywhere.
|
|
923
|
+
if (isEditableTarget(e.target)) return;
|
|
924
|
+
onKeyDown(e);
|
|
925
|
+
};
|
|
926
|
+
const captureKeyUp = (e: KeyboardEvent) => onKeyUp(e);
|
|
927
|
+
|
|
928
|
+
doc.addEventListener('wheel', captureWheel, { passive: false, capture: true });
|
|
929
|
+
doc.addEventListener('keydown', captureKeyDown, { capture: true });
|
|
930
|
+
doc.addEventListener('keyup', captureKeyUp, { capture: true });
|
|
931
|
+
host.addEventListener('pointerenter', onPointerEnter);
|
|
932
|
+
host.addEventListener('pointerdown', onPointerDown);
|
|
933
|
+
host.addEventListener('pointermove', onPointerMove);
|
|
934
|
+
host.addEventListener('pointerup', endPan);
|
|
935
|
+
host.addEventListener('pointercancel', endPan);
|
|
936
|
+
|
|
937
|
+
return () => {
|
|
938
|
+
doc.removeEventListener('wheel', captureWheel, { capture: true } as EventListenerOptions);
|
|
939
|
+
doc.removeEventListener('keydown', captureKeyDown, { capture: true } as EventListenerOptions);
|
|
940
|
+
doc.removeEventListener('keyup', captureKeyUp, { capture: true } as EventListenerOptions);
|
|
941
|
+
host.removeEventListener('pointerenter', onPointerEnter);
|
|
942
|
+
host.removeEventListener('pointerdown', onPointerDown);
|
|
943
|
+
host.removeEventListener('pointermove', onPointerMove);
|
|
944
|
+
host.removeEventListener('pointerup', endPan);
|
|
945
|
+
host.removeEventListener('pointercancel', endPan);
|
|
946
|
+
};
|
|
947
|
+
}, [hostRef]);
|
|
948
|
+
|
|
949
|
+
// Final settle on unmount — drop pending timers, flush onSettle synchronously
|
|
950
|
+
// so persistence-on-close (T5) still records the last viewport.
|
|
951
|
+
useEffect(() => {
|
|
952
|
+
return () => {
|
|
953
|
+
if (publishTimerRef.current != null) clearTimeout(publishTimerRef.current);
|
|
954
|
+
if (settleTimerRef.current != null) {
|
|
955
|
+
clearTimeout(settleTimerRef.current);
|
|
956
|
+
const cb = onSettleRef.current;
|
|
957
|
+
if (cb) cb({ ...vpRef.current });
|
|
958
|
+
}
|
|
959
|
+
if (interactEndTimerRef.current != null) clearTimeout(interactEndTimerRef.current);
|
|
960
|
+
if (animationRef.current != null) cancelAnimationFrame(animationRef.current);
|
|
961
|
+
};
|
|
962
|
+
}, []);
|
|
963
|
+
|
|
964
|
+
return {
|
|
965
|
+
viewport,
|
|
966
|
+
setViewport,
|
|
967
|
+
panBy,
|
|
968
|
+
zoomAt,
|
|
969
|
+
fit,
|
|
970
|
+
reset,
|
|
971
|
+
zoomIn,
|
|
972
|
+
zoomOut,
|
|
973
|
+
jumpTo,
|
|
974
|
+
animateTo,
|
|
975
|
+
isInteracting,
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Helper — true when the event target is an editable input (so spacebar
|
|
980
|
+
// pan doesn't fight typing inside a canvas-embedded textarea).
|
|
981
|
+
function isEditableTarget(t: EventTarget | null): boolean {
|
|
982
|
+
if (!t || !(t as HTMLElement).tagName) return false;
|
|
983
|
+
const el = t as HTMLElement;
|
|
984
|
+
const tag = el.tagName;
|
|
985
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
|
986
|
+
if (el.isContentEditable) return true;
|
|
987
|
+
return false;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Controller context — published by DesignCanvas so DCMiniMap + DCZoomToolbar
|
|
991
|
+
// can issue pan/zoom operations.
|
|
992
|
+
const ControllerContext = createContext<ViewportControllerHandle | null>(null);
|
|
993
|
+
|
|
994
|
+
export function useViewportControllerContext(): ViewportControllerHandle | null {
|
|
995
|
+
return useContext(ControllerContext);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// Drag-state context (Phase 4.2) — published by DesignCanvas so SnapGuideOverlay
|
|
999
|
+
// (mounted by CanvasShell) can read the active drag's snap guides + so each
|
|
1000
|
+
// DCArtboard can know whether it's being dragged as a follower (multi-select
|
|
1001
|
+
// drag). A single source-of-truth: only one drag can be active at a time, so
|
|
1002
|
+
// the bus holds a single DragState. Each DCArtboard's hook writes here when
|
|
1003
|
+
// non-idle and resets to idle on release.
|
|
1004
|
+
interface DragStateBus {
|
|
1005
|
+
current: DragState;
|
|
1006
|
+
setCurrent: (s: DragState) => void;
|
|
1007
|
+
/** Commit drag positions — DesignCanvas wires this to patchCanvasMeta. */
|
|
1008
|
+
commitPositions: (moved: { id: string; x: number; y: number }[]) => void;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
const DragStateContext = createContext<DragStateBus | null>(null);
|
|
1012
|
+
|
|
1013
|
+
export function useDragStateContext(): DragStateBus | null {
|
|
1014
|
+
return useContext(DragStateContext);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1018
|
+
// Frame envelope
|
|
1019
|
+
|
|
1020
|
+
interface DesignCanvasProps {
|
|
1021
|
+
children: ReactNode;
|
|
1022
|
+
/** Per-overlay opt-out. `false` hides it; omit or `true` shows it. */
|
|
1023
|
+
controls?: { minimap?: boolean; toolbar?: boolean };
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* DesignCanvas mounts the universal canvas input grammar (hover preview,
|
|
1028
|
+
* Cmd-click select, multi-select, tool modes V/H/C, right-click menu) for
|
|
1029
|
+
* every TSX canvas. There's no opt-out — the legacy Cmd-only inspector
|
|
1030
|
+
* overlay path was removed in favor of one consistent affordance everywhere.
|
|
1031
|
+
*
|
|
1032
|
+
* `ToolProvider` lives above `DesignCanvasInner` so the viewport
|
|
1033
|
+
* controller's `isPanDragActive` predicate can read the live tool state
|
|
1034
|
+
* via `useToolModeOptional` (hand-mode bare-drag pan).
|
|
1035
|
+
*/
|
|
1036
|
+
export function DesignCanvas(props: DesignCanvasProps) {
|
|
1037
|
+
return (
|
|
1038
|
+
<ToolProvider>
|
|
1039
|
+
<DesignCanvasInner {...props} />
|
|
1040
|
+
</ToolProvider>
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
DesignCanvas.displayName = 'DesignCanvas';
|
|
1044
|
+
|
|
1045
|
+
function DesignCanvasInner({ children, controls }: DesignCanvasProps) {
|
|
1046
|
+
ensureEngineStyles();
|
|
1047
|
+
|
|
1048
|
+
const hostRef = useRef<HTMLDivElement | null>(null);
|
|
1049
|
+
const worldRef = useRef<HTMLDivElement | null>(null);
|
|
1050
|
+
|
|
1051
|
+
const seeds = useMemo(() => harvestArtboards(children), [children]);
|
|
1052
|
+
|
|
1053
|
+
// Merge JSX-derived defaults with meta-persisted positions. Per DDR-027,
|
|
1054
|
+
// artboard size is JSX-authoritative; meta tolerates legacy w/h fields for
|
|
1055
|
+
// back-compat with Phase 4 snapshots but never lets a missing meta size
|
|
1056
|
+
// zero-out the rendered box.
|
|
1057
|
+
const initialArtboards = useCallback((): ArtboardRect[] => {
|
|
1058
|
+
const meta = readCanvasMeta();
|
|
1059
|
+
const defaults = synthDefaultGrid(seeds);
|
|
1060
|
+
const metaLayout = meta?.layout?.artboards;
|
|
1061
|
+
if (!Array.isArray(metaLayout) || metaLayout.length === 0) return defaults;
|
|
1062
|
+
const byId = new Map<string, ArtboardRect>();
|
|
1063
|
+
for (const r of metaLayout) {
|
|
1064
|
+
if (r && typeof r.id === 'string') byId.set(r.id, r);
|
|
1065
|
+
}
|
|
1066
|
+
return defaults.map((d) => {
|
|
1067
|
+
const m = byId.get(d.id);
|
|
1068
|
+
if (!m) return d;
|
|
1069
|
+
return {
|
|
1070
|
+
id: d.id,
|
|
1071
|
+
x: Number.isFinite(m.x) ? m.x : d.x,
|
|
1072
|
+
y: Number.isFinite(m.y) ? m.y : d.y,
|
|
1073
|
+
w: typeof m.w === 'number' && m.w > 0 ? m.w : d.w,
|
|
1074
|
+
h: typeof m.h === 'number' && m.h > 0 ? m.h : d.h,
|
|
1075
|
+
};
|
|
1076
|
+
});
|
|
1077
|
+
}, [seeds]);
|
|
1078
|
+
|
|
1079
|
+
// Artboards live in state (not a useMemo) so a drag commit can update
|
|
1080
|
+
// positions in-place without waiting for an iframe reload to re-read meta.
|
|
1081
|
+
// Phase 4.2 originally used useMemo([seeds]) — dragging would PATCH the
|
|
1082
|
+
// server but the local React state stayed frozen at mount-time. Users had
|
|
1083
|
+
// to switch canvases (forcing a reload) to see the new position.
|
|
1084
|
+
const [artboards, setArtboards] = useState<ArtboardRect[]>(initialArtboards);
|
|
1085
|
+
|
|
1086
|
+
// Re-seed when JSX children change (HMR after canvas TSX edit). The seed
|
|
1087
|
+
// signature is identity-stable across renders that don't change the JSX,
|
|
1088
|
+
// so this won't clobber drag-commit state during normal interaction.
|
|
1089
|
+
useEffect(() => {
|
|
1090
|
+
setArtboards(initialArtboards());
|
|
1091
|
+
}, [initialArtboards]);
|
|
1092
|
+
|
|
1093
|
+
// Stable refs so the controller's callbacks always see the latest values.
|
|
1094
|
+
const artboardsRef = useRef(artboards);
|
|
1095
|
+
artboardsRef.current = artboards;
|
|
1096
|
+
|
|
1097
|
+
const computeFitForArtboards = useCallback((): ViewportState => {
|
|
1098
|
+
const host = hostRef.current;
|
|
1099
|
+
if (!host) return { x: 0, y: 0, zoom: 1 };
|
|
1100
|
+
return computeFit(artboardsRef.current, host);
|
|
1101
|
+
}, []);
|
|
1102
|
+
|
|
1103
|
+
const getInitial = useCallback((): ViewportState | null => {
|
|
1104
|
+
const meta = readCanvasMeta();
|
|
1105
|
+
const v = meta?.viewport;
|
|
1106
|
+
if (v && Number.isFinite(v.x) && Number.isFinite(v.y) && Number.isFinite(v.zoom)) {
|
|
1107
|
+
return { x: v.x, y: v.y, zoom: v.zoom };
|
|
1108
|
+
}
|
|
1109
|
+
const host = hostRef.current;
|
|
1110
|
+
if (!host) return null;
|
|
1111
|
+
return computeFit(artboardsRef.current, host);
|
|
1112
|
+
}, []);
|
|
1113
|
+
|
|
1114
|
+
const onSettle = useCallback((v: ViewportState) => {
|
|
1115
|
+
patchCanvasMeta({ viewport: v });
|
|
1116
|
+
}, []);
|
|
1117
|
+
|
|
1118
|
+
const toolModeCtx = useToolModeOptional();
|
|
1119
|
+
const toolRef = useRef(toolModeCtx?.tool ?? 'move');
|
|
1120
|
+
toolRef.current = toolModeCtx?.tool ?? 'move';
|
|
1121
|
+
const isPanDragActive = useCallback(() => toolRef.current === 'hand', []);
|
|
1122
|
+
|
|
1123
|
+
const controller = useViewportController({
|
|
1124
|
+
hostRef,
|
|
1125
|
+
worldRef,
|
|
1126
|
+
computeFit: computeFitForArtboards,
|
|
1127
|
+
getInitial,
|
|
1128
|
+
onSettle,
|
|
1129
|
+
jumpTargets: artboards,
|
|
1130
|
+
isPanDragActive,
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
const rectById = useMemo(() => {
|
|
1134
|
+
const m = new Map<string, ArtboardRect>();
|
|
1135
|
+
for (const r of artboards) m.set(r.id, r);
|
|
1136
|
+
return m;
|
|
1137
|
+
}, [artboards]);
|
|
1138
|
+
|
|
1139
|
+
const rectFor = useCallback((id: string) => rectById.get(id) ?? null, [rectById]);
|
|
1140
|
+
|
|
1141
|
+
// Active artboard — the one whose center sits closest to the viewport
|
|
1142
|
+
// center after pan settles. Recomputed on every viewport publish (~50 ms).
|
|
1143
|
+
const activeArtboardId = useMemo<string | null>(() => {
|
|
1144
|
+
if (artboards.length === 0) return null;
|
|
1145
|
+
const host = hostRef.current;
|
|
1146
|
+
if (!host) return artboards[0]?.id ?? null;
|
|
1147
|
+
const vp = controller.viewport;
|
|
1148
|
+
const cx = (host.clientWidth / 2 - vp.x) / vp.zoom;
|
|
1149
|
+
const cy = (host.clientHeight / 2 - vp.y) / vp.zoom;
|
|
1150
|
+
let bestId: string | null = null;
|
|
1151
|
+
let bestDist = Number.POSITIVE_INFINITY;
|
|
1152
|
+
for (const r of artboards) {
|
|
1153
|
+
const ax = r.x + r.w / 2;
|
|
1154
|
+
const ay = r.y + r.h / 2;
|
|
1155
|
+
const d = (ax - cx) ** 2 + (ay - cy) ** 2;
|
|
1156
|
+
if (d < bestDist) {
|
|
1157
|
+
bestDist = d;
|
|
1158
|
+
bestId = r.id;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
return bestId;
|
|
1162
|
+
}, [artboards, controller.viewport]);
|
|
1163
|
+
|
|
1164
|
+
// The world's transform is owned by useViewportController (writes straight
|
|
1165
|
+
// to `worldRef.current.style.transform`). Rendering the transform from
|
|
1166
|
+
// React state instead would race: between React's commit and the
|
|
1167
|
+
// controller's next synchronous write, the world would snap back to a
|
|
1168
|
+
// stale published value. We start hidden and the controller's
|
|
1169
|
+
// useLayoutEffect writes the initial transform before first paint.
|
|
1170
|
+
const worldStyle: CSSProperties = { visibility: 'hidden' };
|
|
1171
|
+
|
|
1172
|
+
const ctxValue = useMemo<WorldContextValue>(
|
|
1173
|
+
() => ({
|
|
1174
|
+
rectFor,
|
|
1175
|
+
artboards,
|
|
1176
|
+
viewport: controller.viewport,
|
|
1177
|
+
activeArtboardId,
|
|
1178
|
+
hostRef,
|
|
1179
|
+
worldRef,
|
|
1180
|
+
}),
|
|
1181
|
+
[rectFor, artboards, controller.viewport, activeArtboardId]
|
|
1182
|
+
);
|
|
1183
|
+
|
|
1184
|
+
const showMiniMap = controls?.minimap !== false;
|
|
1185
|
+
const showToolbar = controls?.toolbar !== false;
|
|
1186
|
+
|
|
1187
|
+
// Drag-state bus (Phase 4.2). Single source of truth: only one artboard
|
|
1188
|
+
// drag is active at a time. DCArtboards write here when their local drag
|
|
1189
|
+
// hook is non-idle; SnapGuideOverlay (in canvas-shell) reads guides.
|
|
1190
|
+
const [dragCurrent, setDragCurrent] = useState<DragState>({ kind: 'idle' });
|
|
1191
|
+
|
|
1192
|
+
const commitArtboardPositions = useCallback((moved: { id: string; x: number; y: number }[]) => {
|
|
1193
|
+
const movedById = new Map(moved.map((m) => [m.id, m]));
|
|
1194
|
+
const next = artboardsRef.current.map((r) => {
|
|
1195
|
+
const m = movedById.get(r.id);
|
|
1196
|
+
if (m) return { ...r, x: m.x, y: m.y };
|
|
1197
|
+
return r;
|
|
1198
|
+
});
|
|
1199
|
+
// Optimistic local update — DOM reflects the new position the moment
|
|
1200
|
+
// the drag drops, no iframe reload required. The PATCH below catches
|
|
1201
|
+
// the server up; if it fails we already logged it via `patchCanvasMeta`.
|
|
1202
|
+
setArtboards(next);
|
|
1203
|
+
patchCanvasMeta({ layout: { artboards: next } });
|
|
1204
|
+
}, []);
|
|
1205
|
+
|
|
1206
|
+
const dragBus = useMemo<DragStateBus>(
|
|
1207
|
+
() => ({
|
|
1208
|
+
current: dragCurrent,
|
|
1209
|
+
setCurrent: setDragCurrent,
|
|
1210
|
+
commitPositions: commitArtboardPositions,
|
|
1211
|
+
}),
|
|
1212
|
+
[dragCurrent, commitArtboardPositions]
|
|
1213
|
+
);
|
|
1214
|
+
|
|
1215
|
+
const inner = (
|
|
1216
|
+
<div className="dc-canvas" ref={hostRef}>
|
|
1217
|
+
<div className="dc-world" ref={worldRef} style={worldStyle}>
|
|
1218
|
+
{children}
|
|
1219
|
+
</div>
|
|
1220
|
+
{showMiniMap ? <DCMiniMap /> : null}
|
|
1221
|
+
{/* DCZoomToolbar is intentionally not rendered here. The Phase 5.1
|
|
1222
|
+
ToolPalette absorbs its 4 actions into the unified canvas chrome.
|
|
1223
|
+
The component stays exported for back-compat with any consumer that
|
|
1224
|
+
still imports it directly. */}
|
|
1225
|
+
</div>
|
|
1226
|
+
);
|
|
1227
|
+
|
|
1228
|
+
return (
|
|
1229
|
+
<WorldContext.Provider value={ctxValue}>
|
|
1230
|
+
<ControllerContext.Provider value={controller}>
|
|
1231
|
+
<DragStateContext.Provider value={dragBus}>
|
|
1232
|
+
<CanvasShell hostRef={hostRef}>{inner}</CanvasShell>
|
|
1233
|
+
</DragStateContext.Provider>
|
|
1234
|
+
</ControllerContext.Provider>
|
|
1235
|
+
</WorldContext.Provider>
|
|
1236
|
+
);
|
|
1237
|
+
}
|
|
1238
|
+
DesignCanvasInner.displayName = 'DesignCanvasInner';
|
|
1239
|
+
|
|
1240
|
+
export function DCSection({
|
|
1241
|
+
id,
|
|
1242
|
+
title,
|
|
1243
|
+
subtitle,
|
|
1244
|
+
children,
|
|
1245
|
+
}: {
|
|
1246
|
+
id: string;
|
|
1247
|
+
title: string;
|
|
1248
|
+
subtitle?: string;
|
|
1249
|
+
children: ReactNode;
|
|
1250
|
+
}) {
|
|
1251
|
+
const ctx = useWorldContext();
|
|
1252
|
+
if (ctx) {
|
|
1253
|
+
// Inside DesignCanvas: DCSection is purely metadata. Its title + subtitle
|
|
1254
|
+
// are stashed as data-* on a `display: contents` wrapper so inspector
|
|
1255
|
+
// selectors still resolve, but the wrapper imposes no layout — DCArtboard
|
|
1256
|
+
// children take their own world-coord positions.
|
|
1257
|
+
return (
|
|
1258
|
+
<div
|
|
1259
|
+
className="dc-section dc-section-collapsed"
|
|
1260
|
+
data-dc-section={id}
|
|
1261
|
+
data-dc-section-title={title}
|
|
1262
|
+
data-dc-section-subtitle={subtitle ?? ''}
|
|
1263
|
+
>
|
|
1264
|
+
{children}
|
|
1265
|
+
</div>
|
|
1266
|
+
);
|
|
1267
|
+
}
|
|
1268
|
+
return (
|
|
1269
|
+
<section className="dc-section" data-dc-section={id}>
|
|
1270
|
+
<header>
|
|
1271
|
+
<h2>{title}</h2>
|
|
1272
|
+
{subtitle ? <p className="sku">{subtitle}</p> : null}
|
|
1273
|
+
</header>
|
|
1274
|
+
<div className="dc-section-body">{children}</div>
|
|
1275
|
+
</section>
|
|
1276
|
+
);
|
|
1277
|
+
}
|
|
1278
|
+
DCSection.displayName = 'DCSection';
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* Bordered artboard with a SKU-strip header. Inside DesignCanvas its world
|
|
1282
|
+
* position comes from meta.layout (or the synthesized default grid); the
|
|
1283
|
+
* given `width` + `height` props are honored only as a fallback when the
|
|
1284
|
+
* layout has no matching id. Outside DesignCanvas (DS specimens, legacy
|
|
1285
|
+
* uses) it renders a plain fixed-size block.
|
|
1286
|
+
*/
|
|
1287
|
+
export function DCArtboard({
|
|
1288
|
+
id,
|
|
1289
|
+
label,
|
|
1290
|
+
width,
|
|
1291
|
+
height,
|
|
1292
|
+
children,
|
|
1293
|
+
}: {
|
|
1294
|
+
id: string;
|
|
1295
|
+
label: string;
|
|
1296
|
+
width: number;
|
|
1297
|
+
height: number;
|
|
1298
|
+
children: ReactNode;
|
|
1299
|
+
}) {
|
|
1300
|
+
const ctx = useWorldContext();
|
|
1301
|
+
const controller = useViewportControllerContext();
|
|
1302
|
+
const toolMode = useToolModeOptional();
|
|
1303
|
+
const selSet = useSelectionSetOptional();
|
|
1304
|
+
const dragBus = useDragStateContext();
|
|
1305
|
+
const rect = ctx ? ctx.rectFor(id) : null;
|
|
1306
|
+
|
|
1307
|
+
// Drag hook — always called (hook rules). Inert outside DesignCanvas
|
|
1308
|
+
// (allRects empty, enabled=false), so specimens / legacy uses get a plain
|
|
1309
|
+
// fixed-size block as before.
|
|
1310
|
+
const dragHook = useArtboardDrag({
|
|
1311
|
+
artboardId: id,
|
|
1312
|
+
selected: selSet?.selected ?? [],
|
|
1313
|
+
rectFor: (rid) => (ctx ? ctx.rectFor(rid) : null),
|
|
1314
|
+
allRects: ctx?.artboards ?? [],
|
|
1315
|
+
viewport: ctx?.viewport ?? null,
|
|
1316
|
+
enabled: !!ctx && (toolMode?.tool ?? 'move') === 'move',
|
|
1317
|
+
onCommit: (moved) => {
|
|
1318
|
+
if (dragBus) dragBus.commitPositions(moved);
|
|
1319
|
+
},
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
// Publish this artboard's drag state to the bus. Only push when non-idle;
|
|
1323
|
+
// when our local state returns to idle AFTER having been non-idle, push
|
|
1324
|
+
// one final idle update so the bus clears.
|
|
1325
|
+
const wasNonIdleRef = useRef(false);
|
|
1326
|
+
useEffect(() => {
|
|
1327
|
+
if (!dragBus) return;
|
|
1328
|
+
const s = dragHook.dragState;
|
|
1329
|
+
if (s.kind !== 'idle') {
|
|
1330
|
+
dragBus.setCurrent(s);
|
|
1331
|
+
wasNonIdleRef.current = true;
|
|
1332
|
+
} else if (wasNonIdleRef.current) {
|
|
1333
|
+
dragBus.setCurrent({ kind: 'idle' });
|
|
1334
|
+
wasNonIdleRef.current = false;
|
|
1335
|
+
}
|
|
1336
|
+
}, [dragHook.dragState, dragBus]);
|
|
1337
|
+
|
|
1338
|
+
if (!ctx || !rect) {
|
|
1339
|
+
return (
|
|
1340
|
+
<article className="dc-artboard" data-dc-screen={id} style={{ width, height }}>
|
|
1341
|
+
<header className="dc-artboard-label sku">{label}</header>
|
|
1342
|
+
<div className="dc-artboard-body">{children}</div>
|
|
1343
|
+
</article>
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
1346
|
+
const isActive = ctx.activeArtboardId === id;
|
|
1347
|
+
const onFocus = () => {
|
|
1348
|
+
if (controller) controller.jumpTo(rect);
|
|
1349
|
+
};
|
|
1350
|
+
|
|
1351
|
+
// Am I involved in the current drag (as leader or follower)?
|
|
1352
|
+
const busDrag = dragBus?.current;
|
|
1353
|
+
const isLeader = busDrag?.kind === 'dragging' && busDrag.leaderId === id;
|
|
1354
|
+
const followerOffset =
|
|
1355
|
+
busDrag?.kind === 'dragging' ? busDrag.followers.find((f) => f.id === id) : undefined;
|
|
1356
|
+
const isFollower = !!followerOffset;
|
|
1357
|
+
const isInDrag = isLeader || isFollower;
|
|
1358
|
+
|
|
1359
|
+
// Ghost position (world coords).
|
|
1360
|
+
let ghostX = 0;
|
|
1361
|
+
let ghostY = 0;
|
|
1362
|
+
if (busDrag?.kind === 'dragging') {
|
|
1363
|
+
if (isLeader) {
|
|
1364
|
+
ghostX = busDrag.leaderRect.x;
|
|
1365
|
+
ghostY = busDrag.leaderRect.y;
|
|
1366
|
+
} else if (isFollower && followerOffset) {
|
|
1367
|
+
ghostX = busDrag.leaderRect.x + followerOffset.offsetX;
|
|
1368
|
+
ghostY = busDrag.leaderRect.y + followerOffset.offsetY;
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const handleProps = dragHook.bindHandle();
|
|
1373
|
+
|
|
1374
|
+
return (
|
|
1375
|
+
<>
|
|
1376
|
+
<article
|
|
1377
|
+
className={`dc-artboard dc-positioned${isInDrag ? ' dc-dragging' : ''}`}
|
|
1378
|
+
data-dc-screen={id}
|
|
1379
|
+
aria-current={isActive ? 'true' : undefined}
|
|
1380
|
+
style={{ left: rect.x, top: rect.y, width: rect.w, height: rect.h }}
|
|
1381
|
+
{...handleProps}
|
|
1382
|
+
>
|
|
1383
|
+
<button
|
|
1384
|
+
type="button"
|
|
1385
|
+
className="dc-artboard-label sku"
|
|
1386
|
+
onClick={onFocus}
|
|
1387
|
+
aria-label={`Focus artboard ${label}`}
|
|
1388
|
+
>
|
|
1389
|
+
{label}
|
|
1390
|
+
</button>
|
|
1391
|
+
<div className="dc-artboard-body">{children}</div>
|
|
1392
|
+
</article>
|
|
1393
|
+
{isInDrag ? (
|
|
1394
|
+
<div
|
|
1395
|
+
className="dc-artboard-ghost"
|
|
1396
|
+
aria-hidden="true"
|
|
1397
|
+
style={{ left: ghostX, top: ghostY, width: rect.w, height: rect.h }}
|
|
1398
|
+
>
|
|
1399
|
+
<div className="dc-artboard-ghost-label">{label}</div>
|
|
1400
|
+
</div>
|
|
1401
|
+
) : null}
|
|
1402
|
+
</>
|
|
1403
|
+
);
|
|
1404
|
+
}
|
|
1405
|
+
DCArtboard.displayName = 'DCArtboard';
|
|
1406
|
+
|
|
1407
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1408
|
+
// SnapGuideOverlay (Phase 4.2) — renders 1 px guide lines while a drag is in
|
|
1409
|
+
// flight. Mounted by canvas-shell as a chrome layer outside `.dc-world`, so
|
|
1410
|
+
// the lines are in screen coords (no CSS-zoom subpixel weirdness). Guides
|
|
1411
|
+
// come from `dragBus.current.snap.guides`; world→screen projection uses the
|
|
1412
|
+
// live viewport (`v.x + worldCoord * v.zoom` — same convention as `writeTransform`).
|
|
1413
|
+
|
|
1414
|
+
export function SnapGuideOverlay() {
|
|
1415
|
+
const dragBus = useDragStateContext();
|
|
1416
|
+
const world = useWorldContext();
|
|
1417
|
+
if (!dragBus || !world) return null;
|
|
1418
|
+
const s = dragBus.current;
|
|
1419
|
+
if (s.kind !== 'dragging') return null;
|
|
1420
|
+
const vp = world.viewport;
|
|
1421
|
+
if (!vp) return null;
|
|
1422
|
+
return (
|
|
1423
|
+
<>
|
|
1424
|
+
{s.snap.guides.map((g, i) => {
|
|
1425
|
+
if (g.axis === 'x') {
|
|
1426
|
+
const sx = vp.x + g.pos * vp.zoom;
|
|
1427
|
+
const sFrom = vp.y + g.from * vp.zoom;
|
|
1428
|
+
const sTo = vp.y + g.to * vp.zoom;
|
|
1429
|
+
return (
|
|
1430
|
+
<div
|
|
1431
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: guides are positional
|
|
1432
|
+
key={`x-${i}`}
|
|
1433
|
+
className="dc-snap-guide"
|
|
1434
|
+
style={{
|
|
1435
|
+
position: 'fixed',
|
|
1436
|
+
pointerEvents: 'none',
|
|
1437
|
+
background: 'var(--accent, #d63b1f)',
|
|
1438
|
+
left: sx,
|
|
1439
|
+
top: sFrom,
|
|
1440
|
+
width: 1,
|
|
1441
|
+
height: Math.max(1, sTo - sFrom),
|
|
1442
|
+
zIndex: 6,
|
|
1443
|
+
}}
|
|
1444
|
+
aria-hidden="true"
|
|
1445
|
+
/>
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
const sy = vp.y + g.pos * vp.zoom;
|
|
1449
|
+
const sFrom = vp.x + g.from * vp.zoom;
|
|
1450
|
+
const sTo = vp.x + g.to * vp.zoom;
|
|
1451
|
+
return (
|
|
1452
|
+
<div
|
|
1453
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: guides are positional
|
|
1454
|
+
key={`y-${i}`}
|
|
1455
|
+
className="dc-snap-guide"
|
|
1456
|
+
style={{
|
|
1457
|
+
position: 'fixed',
|
|
1458
|
+
pointerEvents: 'none',
|
|
1459
|
+
background: 'var(--accent, #d63b1f)',
|
|
1460
|
+
left: sFrom,
|
|
1461
|
+
top: sy,
|
|
1462
|
+
width: Math.max(1, sTo - sFrom),
|
|
1463
|
+
height: 1,
|
|
1464
|
+
zIndex: 6,
|
|
1465
|
+
}}
|
|
1466
|
+
aria-hidden="true"
|
|
1467
|
+
/>
|
|
1468
|
+
);
|
|
1469
|
+
})}
|
|
1470
|
+
</>
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1473
|
+
SnapGuideOverlay.displayName = 'SnapGuideOverlay';
|
|
1474
|
+
|
|
1475
|
+
export function DCPostIt({ children }: { children: ReactNode }) {
|
|
1476
|
+
return <aside className="dc-postit">{children}</aside>;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1480
|
+
// Floating overlays (Phase 4 T3) — outside `.dc-world`, so they stay fixed
|
|
1481
|
+
// to the canvas iframe chrome while the world pans/zooms underneath. Mounted
|
|
1482
|
+
// by DesignCanvas; consumers opt out per-overlay via `<DesignCanvas controls>`.
|
|
1483
|
+
// Styling lives inline so the engine drops into ANY DS without requiring
|
|
1484
|
+
// `.dc-mm` / `.dc-zoom-tb` rules in `_components.css`. CV-01 references the
|
|
1485
|
+
// same vocabulary; if a DS wants to restyle, it can target `.dc-mm` /
|
|
1486
|
+
// `.dc-zoom-tb` directly.
|
|
1487
|
+
|
|
1488
|
+
const OVERLAY_CSS = `
|
|
1489
|
+
.dc-mm {
|
|
1490
|
+
position: absolute;
|
|
1491
|
+
right: 16px;
|
|
1492
|
+
bottom: 16px;
|
|
1493
|
+
width: 196px;
|
|
1494
|
+
height: 132px;
|
|
1495
|
+
background: var(--bg-1, rgba(255,255,255,0.98));
|
|
1496
|
+
border: 1px solid var(--u-border-2, rgba(0,0,0,0.08));
|
|
1497
|
+
border-radius: 8px;
|
|
1498
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
1499
|
+
font-size: 10px;
|
|
1500
|
+
color: rgba(40,30,20,0.7);
|
|
1501
|
+
z-index: 6;
|
|
1502
|
+
user-select: none;
|
|
1503
|
+
box-shadow: 0 6px 24px rgba(0,0,0,0.08);
|
|
1504
|
+
overflow: hidden;
|
|
1505
|
+
}
|
|
1506
|
+
.dc-mm-hd {
|
|
1507
|
+
padding: 5px 8px 4px;
|
|
1508
|
+
border-bottom: 1px solid rgba(0,0,0,0.08);
|
|
1509
|
+
letter-spacing: 0.05em;
|
|
1510
|
+
text-transform: uppercase;
|
|
1511
|
+
font-size: 9px;
|
|
1512
|
+
}
|
|
1513
|
+
.dc-mm-body {
|
|
1514
|
+
position: relative;
|
|
1515
|
+
width: 100%;
|
|
1516
|
+
height: calc(100% - 22px);
|
|
1517
|
+
overflow: hidden;
|
|
1518
|
+
cursor: pointer;
|
|
1519
|
+
}
|
|
1520
|
+
.dc-mm-rect {
|
|
1521
|
+
position: absolute;
|
|
1522
|
+
background: rgba(0,0,0,0.06);
|
|
1523
|
+
border: 1px solid rgba(0,0,0,0.18);
|
|
1524
|
+
}
|
|
1525
|
+
.dc-mm-vp {
|
|
1526
|
+
position: absolute;
|
|
1527
|
+
border: 2px solid #d63b1f;
|
|
1528
|
+
pointer-events: none;
|
|
1529
|
+
}
|
|
1530
|
+
.dc-zoom-tb {
|
|
1531
|
+
position: absolute;
|
|
1532
|
+
left: 50%;
|
|
1533
|
+
bottom: 16px;
|
|
1534
|
+
transform: translateX(-50%);
|
|
1535
|
+
display: flex;
|
|
1536
|
+
align-items: stretch;
|
|
1537
|
+
background: rgba(255,255,255,0.94);
|
|
1538
|
+
border: 1px solid rgba(0,0,0,0.12);
|
|
1539
|
+
border-radius: 6px;
|
|
1540
|
+
overflow: hidden;
|
|
1541
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
1542
|
+
font-size: 11px;
|
|
1543
|
+
color: rgba(40,30,20,0.85);
|
|
1544
|
+
z-index: 6;
|
|
1545
|
+
box-shadow: 0 4px 16px rgba(0,0,0,0.06);
|
|
1546
|
+
}
|
|
1547
|
+
.dc-zoom-tb button {
|
|
1548
|
+
appearance: none;
|
|
1549
|
+
background: transparent;
|
|
1550
|
+
border: 0;
|
|
1551
|
+
border-right: 1px solid rgba(0,0,0,0.08);
|
|
1552
|
+
padding: 7px 12px;
|
|
1553
|
+
font: inherit;
|
|
1554
|
+
color: inherit;
|
|
1555
|
+
cursor: pointer;
|
|
1556
|
+
min-width: 36px;
|
|
1557
|
+
text-align: center;
|
|
1558
|
+
}
|
|
1559
|
+
.dc-zoom-tb button:last-child { border-right: 0; }
|
|
1560
|
+
.dc-zoom-tb button:hover { background: rgba(0,0,0,0.04); }
|
|
1561
|
+
.dc-zoom-tb button:focus-visible { outline: 2px solid #d63b1f; outline-offset: -2px; }
|
|
1562
|
+
.dc-zoom-tb-pct { font-variant-numeric: tabular-nums; min-width: 52px; }
|
|
1563
|
+
`.trim();
|
|
1564
|
+
|
|
1565
|
+
function ensureOverlayStyles(): void {
|
|
1566
|
+
if (typeof document === 'undefined') return;
|
|
1567
|
+
if (document.getElementById('dc-overlay-css')) return;
|
|
1568
|
+
const s = document.createElement('style');
|
|
1569
|
+
s.id = 'dc-overlay-css';
|
|
1570
|
+
s.textContent = OVERLAY_CSS;
|
|
1571
|
+
document.head.appendChild(s);
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
interface MiniMapGeometry {
|
|
1575
|
+
scale: number;
|
|
1576
|
+
offsetX: number;
|
|
1577
|
+
offsetY: number;
|
|
1578
|
+
bbox: { x: number; y: number; w: number; h: number };
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
function computeMiniMapGeometry(
|
|
1582
|
+
artboards: ArtboardRect[],
|
|
1583
|
+
mapW: number,
|
|
1584
|
+
mapH: number,
|
|
1585
|
+
pad = 6
|
|
1586
|
+
): MiniMapGeometry {
|
|
1587
|
+
if (artboards.length === 0) {
|
|
1588
|
+
return { scale: 1, offsetX: 0, offsetY: 0, bbox: { x: 0, y: 0, w: 0, h: 0 } };
|
|
1589
|
+
}
|
|
1590
|
+
let xMin = Number.POSITIVE_INFINITY;
|
|
1591
|
+
let yMin = Number.POSITIVE_INFINITY;
|
|
1592
|
+
let xMax = Number.NEGATIVE_INFINITY;
|
|
1593
|
+
let yMax = Number.NEGATIVE_INFINITY;
|
|
1594
|
+
for (const r of artboards) {
|
|
1595
|
+
if (r.x < xMin) xMin = r.x;
|
|
1596
|
+
if (r.y < yMin) yMin = r.y;
|
|
1597
|
+
if (r.x + r.w > xMax) xMax = r.x + r.w;
|
|
1598
|
+
if (r.y + r.h > yMax) yMax = r.y + r.h;
|
|
1599
|
+
}
|
|
1600
|
+
const bw = Math.max(1, xMax - xMin);
|
|
1601
|
+
const bh = Math.max(1, yMax - yMin);
|
|
1602
|
+
const scale = Math.min((mapW - pad * 2) / bw, (mapH - pad * 2) / bh);
|
|
1603
|
+
const offsetX = pad + (mapW - pad * 2 - bw * scale) / 2 - xMin * scale;
|
|
1604
|
+
const offsetY = pad + (mapH - pad * 2 - bh * scale) / 2 - yMin * scale;
|
|
1605
|
+
return { scale, offsetX, offsetY, bbox: { x: xMin, y: yMin, w: bw, h: bh } };
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
/**
|
|
1609
|
+
* Bottom-right floating world map. Renders every DCArtboard rect scaled-to-fit
|
|
1610
|
+
* plus a red viewport indicator. Click-drag inside the map pans the main view;
|
|
1611
|
+
* click outside the viewport rect recenters on that point. Decorative for
|
|
1612
|
+
* accessibility — SR users navigate via DCArtboard label buttons (T4).
|
|
1613
|
+
*/
|
|
1614
|
+
export function DCMiniMap() {
|
|
1615
|
+
ensureOverlayStyles();
|
|
1616
|
+
const world = useWorldContext();
|
|
1617
|
+
const controller = useViewportControllerContext();
|
|
1618
|
+
const bodyRef = useRef<HTMLDivElement | null>(null);
|
|
1619
|
+
// 132 - 22 (header) = 110 body height; width matches the chrome.
|
|
1620
|
+
const MAP_W = 196;
|
|
1621
|
+
const MAP_BODY_H = 110;
|
|
1622
|
+
const dragRef = useRef<{ active: boolean; pointerId: number }>({
|
|
1623
|
+
active: false,
|
|
1624
|
+
pointerId: -1,
|
|
1625
|
+
});
|
|
1626
|
+
|
|
1627
|
+
if (!world || !controller) return null;
|
|
1628
|
+
|
|
1629
|
+
const geometry = computeMiniMapGeometry(world.artboards, MAP_W, MAP_BODY_H);
|
|
1630
|
+
const host = world.hostRef.current;
|
|
1631
|
+
const vp = controller.viewport;
|
|
1632
|
+
|
|
1633
|
+
// Visible-area rect in world coords, then projected into map coords.
|
|
1634
|
+
let vpRect: { left: number; top: number; w: number; h: number } | null = null;
|
|
1635
|
+
if (host && Number.isFinite(vp.zoom) && vp.zoom > 0) {
|
|
1636
|
+
const wLeft = -vp.x / vp.zoom;
|
|
1637
|
+
const wTop = -vp.y / vp.zoom;
|
|
1638
|
+
const wW = host.clientWidth / vp.zoom;
|
|
1639
|
+
const wH = host.clientHeight / vp.zoom;
|
|
1640
|
+
vpRect = {
|
|
1641
|
+
left: wLeft * geometry.scale + geometry.offsetX,
|
|
1642
|
+
top: wTop * geometry.scale + geometry.offsetY,
|
|
1643
|
+
w: wW * geometry.scale,
|
|
1644
|
+
h: wH * geometry.scale,
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
function mapToWorld(mx: number, my: number): { x: number; y: number } {
|
|
1649
|
+
return {
|
|
1650
|
+
x: (mx - geometry.offsetX) / geometry.scale,
|
|
1651
|
+
y: (my - geometry.offsetY) / geometry.scale,
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
function centerOnWorld(wx: number, wy: number) {
|
|
1656
|
+
const h = world?.hostRef.current;
|
|
1657
|
+
const c = controller;
|
|
1658
|
+
if (!h || !c) return;
|
|
1659
|
+
const cur = c.viewport;
|
|
1660
|
+
c.setViewport({
|
|
1661
|
+
x: h.clientWidth / 2 - wx * cur.zoom,
|
|
1662
|
+
y: h.clientHeight / 2 - wy * cur.zoom,
|
|
1663
|
+
zoom: cur.zoom,
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
1668
|
+
const body = bodyRef.current;
|
|
1669
|
+
if (!body) return;
|
|
1670
|
+
const r = body.getBoundingClientRect();
|
|
1671
|
+
const mx = e.clientX - r.left;
|
|
1672
|
+
const my = e.clientY - r.top;
|
|
1673
|
+
const w = mapToWorld(mx, my);
|
|
1674
|
+
centerOnWorld(w.x, w.y);
|
|
1675
|
+
try {
|
|
1676
|
+
body.setPointerCapture(e.pointerId);
|
|
1677
|
+
} catch {
|
|
1678
|
+
/* ignore */
|
|
1679
|
+
}
|
|
1680
|
+
dragRef.current.active = true;
|
|
1681
|
+
dragRef.current.pointerId = e.pointerId;
|
|
1682
|
+
};
|
|
1683
|
+
const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
1684
|
+
if (!dragRef.current.active || e.pointerId !== dragRef.current.pointerId) return;
|
|
1685
|
+
const body = bodyRef.current;
|
|
1686
|
+
if (!body) return;
|
|
1687
|
+
const r = body.getBoundingClientRect();
|
|
1688
|
+
const w = mapToWorld(e.clientX - r.left, e.clientY - r.top);
|
|
1689
|
+
centerOnWorld(w.x, w.y);
|
|
1690
|
+
};
|
|
1691
|
+
const endDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
1692
|
+
if (!dragRef.current.active) return;
|
|
1693
|
+
dragRef.current.active = false;
|
|
1694
|
+
try {
|
|
1695
|
+
bodyRef.current?.releasePointerCapture(e.pointerId);
|
|
1696
|
+
} catch {
|
|
1697
|
+
/* ignore */
|
|
1698
|
+
}
|
|
1699
|
+
};
|
|
1700
|
+
|
|
1701
|
+
return (
|
|
1702
|
+
<div className="dc-mm" aria-hidden="true">
|
|
1703
|
+
<div className="dc-mm-hd">
|
|
1704
|
+
WORLD MAP · {world.artboards.length}/{world.artboards.length}
|
|
1705
|
+
</div>
|
|
1706
|
+
<div
|
|
1707
|
+
className="dc-mm-body"
|
|
1708
|
+
ref={bodyRef}
|
|
1709
|
+
onPointerDown={onPointerDown}
|
|
1710
|
+
onPointerMove={onPointerMove}
|
|
1711
|
+
onPointerUp={endDrag}
|
|
1712
|
+
onPointerCancel={endDrag}
|
|
1713
|
+
>
|
|
1714
|
+
{world.artboards.map((r) => (
|
|
1715
|
+
<div
|
|
1716
|
+
key={r.id}
|
|
1717
|
+
className="dc-mm-rect"
|
|
1718
|
+
style={{
|
|
1719
|
+
left: r.x * geometry.scale + geometry.offsetX,
|
|
1720
|
+
top: r.y * geometry.scale + geometry.offsetY,
|
|
1721
|
+
width: r.w * geometry.scale,
|
|
1722
|
+
height: r.h * geometry.scale,
|
|
1723
|
+
}}
|
|
1724
|
+
/>
|
|
1725
|
+
))}
|
|
1726
|
+
{vpRect ? (
|
|
1727
|
+
<div
|
|
1728
|
+
className="dc-mm-vp"
|
|
1729
|
+
style={{ left: vpRect.left, top: vpRect.top, width: vpRect.w, height: vpRect.h }}
|
|
1730
|
+
/>
|
|
1731
|
+
) : null}
|
|
1732
|
+
</div>
|
|
1733
|
+
</div>
|
|
1734
|
+
);
|
|
1735
|
+
}
|
|
1736
|
+
DCMiniMap.displayName = 'DCMiniMap';
|
|
1737
|
+
|
|
1738
|
+
/**
|
|
1739
|
+
* Bottom-center floating toolbar — zoom out · current % · zoom in · fit · 1:1.
|
|
1740
|
+
* Clicking the % indicator resets to 100 %.
|
|
1741
|
+
*/
|
|
1742
|
+
export function DCZoomToolbar() {
|
|
1743
|
+
ensureOverlayStyles();
|
|
1744
|
+
const controller = useViewportControllerContext();
|
|
1745
|
+
if (!controller) return null;
|
|
1746
|
+
const pct = Math.round(controller.viewport.zoom * 100);
|
|
1747
|
+
return (
|
|
1748
|
+
<div className="dc-zoom-tb" role="toolbar" aria-label="Zoom">
|
|
1749
|
+
<button type="button" onClick={controller.zoomOut} aria-label="Zoom out">
|
|
1750
|
+
−
|
|
1751
|
+
</button>
|
|
1752
|
+
<button
|
|
1753
|
+
type="button"
|
|
1754
|
+
className="dc-zoom-tb-pct"
|
|
1755
|
+
onClick={controller.reset}
|
|
1756
|
+
aria-label={`Zoom ${pct}%, click to reset to 100%`}
|
|
1757
|
+
>
|
|
1758
|
+
{pct}%
|
|
1759
|
+
</button>
|
|
1760
|
+
<button type="button" onClick={controller.zoomIn} aria-label="Zoom in">
|
|
1761
|
+
+
|
|
1762
|
+
</button>
|
|
1763
|
+
<button type="button" onClick={controller.fit} aria-label="Fit to screen">
|
|
1764
|
+
[ ]
|
|
1765
|
+
</button>
|
|
1766
|
+
<button type="button" onClick={controller.reset} aria-label="Actual size">
|
|
1767
|
+
1:1
|
|
1768
|
+
</button>
|
|
1769
|
+
</div>
|
|
1770
|
+
);
|
|
1771
|
+
}
|
|
1772
|
+
DCZoomToolbar.displayName = 'DCZoomToolbar';
|
|
1773
|
+
|
|
1774
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1775
|
+
// Specimen helpers
|
|
1776
|
+
|
|
1777
|
+
/** SKU + breadcrumb trail + optional ThemeToggle. Maps to `.specimen-hd`. */
|
|
1778
|
+
export function SpecimenHeader({
|
|
1779
|
+
sku,
|
|
1780
|
+
crumbs,
|
|
1781
|
+
showThemeToggle = true,
|
|
1782
|
+
}: {
|
|
1783
|
+
sku: string;
|
|
1784
|
+
crumbs: string[];
|
|
1785
|
+
showThemeToggle?: boolean;
|
|
1786
|
+
}) {
|
|
1787
|
+
return (
|
|
1788
|
+
<header className="specimen-hd">
|
|
1789
|
+
<span className="sku">{sku}</span>
|
|
1790
|
+
<span className="crumbs">
|
|
1791
|
+
{crumbs.map((c, i) => (
|
|
1792
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: crumbs may repeat; index disambiguates static breadcrumb labels.
|
|
1793
|
+
<span key={`${c}-${i}`}>{c}</span>
|
|
1794
|
+
))}
|
|
1795
|
+
</span>
|
|
1796
|
+
{showThemeToggle ? <ThemeToggle /> : null}
|
|
1797
|
+
</header>
|
|
1798
|
+
);
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
/** `<dl class="specimen-meta">` ladder. */
|
|
1802
|
+
export function SpecimenMeta({
|
|
1803
|
+
entries,
|
|
1804
|
+
}: {
|
|
1805
|
+
entries: Array<{ label: string; value: ReactNode }>;
|
|
1806
|
+
}) {
|
|
1807
|
+
return (
|
|
1808
|
+
<dl className="specimen-meta">
|
|
1809
|
+
{entries.map(({ label, value }) => (
|
|
1810
|
+
<div key={label}>
|
|
1811
|
+
<dt>{label}</dt>
|
|
1812
|
+
<dd>{value}</dd>
|
|
1813
|
+
</div>
|
|
1814
|
+
))}
|
|
1815
|
+
</dl>
|
|
1816
|
+
);
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
/** <kbd> chrome — keyboard hint. */
|
|
1820
|
+
export function KbdHint({ children }: { children: ReactNode }) {
|
|
1821
|
+
return <kbd>{children}</kbd>;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
/** Inline `var(--name)` value visualiser — small chip + token name. */
|
|
1825
|
+
export function TokenChip({
|
|
1826
|
+
name,
|
|
1827
|
+
swatch,
|
|
1828
|
+
}: {
|
|
1829
|
+
name: string;
|
|
1830
|
+
swatch?: boolean;
|
|
1831
|
+
}) {
|
|
1832
|
+
return (
|
|
1833
|
+
<span className="token-chip" data-token={name}>
|
|
1834
|
+
{swatch ? (
|
|
1835
|
+
<span className="token-chip-swatch" style={{ background: `var(${name})` }} />
|
|
1836
|
+
) : null}
|
|
1837
|
+
<code>{name}</code>
|
|
1838
|
+
</span>
|
|
1839
|
+
);
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
/** Color swatch — square + token label + optional caption. */
|
|
1843
|
+
export function ColorSwatch({
|
|
1844
|
+
token,
|
|
1845
|
+
caption,
|
|
1846
|
+
height = 96,
|
|
1847
|
+
}: {
|
|
1848
|
+
token: string;
|
|
1849
|
+
caption?: ReactNode;
|
|
1850
|
+
height?: number;
|
|
1851
|
+
}) {
|
|
1852
|
+
return (
|
|
1853
|
+
<div className="swatch">
|
|
1854
|
+
<div className="chip" style={{ background: `var(${token})`, height }} />
|
|
1855
|
+
<div className="meta">
|
|
1856
|
+
<strong>{token}</strong>
|
|
1857
|
+
{caption ? <span className="oklch">{caption}</span> : null}
|
|
1858
|
+
</div>
|
|
1859
|
+
</div>
|
|
1860
|
+
);
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
/** Single row of a type-ladder specimen — label + sample at given token. */
|
|
1864
|
+
export function TypeScaleRow({
|
|
1865
|
+
token,
|
|
1866
|
+
label,
|
|
1867
|
+
sample,
|
|
1868
|
+
}: {
|
|
1869
|
+
token: string;
|
|
1870
|
+
label: string;
|
|
1871
|
+
sample?: string;
|
|
1872
|
+
}) {
|
|
1873
|
+
return (
|
|
1874
|
+
<div className="type-row" data-token={token}>
|
|
1875
|
+
<span className="sku">{label}</span>
|
|
1876
|
+
<span className="type-sample" style={{ fontSize: `var(${token})` }}>
|
|
1877
|
+
{sample ?? 'The quick brown fox jumps over the lazy dog'}
|
|
1878
|
+
</span>
|
|
1879
|
+
</div>
|
|
1880
|
+
);
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
/** Light/dark toggle. Writes `data-theme` on `<html>` and persists to memory. */
|
|
1884
|
+
export function ThemeToggle() {
|
|
1885
|
+
const { theme, setTheme } = useTheme();
|
|
1886
|
+
return (
|
|
1887
|
+
<span className="theme-toggle" role="tablist" aria-label="Theme">
|
|
1888
|
+
<button
|
|
1889
|
+
type="button"
|
|
1890
|
+
data-theme="light"
|
|
1891
|
+
aria-pressed={theme === 'light'}
|
|
1892
|
+
onClick={() => setTheme('light')}
|
|
1893
|
+
>
|
|
1894
|
+
LIGHT
|
|
1895
|
+
</button>
|
|
1896
|
+
<button
|
|
1897
|
+
type="button"
|
|
1898
|
+
data-theme="dark"
|
|
1899
|
+
aria-pressed={theme === 'dark'}
|
|
1900
|
+
onClick={() => setTheme('dark')}
|
|
1901
|
+
>
|
|
1902
|
+
DARK
|
|
1903
|
+
</button>
|
|
1904
|
+
</span>
|
|
1905
|
+
);
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1909
|
+
// Hooks
|
|
1910
|
+
|
|
1911
|
+
/**
|
|
1912
|
+
* Read resolved CSS custom property values from `<html>`. Returns the full set
|
|
1913
|
+
* when prefix is omitted; otherwise filters to vars beginning with `--<prefix>`.
|
|
1914
|
+
* Re-resolves on `data-theme` mutation.
|
|
1915
|
+
*/
|
|
1916
|
+
export function useTokens(prefix?: string): Record<string, string> {
|
|
1917
|
+
const [tokens, setTokens] = useState<Record<string, string>>({});
|
|
1918
|
+
useEffect(() => {
|
|
1919
|
+
if (typeof window === 'undefined') return;
|
|
1920
|
+
function read() {
|
|
1921
|
+
const root = document.documentElement;
|
|
1922
|
+
const cs = getComputedStyle(root);
|
|
1923
|
+
const out: Record<string, string> = {};
|
|
1924
|
+
const len = cs.length;
|
|
1925
|
+
for (let i = 0; i < len; i++) {
|
|
1926
|
+
const name = cs.item(i);
|
|
1927
|
+
if (!name.startsWith('--')) continue;
|
|
1928
|
+
if (prefix && !name.startsWith(`--${prefix}`)) continue;
|
|
1929
|
+
out[name] = cs.getPropertyValue(name).trim();
|
|
1930
|
+
}
|
|
1931
|
+
setTokens(out);
|
|
1932
|
+
}
|
|
1933
|
+
read();
|
|
1934
|
+
const mo = new MutationObserver(read);
|
|
1935
|
+
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
|
1936
|
+
return () => mo.disconnect();
|
|
1937
|
+
}, [prefix]);
|
|
1938
|
+
return tokens;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
/**
|
|
1942
|
+
* Current theme + setter. Mirrors the `data-theme` attribute on `<html>`.
|
|
1943
|
+
* Defaults to whatever attribute is already set (or "light"). No persistence
|
|
1944
|
+
* to localStorage — canvases are ephemeral; specimens reset per-load.
|
|
1945
|
+
*/
|
|
1946
|
+
export function useTheme(): { theme: string; setTheme: (t: string) => void } {
|
|
1947
|
+
const [theme, setThemeState] = useState<string>(() => {
|
|
1948
|
+
if (typeof document === 'undefined') return 'light';
|
|
1949
|
+
return document.documentElement.dataset.theme ?? 'light';
|
|
1950
|
+
});
|
|
1951
|
+
const setTheme = useCallback((t: string) => {
|
|
1952
|
+
if (typeof document !== 'undefined') {
|
|
1953
|
+
document.documentElement.dataset.theme = t;
|
|
1954
|
+
}
|
|
1955
|
+
setThemeState(t);
|
|
1956
|
+
}, []);
|
|
1957
|
+
useLayoutEffect(() => {
|
|
1958
|
+
if (typeof document === 'undefined') return;
|
|
1959
|
+
const obs = new MutationObserver(() => {
|
|
1960
|
+
const t = document.documentElement.dataset.theme ?? 'light';
|
|
1961
|
+
setThemeState(t);
|
|
1962
|
+
});
|
|
1963
|
+
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
|
1964
|
+
return () => obs.disconnect();
|
|
1965
|
+
}, []);
|
|
1966
|
+
return useMemo(() => ({ theme, setTheme }), [theme, setTheme]);
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
/**
|
|
1970
|
+
* ResizeObserver wrapper. Pass a ref to any element (typically the active
|
|
1971
|
+
* artboard); returns its current `{ width, height }` in CSS pixels.
|
|
1972
|
+
*/
|
|
1973
|
+
export function useArtboardBounds(ref: RefObject<HTMLElement | null>): {
|
|
1974
|
+
width: number;
|
|
1975
|
+
height: number;
|
|
1976
|
+
} {
|
|
1977
|
+
const [bounds, setBounds] = useState({ width: 0, height: 0 });
|
|
1978
|
+
useEffect(() => {
|
|
1979
|
+
const el = ref.current;
|
|
1980
|
+
if (!el || typeof ResizeObserver === 'undefined') return;
|
|
1981
|
+
const ro = new ResizeObserver((entries) => {
|
|
1982
|
+
const e = entries[0];
|
|
1983
|
+
if (!e) return;
|
|
1984
|
+
const r = e.contentRect;
|
|
1985
|
+
setBounds({ width: r.width, height: r.height });
|
|
1986
|
+
});
|
|
1987
|
+
ro.observe(el);
|
|
1988
|
+
return () => ro.disconnect();
|
|
1989
|
+
}, [ref]);
|
|
1990
|
+
return bounds;
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
// Re-export `useRef` so `useArtboardBounds` consumers can keep a single
|
|
1994
|
+
// import line from `@maude/canvas-lib`.
|
|
1995
|
+
export { useRef };
|