@1agh/maude 0.60.5 → 0.60.6
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/apps/studio/annotations-layer.tsx +21 -12
- package/apps/studio/canvas-comment-mount.tsx +7 -1
- package/apps/studio/canvas-icons.tsx +30 -2
- package/apps/studio/canvas-lib.tsx +25 -3
- package/apps/studio/canvas-shell.tsx +5 -4
- package/apps/studio/client/app.jsx +20 -13
- package/apps/studio/dist/client.bundle.js +1 -1
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/hmr-broadcast.ts +40 -2
- package/apps/studio/test/canvas-wheel-delta.test.ts +42 -0
- package/apps/studio/test/comment-mount.test.ts +10 -1
- package/apps/studio/test/hmr-broadcast.test.ts +6 -4
- package/apps/studio/test/hmr-classify.test.ts +18 -1
- package/apps/studio/test/mode-toggle.test.tsx +226 -0
- package/apps/studio/test/use-tool-mode.test.tsx +33 -3
- package/apps/studio/tool-palette.tsx +86 -24
- package/apps/studio/use-tool-mode.tsx +113 -14
- package/apps/studio/whats-new.json +27 -0
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +31 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// mode-toggle — DDR-223 (issue #93). The preview/edit mode layer over the
|
|
2
|
+
// DDR-187 browse/move tool split: boot posture, the mode⇄tool invariant
|
|
3
|
+
// (arming a resting tool moves the mode; annotation tools are mode-neutral),
|
|
4
|
+
// resetTool, read-only preview boot, and the palette's segmented toggle.
|
|
5
|
+
//
|
|
6
|
+
// Needs a live DOM (context callbacks fired via act) — register happy-dom +
|
|
7
|
+
// drive a createRoot, same convention as browse-posture.test.tsx.
|
|
8
|
+
|
|
9
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
|
|
10
|
+
import { GlobalRegistrator } from '@happy-dom/global-registrator';
|
|
11
|
+
|
|
12
|
+
beforeAll(() => {
|
|
13
|
+
GlobalRegistrator.register();
|
|
14
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
15
|
+
});
|
|
16
|
+
afterAll(() => {
|
|
17
|
+
GlobalRegistrator.unregister();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
import { act, type ReactNode } from 'react';
|
|
21
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
22
|
+
import type { Tool } from '../input-router.tsx';
|
|
23
|
+
import { _resetReadOnlyCache } from '../read-only-mode.ts';
|
|
24
|
+
import { ToolPalette } from '../tool-palette.tsx';
|
|
25
|
+
import { type CanvasMode, ToolProvider, useToolMode } from '../use-tool-mode.tsx';
|
|
26
|
+
|
|
27
|
+
// Capture the live context value so tests can drive setMode/setTool/resetTool
|
|
28
|
+
// directly and assert the resulting (tool, mode) pair.
|
|
29
|
+
let ctx: ReturnType<typeof useToolMode> | null = null;
|
|
30
|
+
function Capture() {
|
|
31
|
+
ctx = useToolMode();
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let root: Root | null = null;
|
|
36
|
+
let host: HTMLElement | null = null;
|
|
37
|
+
|
|
38
|
+
function mount(ui: ReactNode): void {
|
|
39
|
+
host = document.createElement('div');
|
|
40
|
+
document.body.appendChild(host);
|
|
41
|
+
root = createRoot(host);
|
|
42
|
+
act(() => {
|
|
43
|
+
root?.render(ui);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
act(() => {
|
|
49
|
+
root?.unmount();
|
|
50
|
+
});
|
|
51
|
+
host?.remove();
|
|
52
|
+
root = null;
|
|
53
|
+
host = null;
|
|
54
|
+
ctx = null;
|
|
55
|
+
_resetReadOnlyCache();
|
|
56
|
+
setPageUrl('http://localhost/');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// happy-dom: `history.replaceState` does not update `location.search`; the
|
|
60
|
+
// DetachedWindowAPI `setURL` is the supported seam.
|
|
61
|
+
function setPageUrl(url: string): void {
|
|
62
|
+
(window as unknown as { happyDOM?: { setURL?: (u: string) => void } }).happyDOM?.setURL?.(url);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const pair = (): { tool: Tool; mode: CanvasMode } => ({
|
|
66
|
+
tool: ctx?.tool as Tool,
|
|
67
|
+
mode: ctx?.mode as CanvasMode,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('mode-toggle / boot posture (DDR-223)', () => {
|
|
71
|
+
test('default boot = edit with move armed', () => {
|
|
72
|
+
mount(
|
|
73
|
+
<ToolProvider>
|
|
74
|
+
<Capture />
|
|
75
|
+
</ToolProvider>
|
|
76
|
+
);
|
|
77
|
+
expect(pair()).toEqual({ tool: 'move', mode: 'edit' });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('initial="browse" boots preview (comment-mount specimen posture)', () => {
|
|
81
|
+
mount(
|
|
82
|
+
<ToolProvider initial="browse">
|
|
83
|
+
<Capture />
|
|
84
|
+
</ToolProvider>
|
|
85
|
+
);
|
|
86
|
+
expect(pair()).toEqual({ tool: 'browse', mode: 'preview' });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('read-only (?ro=1) boots preview/browse — the DDR-187 viewer posture', () => {
|
|
90
|
+
setPageUrl('http://localhost/?ro=1');
|
|
91
|
+
_resetReadOnlyCache();
|
|
92
|
+
mount(
|
|
93
|
+
<ToolProvider>
|
|
94
|
+
<Capture />
|
|
95
|
+
</ToolProvider>
|
|
96
|
+
);
|
|
97
|
+
expect(pair()).toEqual({ tool: 'browse', mode: 'preview' });
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('mode-toggle / mode⇄tool invariant', () => {
|
|
102
|
+
test('setMode arms the resting tool both ways', () => {
|
|
103
|
+
mount(
|
|
104
|
+
<ToolProvider>
|
|
105
|
+
<Capture />
|
|
106
|
+
</ToolProvider>
|
|
107
|
+
);
|
|
108
|
+
act(() => ctx?.setMode('preview'));
|
|
109
|
+
expect(pair()).toEqual({ tool: 'browse', mode: 'preview' });
|
|
110
|
+
act(() => ctx?.setMode('edit'));
|
|
111
|
+
expect(pair()).toEqual({ tool: 'move', mode: 'edit' });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('arming a resting tool moves the mode (V / tool-set / escape hatch)', () => {
|
|
115
|
+
mount(
|
|
116
|
+
<ToolProvider>
|
|
117
|
+
<Capture />
|
|
118
|
+
</ToolProvider>
|
|
119
|
+
);
|
|
120
|
+
act(() => ctx?.setTool('browse'));
|
|
121
|
+
expect(pair()).toEqual({ tool: 'browse', mode: 'preview' });
|
|
122
|
+
// The Cmd+click-in-browse escape hatch is a plain setTool('move') at its
|
|
123
|
+
// call site — the invariant is what turns it into a coherent mode flip.
|
|
124
|
+
act(() => ctx?.setTool('move'));
|
|
125
|
+
expect(pair()).toEqual({ tool: 'move', mode: 'edit' });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('annotation tools are mode-neutral — pen in preview stays preview', () => {
|
|
129
|
+
mount(
|
|
130
|
+
<ToolProvider initial="browse">
|
|
131
|
+
<Capture />
|
|
132
|
+
</ToolProvider>
|
|
133
|
+
);
|
|
134
|
+
act(() => ctx?.setTool('pen'));
|
|
135
|
+
expect(pair()).toEqual({ tool: 'pen', mode: 'preview' });
|
|
136
|
+
act(() => ctx?.setTool('comment'));
|
|
137
|
+
expect(pair()).toEqual({ tool: 'comment', mode: 'preview' });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('resetTool arms the CURRENT mode’s resting tool', () => {
|
|
141
|
+
mount(
|
|
142
|
+
<ToolProvider initial="browse">
|
|
143
|
+
<Capture />
|
|
144
|
+
</ToolProvider>
|
|
145
|
+
);
|
|
146
|
+
// Preview: drawing an annotation then resetting returns to browse (the
|
|
147
|
+
// alive posture) — never to move.
|
|
148
|
+
act(() => ctx?.setTool('pen'));
|
|
149
|
+
act(() => ctx?.resetTool());
|
|
150
|
+
expect(pair()).toEqual({ tool: 'browse', mode: 'preview' });
|
|
151
|
+
// Edit: byte-identical to the pre-DDR-223 hardcoded setTool('move').
|
|
152
|
+
act(() => ctx?.setMode('edit'));
|
|
153
|
+
act(() => ctx?.setTool('pen'));
|
|
154
|
+
act(() => ctx?.resetTool());
|
|
155
|
+
expect(pair()).toEqual({ tool: 'move', mode: 'edit' });
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('mode-toggle / palette segmented toggle', () => {
|
|
160
|
+
const mountPalette = () =>
|
|
161
|
+
mount(
|
|
162
|
+
<ToolProvider>
|
|
163
|
+
<Capture />
|
|
164
|
+
<ToolPalette />
|
|
165
|
+
</ToolProvider>
|
|
166
|
+
);
|
|
167
|
+
const seg = (which: 'preview' | 'edit' | 'present'): HTMLButtonElement | null =>
|
|
168
|
+
document.querySelector(`[data-testid="palette-mode-${which}"]`);
|
|
169
|
+
|
|
170
|
+
test('renders three icon segments; Edit is pressed on boot; no browse/move tool buttons', () => {
|
|
171
|
+
mountPalette();
|
|
172
|
+
expect(seg('preview')).not.toBeNull();
|
|
173
|
+
expect(seg('edit')?.getAttribute('aria-pressed')).toBe('true');
|
|
174
|
+
expect(seg('preview')?.getAttribute('aria-pressed')).toBe('false');
|
|
175
|
+
// Owner steer 2026-08-15 — icon-only segments (lucide eye / pencil-ruler /
|
|
176
|
+
// presentation); the words live in aria-label/title.
|
|
177
|
+
expect(seg('preview')?.querySelector('svg')).not.toBeNull();
|
|
178
|
+
expect(seg('edit')?.querySelector('svg')).not.toBeNull();
|
|
179
|
+
expect(seg('present')?.querySelector('svg')).not.toBeNull();
|
|
180
|
+
// The segments ARE the resting-tool affordances — no separate Select /
|
|
181
|
+
// Browse buttons in the nav group.
|
|
182
|
+
expect(document.querySelector('[aria-label^="Select (V)"]')).toBeNull();
|
|
183
|
+
expect(document.querySelector('[aria-label^="Browse ("]')).toBeNull();
|
|
184
|
+
// Present moved INTO the toggle — exactly one presentation affordance, and
|
|
185
|
+
// it is the segment (the palette's right-end button is gone).
|
|
186
|
+
const present = document.querySelectorAll('[aria-label^="Presentation mode"]');
|
|
187
|
+
expect(present.length).toBe(1);
|
|
188
|
+
expect(present[0]).toBe(seg('present') as Element);
|
|
189
|
+
expect(seg('present')?.getAttribute('aria-pressed')).toBe('false');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('clicking Preview flips the posture; insert (+) is edit-only', () => {
|
|
193
|
+
mountPalette();
|
|
194
|
+
expect(
|
|
195
|
+
document.querySelector('[aria-label="Insert element — Div, Text, or Image"]')
|
|
196
|
+
).not.toBeNull();
|
|
197
|
+
act(() => {
|
|
198
|
+
seg('preview')?.click();
|
|
199
|
+
});
|
|
200
|
+
expect(pair()).toEqual({ tool: 'browse', mode: 'preview' });
|
|
201
|
+
expect(seg('preview')?.getAttribute('aria-pressed')).toBe('true');
|
|
202
|
+
expect(
|
|
203
|
+
document.querySelector('[aria-label="Insert element — Div, Text, or Image"]')
|
|
204
|
+
).toBeNull();
|
|
205
|
+
// Draw tools stay available in preview (issue #93: annotations in preview).
|
|
206
|
+
expect(document.querySelector('[aria-label^="Pen (B)"]')).not.toBeNull();
|
|
207
|
+
// And the Edit segment arms move again.
|
|
208
|
+
act(() => {
|
|
209
|
+
seg('edit')?.click();
|
|
210
|
+
});
|
|
211
|
+
expect(pair()).toEqual({ tool: 'move', mode: 'edit' });
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('clicking the ACTIVE segment re-arms the resting tool', () => {
|
|
215
|
+
mountPalette();
|
|
216
|
+
act(() => {
|
|
217
|
+
seg('preview')?.click();
|
|
218
|
+
});
|
|
219
|
+
act(() => ctx?.setTool('pen'));
|
|
220
|
+
expect(pair()).toEqual({ tool: 'pen', mode: 'preview' });
|
|
221
|
+
act(() => {
|
|
222
|
+
seg('preview')?.click();
|
|
223
|
+
});
|
|
224
|
+
expect(pair()).toEqual({ tool: 'browse', mode: 'preview' });
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -7,6 +7,7 @@ import { renderToStaticMarkup } from 'react-dom/server';
|
|
|
7
7
|
import {
|
|
8
8
|
DEFAULT_TOOLS,
|
|
9
9
|
filterToolsForReadOnly,
|
|
10
|
+
MODE_DEFAULT_TOOL,
|
|
10
11
|
ToolProvider,
|
|
11
12
|
useToolMode,
|
|
12
13
|
useToolModeOptional,
|
|
@@ -49,6 +50,11 @@ describe('use-tool-mode / static', () => {
|
|
|
49
50
|
expect(Object.isFrozen(DEFAULT_TOOLS)).toBe(true);
|
|
50
51
|
});
|
|
51
52
|
|
|
53
|
+
test('MODE_DEFAULT_TOOL — DDR-223 resting tools: preview→browse, edit→move', () => {
|
|
54
|
+
expect(MODE_DEFAULT_TOOL).toEqual({ preview: 'browse', edit: 'move' });
|
|
55
|
+
expect(Object.isFrozen(MODE_DEFAULT_TOOL)).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
52
58
|
test('cursors per tool (Phase 24 — ONE Kenney library for every tool + native fallback)', () => {
|
|
53
59
|
const byId = Object.fromEntries(DEFAULT_TOOLS.map((t) => [t.id, t.cursor]));
|
|
54
60
|
// Phase 24 — EVERY tool (incl. move) ships a Kenney data-URI SVG cursor that
|
|
@@ -97,16 +103,40 @@ describe('use-tool-mode / useToolModeOptional', () => {
|
|
|
97
103
|
describe('use-tool-mode / SSR render', () => {
|
|
98
104
|
test('ToolProvider with consumer renders without throwing', () => {
|
|
99
105
|
function Consumer() {
|
|
100
|
-
const { tool } = useToolMode();
|
|
101
|
-
return
|
|
106
|
+
const { tool, mode } = useToolMode();
|
|
107
|
+
return (
|
|
108
|
+
<span data-tool={tool} data-mode={mode}>
|
|
109
|
+
{tool}
|
|
110
|
+
</span>
|
|
111
|
+
);
|
|
102
112
|
}
|
|
103
113
|
const html = renderToStaticMarkup(
|
|
104
114
|
<ToolProvider>
|
|
105
115
|
<Consumer />
|
|
106
116
|
</ToolProvider>
|
|
107
117
|
);
|
|
108
|
-
//
|
|
118
|
+
// DDR-223 (issue #93) — boot posture is EDIT with the Move (select) tool
|
|
119
|
+
// armed; Preview (the DDR-187 alive posture) is one toggle away.
|
|
120
|
+
expect(html).toContain('data-tool="move"');
|
|
121
|
+
expect(html).toContain('data-mode="edit"');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('ToolProvider initial="browse" boots the preview mode (comment-mount / specimens)', () => {
|
|
125
|
+
function Consumer() {
|
|
126
|
+
const { tool, mode } = useToolMode();
|
|
127
|
+
return (
|
|
128
|
+
<span data-tool={tool} data-mode={mode}>
|
|
129
|
+
{tool}
|
|
130
|
+
</span>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const html = renderToStaticMarkup(
|
|
134
|
+
<ToolProvider initial="browse">
|
|
135
|
+
<Consumer />
|
|
136
|
+
</ToolProvider>
|
|
137
|
+
);
|
|
109
138
|
expect(html).toContain('data-tool="browse"');
|
|
139
|
+
expect(html).toContain('data-mode="preview"');
|
|
110
140
|
});
|
|
111
141
|
|
|
112
142
|
test('ToolProvider honors initial tool', () => {
|
|
@@ -16,7 +16,9 @@ import { useEffect, useRef, useState } from 'react';
|
|
|
16
16
|
|
|
17
17
|
import {
|
|
18
18
|
IconChevronDown,
|
|
19
|
+
IconEye,
|
|
19
20
|
IconLetterA,
|
|
21
|
+
IconPencilRuler,
|
|
20
22
|
IconPresentation,
|
|
21
23
|
IconSquare,
|
|
22
24
|
SHAPE_KIND_ICONS,
|
|
@@ -217,6 +219,10 @@ const PALETTE_CSS = `
|
|
|
217
219
|
background: color-mix(in oklab, var(--maude-hud-accent, #d63b1f) 14%, transparent);
|
|
218
220
|
color: var(--maude-hud-accent, #d63b1f);
|
|
219
221
|
}
|
|
222
|
+
/* DDR-223 — Preview/Edit/Present segmented mode toggle at the head of the
|
|
223
|
+
palette (owner steer 2026-08-15: icon segments — lucide eye / pencil-ruler /
|
|
224
|
+
presentation). Standard 32 × 32 buttons; active state reuses the
|
|
225
|
+
aria-pressed tint + underbar. */
|
|
220
226
|
/* Stage I3 tail — "+ Element" insert popover. A vertical labeled list (not the
|
|
221
227
|
shape popover's icon grid): Div/Text/Image have no recognizable glyph on
|
|
222
228
|
their own, and this is a rare, must-be-legible action, not a recalled tool. */
|
|
@@ -257,9 +263,12 @@ function ensurePaletteStyles(): void {
|
|
|
257
263
|
document.head.appendChild(s);
|
|
258
264
|
}
|
|
259
265
|
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
|
|
266
|
+
// DDR-223 — the Preview/Edit segments replace BOTH resting-tool buttons in
|
|
267
|
+
// the nav group: `browse` is armed by the Preview segment, `move` by the Edit
|
|
268
|
+
// segment (clicking the active segment re-arms it; V and Esc still arm move
|
|
269
|
+
// directly). Annotation tools render in BOTH modes (issue #93: preview keeps
|
|
270
|
+
// annotations).
|
|
271
|
+
const NAV_TOOLS = ['hand', 'comment'] as const;
|
|
263
272
|
// Phase 24 — the two rect/ellipse buttons collapse into one Shape tool (with a
|
|
264
273
|
// kind popover); sticky/arrow/text/eraser keep their order. Wave H — section
|
|
265
274
|
// joins the row (it was ⇧S-only before; users couldn't discover it).
|
|
@@ -324,7 +333,18 @@ export function resolveInsertAnchor(
|
|
|
324
333
|
|
|
325
334
|
export function ToolPalette() {
|
|
326
335
|
ensurePaletteStyles();
|
|
327
|
-
const {
|
|
336
|
+
const {
|
|
337
|
+
tool,
|
|
338
|
+
setTool,
|
|
339
|
+
tools,
|
|
340
|
+
mode,
|
|
341
|
+
setMode,
|
|
342
|
+
resetTool,
|
|
343
|
+
sticky,
|
|
344
|
+
toggleSticky,
|
|
345
|
+
shapeKind,
|
|
346
|
+
setShapeKind,
|
|
347
|
+
} = useToolMode();
|
|
328
348
|
const chrome = useChromeVisibility();
|
|
329
349
|
const [mounted, setMounted] = useState(false);
|
|
330
350
|
const [shapeOpen, setShapeOpen] = useState(false);
|
|
@@ -484,8 +504,65 @@ export function ToolPalette() {
|
|
|
484
504
|
);
|
|
485
505
|
};
|
|
486
506
|
|
|
507
|
+
// DDR-223 — the mode toggle, three segments (owner steer 2026-08-15, round
|
|
508
|
+
// 2): Preview (eye), Edit (pencil-ruler), Present (presentation) — icon-only,
|
|
509
|
+
// the words live in aria-label + title. Clicking the ACTIVE Preview/Edit
|
|
510
|
+
// segment re-arms that mode's resting tool (the "get me out of the pen tool"
|
|
511
|
+
// gesture — especially in preview, where browse has no palette button of its
|
|
512
|
+
// own). Present is the existing SHELL-level state (enter-only — the palette
|
|
513
|
+
// is hidden while presenting; exit is Esc or the floating pill); it moved
|
|
514
|
+
// here from the palette's right end so all three "ways of looking at the
|
|
515
|
+
// canvas" sit in one control.
|
|
516
|
+
const renderModeToggle = () => (
|
|
517
|
+
// biome-ignore lint/a11y/useSemanticElements: segmented control of buttons, not a form — fieldset semantics (and its default box styling) don't apply
|
|
518
|
+
<div className="dc-tp-group dc-tp-mode" role="group" aria-label="Canvas mode">
|
|
519
|
+
<button
|
|
520
|
+
type="button"
|
|
521
|
+
data-testid="palette-mode-preview"
|
|
522
|
+
aria-label="Preview mode — the mock is live; annotation tools stay on"
|
|
523
|
+
aria-pressed={mode === 'preview'}
|
|
524
|
+
title="Preview — the mock is live (buttons click); annotation tools stay on"
|
|
525
|
+
onClick={() => (mode === 'preview' ? resetTool() : setMode('preview'))}
|
|
526
|
+
>
|
|
527
|
+
<IconEye />
|
|
528
|
+
</button>
|
|
529
|
+
<button
|
|
530
|
+
type="button"
|
|
531
|
+
data-testid="palette-mode-edit"
|
|
532
|
+
aria-label="Edit mode — click selects & edits, like Figma (V)"
|
|
533
|
+
aria-pressed={mode === 'edit'}
|
|
534
|
+
title="Edit — click selects & edits, like Figma (V)"
|
|
535
|
+
onClick={() => (mode === 'edit' ? resetTool() : setMode('edit'))}
|
|
536
|
+
>
|
|
537
|
+
<IconPencilRuler />
|
|
538
|
+
</button>
|
|
539
|
+
<button
|
|
540
|
+
type="button"
|
|
541
|
+
data-testid="palette-mode-present"
|
|
542
|
+
aria-label="Presentation mode — hide all chrome (Esc to exit)"
|
|
543
|
+
aria-pressed={chrome?.present ?? false}
|
|
544
|
+
title="Presentation mode — hide all chrome (Esc to exit)"
|
|
545
|
+
onClick={() => {
|
|
546
|
+
// Present Mode is a SHELL-level state (it hides the menubar /
|
|
547
|
+
// sidebar / panels too), so the canvas iframe requests it from the
|
|
548
|
+
// parent shell, which flips on `.is-present` + broadcasts
|
|
549
|
+
// dgn:'view-chrome' back down.
|
|
550
|
+
try {
|
|
551
|
+
window.parent.postMessage({ dgn: 'present-enter' }, '*');
|
|
552
|
+
} catch {
|
|
553
|
+
/* detached / cross-origin */
|
|
554
|
+
}
|
|
555
|
+
}}
|
|
556
|
+
>
|
|
557
|
+
<IconPresentation />
|
|
558
|
+
</button>
|
|
559
|
+
</div>
|
|
560
|
+
);
|
|
561
|
+
|
|
487
562
|
return (
|
|
488
563
|
<div ref={containerRef} className="dc-tool-palette" role="toolbar" aria-label="Canvas tools">
|
|
564
|
+
{renderModeToggle()}
|
|
565
|
+
<div className="dc-tp-sep" />
|
|
489
566
|
<div className="dc-tp-group">
|
|
490
567
|
{navList.map((t) => (t ? renderToolButton(t.id, t.label, t.shortcut) : null))}
|
|
491
568
|
</div>
|
|
@@ -505,7 +582,9 @@ export function ToolPalette() {
|
|
|
505
582
|
) : null}
|
|
506
583
|
<div className="dc-tp-sep" />
|
|
507
584
|
<div className="dc-tp-group">
|
|
508
|
-
{
|
|
585
|
+
{/* DDR-223 — "+ Element" is a JSX WRITE, an edit-mode affordance;
|
|
586
|
+
stickers/export/present stay in both modes. */}
|
|
587
|
+
{!readOnly && mode === 'edit' && (
|
|
509
588
|
<span className="dc-tp-insert">
|
|
510
589
|
<button
|
|
511
590
|
type="button"
|
|
@@ -575,25 +654,8 @@ export function ToolPalette() {
|
|
|
575
654
|
⬇
|
|
576
655
|
</span>
|
|
577
656
|
</button>
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
aria-label="Presentation mode — hide all chrome"
|
|
581
|
-
title="Presentation mode — hide all chrome (Esc to exit)"
|
|
582
|
-
onClick={() => {
|
|
583
|
-
// Present Mode is a SHELL-level state (it hides the menubar /
|
|
584
|
-
// sidebar / panels too), so the canvas iframe requests it from the
|
|
585
|
-
// parent shell, which flips on `.is-present` + broadcasts
|
|
586
|
-
// dgn:'view-chrome' back down. Enter-only — the palette is hidden
|
|
587
|
-
// while presenting; exit is Esc or the floating pill.
|
|
588
|
-
try {
|
|
589
|
-
window.parent.postMessage({ dgn: 'present-enter' }, '*');
|
|
590
|
-
} catch {
|
|
591
|
-
/* detached / cross-origin */
|
|
592
|
-
}
|
|
593
|
-
}}
|
|
594
|
-
>
|
|
595
|
-
<IconPresentation />
|
|
596
|
-
</button>
|
|
657
|
+
{/* DDR-223 addendum 2 — the Presentation button moved into the mode
|
|
658
|
+
toggle at the palette's head (third segment). */}
|
|
597
659
|
</div>
|
|
598
660
|
</div>
|
|
599
661
|
);
|
|
@@ -44,14 +44,42 @@ export interface ToolDescriptor {
|
|
|
44
44
|
*/
|
|
45
45
|
export type ShapeKind = 'square' | 'rounded' | 'circle' | 'diamond' | 'triangle' | 'triangle-down';
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* DDR-223 (issue #93) — the binary canvas mode over the tool set. `preview`
|
|
49
|
+
* rests on `browse` (pure pass-through, the mock is alive; annotation tools
|
|
50
|
+
* stay usable); `edit` rests on `move` (the Figma select ladder). The mode is
|
|
51
|
+
* a store-level layer — `classify()` and every `tool === 'move'` gate are
|
|
52
|
+
* untouched (DDR-187's decomposition stands).
|
|
53
|
+
*/
|
|
54
|
+
export type CanvasMode = 'preview' | 'edit';
|
|
55
|
+
|
|
56
|
+
/** The resting tool each mode arms (and returns to via `resetTool`). */
|
|
57
|
+
export const MODE_DEFAULT_TOOL: Readonly<Record<CanvasMode, Tool>> = Object.freeze({
|
|
58
|
+
preview: 'browse',
|
|
59
|
+
edit: 'move',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Mode implied by arming a mode-exclusive resting tool. `browse` and `move`
|
|
64
|
+
* are the only mode-carrying tools; everything else (comment, draw set) is
|
|
65
|
+
* mode-NEUTRAL — arming pen in preview keeps you in preview (the issue-#93
|
|
66
|
+
* "annotations work in preview" contract).
|
|
67
|
+
*/
|
|
68
|
+
function modeForTool(t: Tool): CanvasMode | null {
|
|
69
|
+
if (t === 'browse') return 'preview';
|
|
70
|
+
if (t === 'move') return 'edit';
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
47
74
|
// Phase 21 — every tool ships a custom 32×32 SVG cursor (canvas-cursors.ts)
|
|
48
75
|
// with a white outline halo so the glyph reads on any background. The native
|
|
49
76
|
// crosshair/text/cell were thin + tiny ("pen almost invisible"); these mirror
|
|
50
77
|
// the tool-palette icons. `move` keeps the system arrow on purpose.
|
|
51
78
|
export const DEFAULT_TOOLS: readonly ToolDescriptor[] = Object.freeze([
|
|
52
|
-
// feature-4 — Browse is the
|
|
53
|
-
//
|
|
54
|
-
//
|
|
79
|
+
// feature-4 — Browse is the pure pass-through tool: the mock is alive
|
|
80
|
+
// (buttons click). Since DDR-223 it is the PREVIEW mode's resting tool (the
|
|
81
|
+
// boot default moved to edit/`move`); no letter shortcut — it's armed via
|
|
82
|
+
// the Preview toggle / Esc-in-preview.
|
|
55
83
|
{ id: 'browse', label: 'Browse', shortcut: '', cursor: TOOL_CURSORS.browse },
|
|
56
84
|
{ id: 'move', label: 'Select', shortcut: 'V', cursor: TOOL_CURSORS.move },
|
|
57
85
|
{ id: 'hand', label: 'Hand', shortcut: 'H', cursor: TOOL_CURSORS.hand },
|
|
@@ -91,6 +119,14 @@ interface ToolContextValue {
|
|
|
91
119
|
tool: Tool;
|
|
92
120
|
setTool: (t: Tool) => void;
|
|
93
121
|
tools: readonly ToolDescriptor[];
|
|
122
|
+
/** DDR-223 — the binary canvas mode. Kept coherent with `tool` (arming
|
|
123
|
+
* `move`/`browse` moves the mode; `setMode` arms the mode's resting tool). */
|
|
124
|
+
mode: CanvasMode;
|
|
125
|
+
setMode: (m: CanvasMode) => void;
|
|
126
|
+
/** Arm the current mode's resting tool — the Esc / post-draw-commit flip.
|
|
127
|
+
* `move` in edit (byte-identical to the pre-DDR-223 hardcode), `browse` in
|
|
128
|
+
* preview (drawing an annotation never silently exits the alive posture). */
|
|
129
|
+
resetTool: () => void;
|
|
94
130
|
/** T19 — sticky-tool double-click lock. When `sticky.locked === true` AND
|
|
95
131
|
* `sticky.tool === tool`, draw tools stay armed after each shape commit
|
|
96
132
|
* (T18 auto-flip is suppressed). Single-click on any other tool clears
|
|
@@ -111,23 +147,33 @@ const ToolContext = createContext<ToolContextValue | null>(null);
|
|
|
111
147
|
export function ToolProvider({
|
|
112
148
|
children,
|
|
113
149
|
tools: toolsProp = DEFAULT_TOOLS,
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
150
|
+
// DDR-223 (issue #93, supersedes DDR-187's boot half) — authoring surfaces
|
|
151
|
+
// boot into EDIT with `move` (V) armed: click-selects from the first frame,
|
|
152
|
+
// Preview (the alive pass-through posture) is one always-visible toggle
|
|
153
|
+
// away. Read-only canvases (cloud viewers) keep the DDR-187 posture and
|
|
154
|
+
// boot preview/`browse` — a viewer's job is to use the live mock. The
|
|
155
|
+
// comment-mount layer passes `initial='browse'` explicitly so bare DS
|
|
156
|
+
// specimens stay alive too.
|
|
157
|
+
initial,
|
|
158
|
+
initialMode,
|
|
119
159
|
}: {
|
|
120
160
|
children: ReactNode;
|
|
121
161
|
tools?: readonly ToolDescriptor[];
|
|
122
162
|
initial?: Tool;
|
|
163
|
+
initialMode?: CanvasMode;
|
|
123
164
|
}) {
|
|
124
165
|
// Cloud Phase 25 C2 — a read-only canvas keeps only navigate/inspect tools.
|
|
125
166
|
// Filtering HERE covers every consumer at once: the palette renders from
|
|
126
167
|
// `tools`, the input router's letter shortcuts resolve against `tools`, and
|
|
127
168
|
// `setTool` below refuses anything outside the list (the shell `tool-set`
|
|
128
169
|
// postMessage lane included).
|
|
129
|
-
const
|
|
130
|
-
const
|
|
170
|
+
const readOnly = isReadOnlyCanvas();
|
|
171
|
+
const tools = useMemo(() => filterToolsForReadOnly(toolsProp, readOnly), [toolsProp, readOnly]);
|
|
172
|
+
const bootTool: Tool = initial ?? (readOnly ? 'browse' : 'move');
|
|
173
|
+
const bootMode: CanvasMode =
|
|
174
|
+
initialMode ?? modeForTool(bootTool) ?? (readOnly ? 'preview' : 'edit');
|
|
175
|
+
const [tool, setToolState] = useState<Tool>(bootTool);
|
|
176
|
+
const [mode, setModeState] = useState<CanvasMode>(bootMode);
|
|
131
177
|
const [sticky, setSticky] = useState<{ tool: Tool | null; locked: boolean }>(() => ({
|
|
132
178
|
tool: null,
|
|
133
179
|
locked: false,
|
|
@@ -138,12 +184,39 @@ export function ToolProvider({
|
|
|
138
184
|
// shortcut or postMessage can't arm a tool the palette doesn't show.
|
|
139
185
|
if (!tools.some((d) => d.id === t)) return;
|
|
140
186
|
setToolState(t);
|
|
187
|
+
// DDR-223 — mode⇄tool invariant: arming a resting tool moves the mode
|
|
188
|
+
// with it, so V, the menubar `tool-set` lane, and the Cmd+click-in-
|
|
189
|
+
// browse escape hatch all land in EDIT coherently (and arming browse
|
|
190
|
+
// lands in PREVIEW). Annotation tools leave the mode alone. This is
|
|
191
|
+
// also what keeps `move` structurally unreachable inside preview — the
|
|
192
|
+
// moment it arms, the mode is edit, so no `tool === 'move'` gate can
|
|
193
|
+
// ever fire while the UI claims preview.
|
|
194
|
+
const m = modeForTool(t);
|
|
195
|
+
if (m) setModeState(m);
|
|
141
196
|
// Single-click on a different tool clears any sticky lock — sticky is
|
|
142
197
|
// a per-tool flag, not global.
|
|
143
198
|
setSticky((prev) => (prev.locked && prev.tool === t ? prev : { tool: null, locked: false }));
|
|
144
199
|
},
|
|
145
200
|
[tools]
|
|
146
201
|
);
|
|
202
|
+
const setMode = useCallback(
|
|
203
|
+
(m: CanvasMode) => {
|
|
204
|
+
// Switching mode arms its resting tool (Figma-style: the toggle IS the
|
|
205
|
+
// posture). Read-only keeps both browse+move on its allowlist, so the
|
|
206
|
+
// guard below never fires there; it exists for exotic custom `tools`.
|
|
207
|
+
const rest = MODE_DEFAULT_TOOL[m];
|
|
208
|
+
if (!tools.some((d) => d.id === rest)) return;
|
|
209
|
+
setModeState(m);
|
|
210
|
+
setToolState(rest);
|
|
211
|
+
setSticky((prev) =>
|
|
212
|
+
prev.locked && prev.tool === rest ? prev : { tool: null, locked: false }
|
|
213
|
+
);
|
|
214
|
+
},
|
|
215
|
+
[tools]
|
|
216
|
+
);
|
|
217
|
+
const resetTool = useCallback(() => {
|
|
218
|
+
setTool(MODE_DEFAULT_TOOL[mode]);
|
|
219
|
+
}, [mode, setTool]);
|
|
147
220
|
const toggleSticky = useCallback((t: Tool) => {
|
|
148
221
|
setSticky((prev) => {
|
|
149
222
|
if (prev.locked && prev.tool === t) return { tool: null, locked: false };
|
|
@@ -227,8 +300,20 @@ export function ToolProvider({
|
|
|
227
300
|
}, [tool, tools]);
|
|
228
301
|
|
|
229
302
|
const value = useMemo<ToolContextValue>(
|
|
230
|
-
() => ({
|
|
231
|
-
|
|
303
|
+
() => ({
|
|
304
|
+
tool,
|
|
305
|
+
setTool,
|
|
306
|
+
tools,
|
|
307
|
+
mode,
|
|
308
|
+
setMode,
|
|
309
|
+
resetTool,
|
|
310
|
+
sticky,
|
|
311
|
+
toggleSticky,
|
|
312
|
+
clearSticky,
|
|
313
|
+
shapeKind,
|
|
314
|
+
setShapeKind,
|
|
315
|
+
}),
|
|
316
|
+
[tool, setTool, tools, mode, setMode, resetTool, sticky, toggleSticky, clearSticky, shapeKind]
|
|
232
317
|
);
|
|
233
318
|
|
|
234
319
|
return <ToolContext.Provider value={value}>{children}</ToolContext.Provider>;
|
|
@@ -240,10 +325,24 @@ export function ToolProvider({
|
|
|
240
325
|
* `DesignCanvas` consumes that instance instead of double-mounting. The hook
|
|
241
326
|
* is called unconditionally; only the returned tree branches (hook rules).
|
|
242
327
|
*/
|
|
243
|
-
export function MaybeToolProvider({
|
|
328
|
+
export function MaybeToolProvider({
|
|
329
|
+
children,
|
|
330
|
+
initial,
|
|
331
|
+
initialMode,
|
|
332
|
+
}: {
|
|
333
|
+
children: ReactNode;
|
|
334
|
+
/** DDR-223 — forwarded so the comment-mount layer can pin bare DS
|
|
335
|
+
* specimens to the preview/`browse` posture (see canvas-comment-mount). */
|
|
336
|
+
initial?: Tool;
|
|
337
|
+
initialMode?: CanvasMode;
|
|
338
|
+
}) {
|
|
244
339
|
const outer = useContext(ToolContext);
|
|
245
340
|
if (outer) return <>{children}</>;
|
|
246
|
-
return
|
|
341
|
+
return (
|
|
342
|
+
<ToolProvider initial={initial} initialMode={initialMode}>
|
|
343
|
+
{children}
|
|
344
|
+
</ToolProvider>
|
|
345
|
+
);
|
|
247
346
|
}
|
|
248
347
|
|
|
249
348
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./whats-new.schema.json",
|
|
3
3
|
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"id": "assets-arrive-and-heal",
|
|
6
|
+
"version": "0.60.6",
|
|
7
|
+
"date": "2026-08-15",
|
|
8
|
+
"kind": "fix",
|
|
9
|
+
"title": "Dropped images reach every machine, live",
|
|
10
|
+
"summary": "A picture added on one machine now arrives on your others while you watch: the cloud serves it the moment it has it, and a broken-image frame heals in place when the file lands — no reload needed.",
|
|
11
|
+
"surface": "design-ui"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "preview-edit-mode-toggle",
|
|
15
|
+
"version": "0.60.6",
|
|
16
|
+
"date": "2026-08-15",
|
|
17
|
+
"kind": "feature",
|
|
18
|
+
"title": "Preview, Edit and Present in one switch",
|
|
19
|
+
"summary": "Canvases open in Edit — clicking selects right away, like Figma. A new three-way switch in the toolbar flips to Preview (the mock is live and clickable; comments and drawing still work) or straight into Presentation.",
|
|
20
|
+
"surface": "design-ui"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"id": "desktop-splash-recovers-itself",
|
|
24
|
+
"version": "0.60.6",
|
|
25
|
+
"date": "2026-08-15",
|
|
26
|
+
"kind": "fix",
|
|
27
|
+
"title": "No more getting stuck on “Starting…”",
|
|
28
|
+
"summary": "The desktop app could go black and sit on the startup screen for good — often right after a command finished — with a force-quit as the only way out. It now finds its way back on its own, in about a second, and keeps your session instead of losing it.",
|
|
29
|
+
"surface": "desktop"
|
|
30
|
+
},
|
|
4
31
|
{
|
|
5
32
|
"id": "annotations-survive-cold-start",
|
|
6
33
|
"version": "0.60.5",
|