@1agh/maude 0.24.0 → 0.25.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/package.json +8 -8
- package/plugins/design/dependencies.json +30 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +481 -78
- package/plugins/design/dev-server/annotations-layer.tsx +817 -170
- package/plugins/design/dev-server/api.ts +15 -1
- package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
- package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
- package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
- package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
- package/plugins/design/dev-server/bin/smoke.sh +114 -12
- package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
- package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
- package/plugins/design/dev-server/canvas-cursors.ts +130 -82
- package/plugins/design/dev-server/canvas-icons.tsx +169 -0
- package/plugins/design/dev-server/canvas-shell.tsx +113 -89
- package/plugins/design/dev-server/client/app.jsx +1084 -417
- package/plugins/design/dev-server/dist/client.bundle.js +242 -20
- package/plugins/design/dev-server/dist/comment-mount.js +40 -62
- package/plugins/design/dev-server/export-dialog.tsx +189 -1
- package/plugins/design/dev-server/exporters/html.ts +4 -1
- package/plugins/design/dev-server/exporters/index.ts +4 -1
- package/plugins/design/dev-server/exporters/pdf.ts +7 -1
- package/plugins/design/dev-server/exporters/png.ts +21 -2
- package/plugins/design/dev-server/exporters/pptx.ts +330 -201
- package/plugins/design/dev-server/exporters/scope.ts +107 -11
- package/plugins/design/dev-server/exporters/svg.ts +4 -1
- package/plugins/design/dev-server/http.ts +40 -9
- package/plugins/design/dev-server/input-router.tsx +9 -2
- package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +83 -3
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +243 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +95 -6
- package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
- package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
- package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
- package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
- package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
- package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
- package/plugins/design/dev-server/test/input-router.test.ts +11 -4
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +32 -0
- package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +9 -11
- package/plugins/design/dev-server/tool-palette.tsx +140 -11
- package/plugins/design/dev-server/use-annotation-resize.tsx +208 -61
- package/plugins/design/dev-server/use-tool-mode.tsx +55 -9
- package/plugins/design/templates/_shell.html +36 -9
|
@@ -33,6 +33,17 @@ export type Target =
|
|
|
33
33
|
file: string;
|
|
34
34
|
/** True when `cssPath` is expected to match many elements; the adapter iterates. */
|
|
35
35
|
multi?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* True when the adapter should widen `cssPath` to its closest
|
|
38
|
+
* `[data-dc-screen]` ancestor before capture — i.e. "export the artboard
|
|
39
|
+
* containing this element". `selection` scope sets this `false` so the
|
|
40
|
+
* raster/vector captures the element EXACTLY (the item-3 bug was every
|
|
41
|
+
* single-target export hard-widening). `artboard` scope sets it `true`
|
|
42
|
+
* only in the fallback where we have a descendant selector but no
|
|
43
|
+
* artboard id; when the id IS known we target `[data-dc-screen="<id>"]`
|
|
44
|
+
* directly and leave this `false`.
|
|
45
|
+
*/
|
|
46
|
+
widen?: boolean;
|
|
36
47
|
}
|
|
37
48
|
| {
|
|
38
49
|
kind: 'file-tree';
|
|
@@ -49,6 +60,21 @@ export interface ActiveJsonShape {
|
|
|
49
60
|
| null;
|
|
50
61
|
}
|
|
51
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Submit-time hints captured from the LIVE iframe (the export dialog snapshots
|
|
65
|
+
* the canvas selection + the artboard under the viewport centre and rides them
|
|
66
|
+
* on the export `options` bag). The resolver prefers these over `activeJson`
|
|
67
|
+
* because opening the dialog / clicking Export can drop the persisted
|
|
68
|
+
* `_active.json.selected`, and they survive the cross-origin bridge unchanged.
|
|
69
|
+
* See the export-pipeline-fixes plan Task 1 + the selection-passthrough DDR.
|
|
70
|
+
*/
|
|
71
|
+
export interface ExportScopeHints {
|
|
72
|
+
/** Live selection at submit time. `selector` wins over `activeJson.selected`. */
|
|
73
|
+
selection?: { selector?: string; file?: string } | null;
|
|
74
|
+
/** `data-dc-screen` id of the artboard to export for `scope=artboard`. */
|
|
75
|
+
artboardId?: string | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
52
78
|
export interface ResolveScopeArgs {
|
|
53
79
|
scope: Scope;
|
|
54
80
|
activeJson: ActiveJsonShape;
|
|
@@ -56,6 +82,33 @@ export interface ResolveScopeArgs {
|
|
|
56
82
|
designRoot: string;
|
|
57
83
|
/** Absolute path to repo root. Required for `project-raw` to bound the walk. */
|
|
58
84
|
repoRoot?: string;
|
|
85
|
+
/**
|
|
86
|
+
* The export `options` bag, threaded from `runExport`. Read additively so
|
|
87
|
+
* existing callers (and the pure unit tests) that omit it stay green.
|
|
88
|
+
*/
|
|
89
|
+
options?: Record<string, unknown>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Narrow the free-form options bag to the selection/artboard hints we read. */
|
|
93
|
+
function readHints(options: Record<string, unknown> | undefined): ExportScopeHints {
|
|
94
|
+
if (!options || typeof options !== 'object') return {};
|
|
95
|
+
const sel = options.selection;
|
|
96
|
+
const selection =
|
|
97
|
+
sel && typeof sel === 'object'
|
|
98
|
+
? {
|
|
99
|
+
selector:
|
|
100
|
+
typeof (sel as Record<string, unknown>).selector === 'string'
|
|
101
|
+
? ((sel as Record<string, unknown>).selector as string)
|
|
102
|
+
: undefined,
|
|
103
|
+
file:
|
|
104
|
+
typeof (sel as Record<string, unknown>).file === 'string'
|
|
105
|
+
? ((sel as Record<string, unknown>).file as string)
|
|
106
|
+
: undefined,
|
|
107
|
+
}
|
|
108
|
+
: null;
|
|
109
|
+
const artboardId =
|
|
110
|
+
typeof options.artboardId === 'string' && options.artboardId ? options.artboardId : null;
|
|
111
|
+
return { selection, artboardId };
|
|
59
112
|
}
|
|
60
113
|
|
|
61
114
|
const RAW_EXCLUDES = new Set([
|
|
@@ -88,6 +141,16 @@ function slugify(file: string, designRel: string): string {
|
|
|
88
141
|
.toLowerCase();
|
|
89
142
|
}
|
|
90
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Escape a value for use inside a double-quoted CSS attribute selector
|
|
146
|
+
* (`[data-dc-screen="<here>"]`). Artboard ids are normally plain slugs, but a
|
|
147
|
+
* stray `"` or `\` would break out of the selector — escape defensively so the
|
|
148
|
+
* id is matched literally.
|
|
149
|
+
*/
|
|
150
|
+
function cssAttrEscape(value: string): string {
|
|
151
|
+
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
152
|
+
}
|
|
153
|
+
|
|
91
154
|
interface SelectionShape {
|
|
92
155
|
file?: string;
|
|
93
156
|
selector?: string;
|
|
@@ -147,37 +210,70 @@ export async function resolveScope(args: ResolveScopeArgs): Promise<Target[]> {
|
|
|
147
210
|
if (!activeFile) return [];
|
|
148
211
|
const slug = slugify(activeFile, designRel);
|
|
149
212
|
const sel = firstSelection(activeJson.selected);
|
|
213
|
+
const hints = readHints(args.options);
|
|
150
214
|
|
|
151
215
|
if (scope === 'selection') {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
216
|
+
// Prefer the live submit-time snapshot over the persisted `_active.json`
|
|
217
|
+
// selection — the dialog can clear the canvas selection before the bridged
|
|
218
|
+
// export runs, leaving `activeJson.selected` null (item 3 root cause #1).
|
|
219
|
+
const selector = hints.selection?.selector ?? sel?.selector ?? sel?.cssPath;
|
|
220
|
+
if (!selector) {
|
|
221
|
+
// No selection anywhere → fall back to artboard scope so the export
|
|
222
|
+
// still produces something useful. Single fallback site.
|
|
156
223
|
return resolveScope({ ...args, scope: 'artboard' });
|
|
157
224
|
}
|
|
158
|
-
const file =
|
|
225
|
+
const file = hints.selection?.file ?? sel?.file ?? activeFile;
|
|
159
226
|
return [
|
|
160
227
|
{
|
|
161
228
|
kind: 'element',
|
|
162
229
|
cssPath: selector,
|
|
163
230
|
canvasSlug: slugify(file, designRel),
|
|
164
231
|
file,
|
|
232
|
+
// Capture the element EXACTLY — do NOT widen to the enclosing artboard
|
|
233
|
+
// (item 3 root cause #2: every single-target export hard-widened).
|
|
234
|
+
widen: false,
|
|
165
235
|
},
|
|
166
236
|
];
|
|
167
237
|
}
|
|
168
238
|
|
|
169
239
|
if (scope === 'artboard') {
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
240
|
+
// Preferred path: the dialog captured which artboard is active (the
|
|
241
|
+
// selection's host, or the artboard under the viewport centre). Target it
|
|
242
|
+
// by id so EVERY format (PDF included — its shim doesn't widen) renders
|
|
243
|
+
// the right artboard, not `:first-of-type` (item 5 root cause).
|
|
244
|
+
if (hints.artboardId) {
|
|
245
|
+
return [
|
|
246
|
+
{
|
|
247
|
+
kind: 'element',
|
|
248
|
+
cssPath: `[data-dc-screen="${cssAttrEscape(hints.artboardId)}"]`,
|
|
249
|
+
canvasSlug: slug,
|
|
250
|
+
file: activeFile,
|
|
251
|
+
widen: false,
|
|
252
|
+
},
|
|
253
|
+
];
|
|
254
|
+
}
|
|
255
|
+
// Fallback: we only have a descendant selector → pass it through and let
|
|
256
|
+
// the adapter widen to the closest `[data-dc-screen]` ancestor.
|
|
257
|
+
const descendant = hints.selection?.selector ?? sel?.selector ?? sel?.cssPath;
|
|
258
|
+
if (descendant) {
|
|
259
|
+
return [
|
|
260
|
+
{
|
|
261
|
+
kind: 'element',
|
|
262
|
+
cssPath: descendant,
|
|
263
|
+
canvasSlug: slug,
|
|
264
|
+
file: activeFile,
|
|
265
|
+
widen: true,
|
|
266
|
+
},
|
|
267
|
+
];
|
|
268
|
+
}
|
|
269
|
+
// Last resort — first artboard on the active canvas.
|
|
175
270
|
return [
|
|
176
271
|
{
|
|
177
272
|
kind: 'element',
|
|
178
|
-
cssPath:
|
|
273
|
+
cssPath: '[data-dc-screen]:first-of-type',
|
|
179
274
|
canvasSlug: slug,
|
|
180
275
|
file: activeFile,
|
|
276
|
+
widen: false,
|
|
181
277
|
},
|
|
182
278
|
];
|
|
183
279
|
}
|
|
@@ -44,7 +44,10 @@ async function captureSvg(
|
|
|
44
44
|
if (target.multi) {
|
|
45
45
|
args.push('--multi', '1', '--out-dir', outDir);
|
|
46
46
|
} else {
|
|
47
|
-
|
|
47
|
+
// Widen to the enclosing artboard only when scope.ts requested it; a
|
|
48
|
+
// `selection` target captures the element exactly.
|
|
49
|
+
if (target.widen) args.push('--widen-to-artboard', '1');
|
|
50
|
+
args.push('--out', path.join(outDir, `${target.canvasSlug}.svg`));
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
// Run via `node` so the shim's `import 'playwright'` resolves against
|
|
@@ -153,6 +153,26 @@ interface CanvasCacheEntry {
|
|
|
153
153
|
}
|
|
154
154
|
const canvasCache = new Map<string, CanvasCacheEntry>();
|
|
155
155
|
|
|
156
|
+
// Per-process boot id, folded into every canvas ETag. The canvas transpile
|
|
157
|
+
// BUNDLES the shared chrome (canvas-lib → tool-palette / annotations-layer /
|
|
158
|
+
// use-tool-mode / canvas-cursors / …), but the freshness sig + base etag only
|
|
159
|
+
// track the canvas file + its inlined CSS — NOT those chrome sources. So a
|
|
160
|
+
// chrome edit left the etag unchanged → the browser's `If-None-Match` matched →
|
|
161
|
+
// 304 → the user kept getting the STALE transpile even after a server restart
|
|
162
|
+
// (the recurring "my cursor / chrome change didn't show up" bug). Folding the
|
|
163
|
+
// boot id in means a restart — the expected workflow after a dev-server source
|
|
164
|
+
// edit (see CLAUDE.md) — changes every canvas etag, so a normal reload re-fetches
|
|
165
|
+
// fresh chrome. (DDR-067.)
|
|
166
|
+
const RUNTIME_BOOT_ID = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
167
|
+
|
|
168
|
+
// Bumped by the chrome source watchers (below) on every canvas-lib / dev-server
|
|
169
|
+
// `.tsx`/`.ts` edit. Folded into the canvas ETag so a LIVE chrome edit (no
|
|
170
|
+
// restart) also changes the etag — otherwise clearing `canvasCache` re-transpiles
|
|
171
|
+
// fresh on the server, but the unchanged canvas SOURCE yields the same
|
|
172
|
+
// `result.etag`, the browser's `If-None-Match` matches → 304 → the user still
|
|
173
|
+
// gets the stale bundle even after the HMR hard-reload. (DDR-067.)
|
|
174
|
+
let CHROME_EPOCH = 0;
|
|
175
|
+
|
|
156
176
|
/** Relative `.css` import specifiers in a canvas source → absolute paths (the
|
|
157
177
|
* files Bun.build inlines). Bare / virtual specifiers (`@maude/…`, npm) are
|
|
158
178
|
* skipped — only on-disk relative CSS affects the inlined output. Resolved
|
|
@@ -228,7 +248,10 @@ async function serveCanvasTsx(
|
|
|
228
248
|
// may have added or removed a `.css` import.
|
|
229
249
|
cached = {
|
|
230
250
|
sig: canvasFreshnessSig(absPath, cssDeps),
|
|
231
|
-
|
|
251
|
+
// Fold in the boot id (restart) + chrome epoch (live edit) so a chrome
|
|
252
|
+
// change busts the browser's cached transpile even when the canvas source
|
|
253
|
+
// (hence result.etag) is unchanged. See RUNTIME_BOOT_ID / CHROME_EPOCH.
|
|
254
|
+
etag: `${result.etag}-${RUNTIME_BOOT_ID}-${CHROME_EPOCH}`,
|
|
232
255
|
js: result.js,
|
|
233
256
|
cssDeps,
|
|
234
257
|
};
|
|
@@ -309,6 +332,7 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
309
332
|
ctx.bus.on('fs:any', (rel: string) => {
|
|
310
333
|
if (rel.startsWith('_lib/')) {
|
|
311
334
|
canvasCache.clear();
|
|
335
|
+
CHROME_EPOCH++;
|
|
312
336
|
}
|
|
313
337
|
});
|
|
314
338
|
|
|
@@ -316,6 +340,7 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
316
340
|
try {
|
|
317
341
|
libWatcher = watch(canvasLibPath(), () => {
|
|
318
342
|
canvasCache.clear();
|
|
343
|
+
CHROME_EPOCH++;
|
|
319
344
|
ctx.bus.emit('fs:any', '_lib/canvas-lib.tsx');
|
|
320
345
|
});
|
|
321
346
|
} catch (err) {
|
|
@@ -327,21 +352,27 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
327
352
|
void libWatcher;
|
|
328
353
|
|
|
329
354
|
// G7v2 — canvas-lib.tsx transitively imports many dev-server siblings
|
|
330
|
-
// (canvas-shell, contextual-toolbar, equal-spacing-handles,
|
|
331
|
-
// any of them invalidates the bundled canvas
|
|
332
|
-
// the mtime-keyed `canvasCache` keeps serving
|
|
333
|
-
// user sees pre-edit behaviour even after a hard
|
|
355
|
+
// (canvas-shell, contextual-toolbar, equal-spacing-handles, tool-palette,
|
|
356
|
+
// use-tool-mode, ...). Editing any of them invalidates the bundled canvas
|
|
357
|
+
// output. Without watching them the mtime-keyed `canvasCache` keeps serving
|
|
358
|
+
// the stale bundle and the user sees pre-edit behaviour even after a hard
|
|
359
|
+
// iframe reload.
|
|
334
360
|
//
|
|
335
|
-
// Recursive watch over DEV_SERVER_ROOT, filtered to .tsx
|
|
336
|
-
//
|
|
337
|
-
// (`
|
|
361
|
+
// Recursive watch over DEV_SERVER_ROOT, filtered to .tsx AND .ts: the chrome
|
|
362
|
+
// graph includes plain `.ts` modules that DO reach the canvas
|
|
363
|
+
// (`canvas-cursors.ts`, `canvas-arrowheads.ts`) — the prior `.tsx`-only filter
|
|
364
|
+
// skipped them, so cursor edits never busted the cache (the recurring stale-
|
|
365
|
+
// cursor bug, DDR-067). Server-only `.ts` (api / http / context) also clears
|
|
366
|
+
// here, which is harmless (a needless rebuild, not a stale serve). Test files
|
|
367
|
+
// (`test/`), built output (`dist/`, `client/`), and node_modules are skipped.
|
|
338
368
|
let devSrcWatcher: ReturnType<typeof watch> | null = null;
|
|
339
369
|
try {
|
|
340
370
|
devSrcWatcher = watch(DEV_SERVER_ROOT, { recursive: true }, (_evt, filename) => {
|
|
341
371
|
if (!filename) return;
|
|
342
|
-
if (!filename.endsWith('.tsx')) return;
|
|
372
|
+
if (!filename.endsWith('.tsx') && !filename.endsWith('.ts')) return;
|
|
343
373
|
if (filename.startsWith('test/') || filename.startsWith('test\\')) return;
|
|
344
374
|
if (filename.startsWith('dist/') || filename.startsWith('client/')) return;
|
|
375
|
+
if (filename.includes('node_modules')) return;
|
|
345
376
|
canvasCache.clear();
|
|
346
377
|
ctx.bus.emit('fs:any', `_lib/${filename}`);
|
|
347
378
|
});
|
|
@@ -68,6 +68,11 @@ export type Tool =
|
|
|
68
68
|
| 'hand'
|
|
69
69
|
| 'comment'
|
|
70
70
|
| 'pen'
|
|
71
|
+
// Phase 24 — `shape` is the single active draw tool that produces rect /
|
|
72
|
+
// ellipse / polygon strokes (the kind is chosen via the palette popover).
|
|
73
|
+
// `rect` / `ellipse` stay in the union as the stroke discriminants they
|
|
74
|
+
// always were, but are no longer directly selectable active tools.
|
|
75
|
+
| 'shape'
|
|
71
76
|
| 'rect'
|
|
72
77
|
| 'ellipse'
|
|
73
78
|
| 'arrow'
|
|
@@ -77,6 +82,7 @@ export type Tool =
|
|
|
77
82
|
|
|
78
83
|
const ANNOTATION_TOOLS = new Set<Tool>([
|
|
79
84
|
'pen',
|
|
85
|
+
'shape',
|
|
80
86
|
'rect',
|
|
81
87
|
'ellipse',
|
|
82
88
|
'arrow',
|
|
@@ -161,8 +167,9 @@ export function classify(input: ClassifyInput): RouterAction {
|
|
|
161
167
|
if (k === 'h') return { kind: 'tool', tool: 'hand' };
|
|
162
168
|
if (k === 'c') return { kind: 'tool', tool: 'comment' };
|
|
163
169
|
if (k === 'b') return { kind: 'tool', tool: 'pen' };
|
|
164
|
-
|
|
165
|
-
|
|
170
|
+
// Phase 24 — R (and legacy O) both arm the single Shape tool; the specific
|
|
171
|
+
// primitive is picked from the palette's shape-kind popover.
|
|
172
|
+
if (k === 'r' || k === 'o') return { kind: 'tool', tool: 'shape' };
|
|
166
173
|
if (k === 'a') return { kind: 'tool', tool: 'arrow' };
|
|
167
174
|
// Phase 21 — N = sticky Note ('S' is taken by the shell Design-system view
|
|
168
175
|
// + Shift-marquee); T = standalone Text. Both are bare letters the shell
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// annotations-layer — draw-time resize modifiers (constrainDrawBox +
|
|
2
|
+
// applyDrawModifiers). FigJam parity: Shift = 1:1, Alt = from-center, applied
|
|
3
|
+
// while CREATING a shape (not just resizing an existing one). Pure functions.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
type ArrowStroke,
|
|
9
|
+
type EllipseStroke,
|
|
10
|
+
type PenStroke,
|
|
11
|
+
type PolygonStroke,
|
|
12
|
+
type RectStroke,
|
|
13
|
+
type StickyStroke,
|
|
14
|
+
applyDrawModifiers,
|
|
15
|
+
constrainDrawBox,
|
|
16
|
+
} from '../annotations-layer.tsx';
|
|
17
|
+
|
|
18
|
+
const NONE = { shift: false, alt: false };
|
|
19
|
+
|
|
20
|
+
describe('constrainDrawBox', () => {
|
|
21
|
+
test('no modifiers — corner drag from the anchor (back-compat)', () => {
|
|
22
|
+
expect(constrainDrawBox(10, 20, 110, 70, NONE)).toEqual({ x: 10, y: 20, w: 100, h: 50 });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('Shift — locks to a square via the dominant axis, keeps sign', () => {
|
|
26
|
+
expect(constrainDrawBox(0, 0, 120, 200, { shift: true, alt: false })).toEqual({
|
|
27
|
+
x: 0,
|
|
28
|
+
y: 0,
|
|
29
|
+
w: 200,
|
|
30
|
+
h: 200,
|
|
31
|
+
});
|
|
32
|
+
expect(constrainDrawBox(0, 0, -120, -200, { shift: true, alt: false })).toEqual({
|
|
33
|
+
x: 0,
|
|
34
|
+
y: 0,
|
|
35
|
+
w: -200,
|
|
36
|
+
h: -200,
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('Alt — grows from the anchor as center', () => {
|
|
41
|
+
const b = constrainDrawBox(50, 50, 120, 80, { shift: false, alt: true });
|
|
42
|
+
expect(b).toEqual({ x: -20, y: 20, w: 140, h: 60 });
|
|
43
|
+
expect(b.x + b.w / 2).toBe(50); // center invariant
|
|
44
|
+
expect(b.y + b.h / 2).toBe(50);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('Shift+Alt — centered square', () => {
|
|
48
|
+
expect(constrainDrawBox(0, 0, 120, 200, { shift: true, alt: true })).toEqual({
|
|
49
|
+
x: -200,
|
|
50
|
+
y: -200,
|
|
51
|
+
w: 400,
|
|
52
|
+
h: 400,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('applyDrawModifiers / rect + polygon', () => {
|
|
58
|
+
const rect: RectStroke = {
|
|
59
|
+
id: 'r',
|
|
60
|
+
tool: 'rect',
|
|
61
|
+
color: '#000',
|
|
62
|
+
width: 2,
|
|
63
|
+
x: 0,
|
|
64
|
+
y: 0,
|
|
65
|
+
w: 0,
|
|
66
|
+
h: 0,
|
|
67
|
+
};
|
|
68
|
+
const poly: PolygonStroke = {
|
|
69
|
+
id: 'p',
|
|
70
|
+
tool: 'polygon',
|
|
71
|
+
shape: 'triangle',
|
|
72
|
+
color: '#000',
|
|
73
|
+
width: 2,
|
|
74
|
+
x: 0,
|
|
75
|
+
y: 0,
|
|
76
|
+
w: 0,
|
|
77
|
+
h: 0,
|
|
78
|
+
};
|
|
79
|
+
test('rect no-mod corner drag', () => {
|
|
80
|
+
expect(applyDrawModifiers(rect, { x: 0, y: 0 }, 100, 50, NONE)).toMatchObject({
|
|
81
|
+
x: 0,
|
|
82
|
+
y: 0,
|
|
83
|
+
w: 100,
|
|
84
|
+
h: 50,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
test('rect Shift → square', () => {
|
|
88
|
+
expect(
|
|
89
|
+
applyDrawModifiers(rect, { x: 0, y: 0 }, 100, 50, { shift: true, alt: false })
|
|
90
|
+
).toMatchObject({ w: 100, h: 100 });
|
|
91
|
+
});
|
|
92
|
+
test('polygon Alt → centered, preserves shape discriminant', () => {
|
|
93
|
+
const out = applyDrawModifiers(poly, { x: 50, y: 50 }, 120, 80, {
|
|
94
|
+
shift: false,
|
|
95
|
+
alt: true,
|
|
96
|
+
}) as PolygonStroke;
|
|
97
|
+
expect(out).toMatchObject({ x: -20, y: 20, w: 140, h: 60, shape: 'triangle' });
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('applyDrawModifiers / ellipse', () => {
|
|
102
|
+
const ell: EllipseStroke = {
|
|
103
|
+
id: 'e',
|
|
104
|
+
tool: 'ellipse',
|
|
105
|
+
color: '#000',
|
|
106
|
+
width: 2,
|
|
107
|
+
cx: 0,
|
|
108
|
+
cy: 0,
|
|
109
|
+
rx: 0,
|
|
110
|
+
ry: 0,
|
|
111
|
+
};
|
|
112
|
+
test('no-mod — corner drag (bbox 0..100 × 0..50)', () => {
|
|
113
|
+
expect(applyDrawModifiers(ell, { x: 0, y: 0 }, 100, 50, NONE)).toMatchObject({
|
|
114
|
+
cx: 50,
|
|
115
|
+
cy: 25,
|
|
116
|
+
rx: 50,
|
|
117
|
+
ry: 25,
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
test('Alt — centered on the anchor', () => {
|
|
121
|
+
expect(
|
|
122
|
+
applyDrawModifiers(ell, { x: 50, y: 50 }, 120, 80, { shift: false, alt: true })
|
|
123
|
+
).toMatchObject({ cx: 50, cy: 50, rx: 70, ry: 30 });
|
|
124
|
+
});
|
|
125
|
+
test('Shift — circle (rx === ry)', () => {
|
|
126
|
+
const out = applyDrawModifiers(ell, { x: 0, y: 0 }, 100, 40, {
|
|
127
|
+
shift: true,
|
|
128
|
+
alt: false,
|
|
129
|
+
}) as EllipseStroke;
|
|
130
|
+
expect(out.rx).toBe(out.ry);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('applyDrawModifiers / sticky — always square', () => {
|
|
135
|
+
const sticky: StickyStroke = {
|
|
136
|
+
id: 's',
|
|
137
|
+
tool: 'sticky',
|
|
138
|
+
color: '#fce8a6',
|
|
139
|
+
x: 0,
|
|
140
|
+
y: 0,
|
|
141
|
+
w: 0,
|
|
142
|
+
h: 0,
|
|
143
|
+
text: '',
|
|
144
|
+
fontSize: 16,
|
|
145
|
+
};
|
|
146
|
+
test('no-mod still square (larger axis)', () => {
|
|
147
|
+
expect(applyDrawModifiers(sticky, { x: 0, y: 0 }, 150, 120, NONE)).toMatchObject({
|
|
148
|
+
w: 150,
|
|
149
|
+
h: 150,
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
test('Alt — centered square', () => {
|
|
153
|
+
expect(
|
|
154
|
+
applyDrawModifiers(sticky, { x: 0, y: 0 }, 150, 120, { shift: false, alt: true })
|
|
155
|
+
).toMatchObject({ x: -150, y: -150, w: 300, h: 300 });
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('applyDrawModifiers / arrow', () => {
|
|
160
|
+
const arrow: ArrowStroke = {
|
|
161
|
+
id: 'a',
|
|
162
|
+
tool: 'arrow',
|
|
163
|
+
color: '#000',
|
|
164
|
+
width: 2,
|
|
165
|
+
x1: 0,
|
|
166
|
+
y1: 0,
|
|
167
|
+
x2: 0,
|
|
168
|
+
y2: 0,
|
|
169
|
+
};
|
|
170
|
+
test('no-mod — start fixed at anchor, end follows cursor', () => {
|
|
171
|
+
expect(applyDrawModifiers(arrow, { x: 0, y: 0 }, 100, 40, NONE)).toMatchObject({
|
|
172
|
+
x1: 0,
|
|
173
|
+
y1: 0,
|
|
174
|
+
x2: 100,
|
|
175
|
+
y2: 40,
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
test('Alt — anchor is the midpoint, start mirrors the end', () => {
|
|
179
|
+
expect(
|
|
180
|
+
applyDrawModifiers(arrow, { x: 0, y: 0 }, 100, 40, { shift: false, alt: true })
|
|
181
|
+
).toMatchObject({ x1: -100, y1: -40, x2: 100, y2: 40 });
|
|
182
|
+
});
|
|
183
|
+
test('Shift — end snaps to a 45° shaft', () => {
|
|
184
|
+
const out = applyDrawModifiers(arrow, { x: 0, y: 0 }, 50, 60, {
|
|
185
|
+
shift: true,
|
|
186
|
+
alt: false,
|
|
187
|
+
}) as ArrowStroke;
|
|
188
|
+
expect(out.x2).toBeCloseTo(out.y2, 6);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('applyDrawModifiers / pen — pass-through', () => {
|
|
193
|
+
const pen: PenStroke = {
|
|
194
|
+
id: 'pen',
|
|
195
|
+
tool: 'pen',
|
|
196
|
+
color: '#000',
|
|
197
|
+
width: 2,
|
|
198
|
+
points: [
|
|
199
|
+
[0, 0],
|
|
200
|
+
[5, 5],
|
|
201
|
+
],
|
|
202
|
+
};
|
|
203
|
+
test('pen is returned unchanged (no box constraint)', () => {
|
|
204
|
+
expect(applyDrawModifiers(pen, { x: 0, y: 0 }, 99, 99, { shift: true, alt: true })).toBe(pen);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type ArrowStroke,
|
|
14
14
|
type EllipseStroke,
|
|
15
15
|
type PenStroke,
|
|
16
|
+
type PolygonStroke,
|
|
16
17
|
type RectStroke,
|
|
17
18
|
STICKY_PALETTE,
|
|
18
19
|
type StickyStroke,
|
|
@@ -20,6 +21,8 @@ import {
|
|
|
20
21
|
type TextStroke,
|
|
21
22
|
arrowHeadPoints,
|
|
22
23
|
penPathD,
|
|
24
|
+
polygonPoints,
|
|
25
|
+
polygonVertices,
|
|
23
26
|
rid,
|
|
24
27
|
strokeBBox,
|
|
25
28
|
strokeHitTest,
|
|
@@ -498,9 +501,9 @@ describe('annotations-layer / Phase 21 sticky serialization', () => {
|
|
|
498
501
|
expect(svg).toContain('>approve copy?</text>');
|
|
499
502
|
});
|
|
500
503
|
|
|
501
|
-
test('sticky default color is the yellow paper tint (slot 0)', () => {
|
|
502
|
-
expect(STICKY_PALETTE[0]).toBe('#
|
|
503
|
-
expect(STICKY_PALETTE).toHaveLength(
|
|
504
|
+
test('sticky default color is the muted yellow paper tint (slot 0); 10 muted tints (Phase 24)', () => {
|
|
505
|
+
expect(STICKY_PALETTE[0]).toBe('#fce8a6');
|
|
506
|
+
expect(STICKY_PALETTE).toHaveLength(10);
|
|
504
507
|
});
|
|
505
508
|
|
|
506
509
|
test('sticky body text is HTML-escaped (no tag injection)', () => {
|
|
@@ -692,3 +695,80 @@ describe('annotations-layer / Phase 21 sticky + standalone-text geometry', () =>
|
|
|
692
695
|
expect(strokeHitTest(anchored, 2, 2, 4)).toBe(false);
|
|
693
696
|
});
|
|
694
697
|
});
|
|
698
|
+
|
|
699
|
+
describe('annotations-layer / Phase 24 polygon geometry', () => {
|
|
700
|
+
test('polygonVertices span the full bbox for every shape', () => {
|
|
701
|
+
for (const shape of ['diamond', 'triangle', 'triangle-down'] as const) {
|
|
702
|
+
const pts = polygonVertices(shape, 10, 20, 80, 60);
|
|
703
|
+
const xs = pts.map((p) => p[0]);
|
|
704
|
+
const ys = pts.map((p) => p[1]);
|
|
705
|
+
expect(Math.min(...xs)).toBeCloseTo(10, 4);
|
|
706
|
+
expect(Math.max(...xs)).toBeCloseTo(90, 4);
|
|
707
|
+
expect(Math.min(...ys)).toBeCloseTo(20, 4);
|
|
708
|
+
expect(Math.max(...ys)).toBeCloseTo(80, 4);
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
test('diamond has 4 vertices; triangles have 3', () => {
|
|
713
|
+
expect(polygonVertices('diamond', 0, 0, 10, 10)).toHaveLength(4);
|
|
714
|
+
expect(polygonVertices('triangle', 0, 0, 10, 10)).toHaveLength(3);
|
|
715
|
+
expect(polygonVertices('triangle-down', 0, 0, 10, 10)).toHaveLength(3);
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
test('polygonPoints serializes vertices as an SVG points string', () => {
|
|
719
|
+
expect(polygonPoints('diamond', 0, 0, 10, 10)).toBe('5,0 10,5 5,10 0,5');
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
test('polygon serializes as <polygon data-tool="polygon" data-shape=...>', () => {
|
|
723
|
+
const p: PolygonStroke = {
|
|
724
|
+
id: 'pg',
|
|
725
|
+
tool: 'polygon',
|
|
726
|
+
shape: 'triangle',
|
|
727
|
+
color: '#e5484d',
|
|
728
|
+
width: 2,
|
|
729
|
+
x: 0,
|
|
730
|
+
y: 0,
|
|
731
|
+
w: 40,
|
|
732
|
+
h: 40,
|
|
733
|
+
};
|
|
734
|
+
const svg = strokesToSvg([p]);
|
|
735
|
+
expect(svg).toContain('data-tool="polygon"');
|
|
736
|
+
expect(svg).toContain('data-shape="triangle"');
|
|
737
|
+
expect(svg).toContain('points="20,0 40,40 0,40"');
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
test('polygon bbox is its normalized extent', () => {
|
|
741
|
+
const p: PolygonStroke = {
|
|
742
|
+
id: 'pg',
|
|
743
|
+
tool: 'polygon',
|
|
744
|
+
shape: 'diamond',
|
|
745
|
+
color: '#000',
|
|
746
|
+
width: 2,
|
|
747
|
+
x: 100,
|
|
748
|
+
y: 100,
|
|
749
|
+
w: -40,
|
|
750
|
+
h: -30,
|
|
751
|
+
};
|
|
752
|
+
expect(strokeBBox(p)).toEqual({ x: 60, y: 70, w: 40, h: 30 });
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
test('filled diamond hit-tests inside; stroke-only hits the edge not the centre', () => {
|
|
756
|
+
const filled: PolygonStroke = {
|
|
757
|
+
id: 'd',
|
|
758
|
+
tool: 'polygon',
|
|
759
|
+
shape: 'diamond',
|
|
760
|
+
color: '#000',
|
|
761
|
+
width: 2,
|
|
762
|
+
x: 0,
|
|
763
|
+
y: 0,
|
|
764
|
+
w: 100,
|
|
765
|
+
h: 100,
|
|
766
|
+
fill: '#eee',
|
|
767
|
+
};
|
|
768
|
+
expect(strokeHitTest(filled, 50, 50, 4)).toBe(true); // centre, filled
|
|
769
|
+
expect(strokeHitTest(filled, 5, 5, 4)).toBe(false); // outside the diamond (in a bbox corner)
|
|
770
|
+
const outline: PolygonStroke = { ...filled, fill: null };
|
|
771
|
+
expect(strokeHitTest(outline, 50, 50, 4)).toBe(false); // centre, not filled
|
|
772
|
+
expect(strokeHitTest(outline, 25, 25, 4)).toBe(true); // on the NW edge midpoint line
|
|
773
|
+
});
|
|
774
|
+
});
|