@1agh/maude 0.19.1 → 0.20.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 +9 -9
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +112 -38
- package/plugins/design/dev-server/annotations-layer.tsx +204 -95
- package/plugins/design/dev-server/artboard-marquee.tsx +8 -4
- package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
- package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
- package/plugins/design/dev-server/canvas-lib.tsx +506 -30
- package/plugins/design/dev-server/canvas-shell.tsx +352 -20
- package/plugins/design/dev-server/client/hmr.mjs +28 -0
- package/plugins/design/dev-server/commands/annotation-strokes-command.ts +127 -0
- package/plugins/design/dev-server/commands/equal-spacing-command.ts +28 -0
- package/plugins/design/dev-server/commands/move-artboards-command.ts +158 -0
- package/plugins/design/dev-server/comments-overlay.tsx +12 -4
- package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
- package/plugins/design/dev-server/dist/client.bundle.js +3 -3
- package/plugins/design/dev-server/dist/runtime/motion.js +165 -0
- package/plugins/design/dev-server/dist/runtime/motion_react.js +409 -0
- package/plugins/design/dev-server/equal-spacing-detector.ts +98 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
- package/plugins/design/dev-server/handoff.ts +24 -0
- package/plugins/design/dev-server/http.ts +27 -0
- package/plugins/design/dev-server/input-router.tsx +52 -2
- package/plugins/design/dev-server/marquee-overlay.tsx +282 -0
- package/plugins/design/dev-server/runtime-bundle.ts +7 -0
- package/plugins/design/dev-server/test/annotation-strokes-command.test.ts +149 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +44 -0
- package/plugins/design/dev-server/test/canvas-lib-motion.test.ts +131 -0
- package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
- package/plugins/design/dev-server/test/input-router.test.ts +87 -1
- package/plugins/design/dev-server/test/marquee-overlay.test.ts +94 -0
- package/plugins/design/dev-server/test/move-artboards-command.test.ts +108 -0
- package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
- package/plugins/design/dev-server/test/use-cursor-modifiers.test.ts +59 -0
- package/plugins/design/dev-server/test/use-keyboard-discipline.test.ts +27 -0
- package/plugins/design/dev-server/test/use-undo-stack.test.tsx +193 -0
- package/plugins/design/dev-server/undo-hud.tsx +95 -0
- package/plugins/design/dev-server/undo-stack.ts +240 -0
- package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
- package/plugins/design/dev-server/use-cursor-modifiers.tsx +122 -0
- package/plugins/design/dev-server/use-keyboard-discipline.tsx +125 -0
- package/plugins/design/dev-server/use-undo-stack.tsx +355 -0
- package/plugins/design/templates/_shell.html +17 -6
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +245 -0
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +2 -2
- package/plugins/design/templates/design-system-inspiration/core/preview/_components.css.tpl +129 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +63 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/motion.css.tpl +106 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/motion.tsx.tpl +208 -0
- /package/plugins/design/templates/design-system-inspiration/core/preview/{motion.html → .archive/motion.html} +0 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file commands/annotation-strokes-command.ts — undo entry for annotation
|
|
3
|
+
* stroke add / erase / batch-translate.
|
|
4
|
+
* @scope plugins/design/dev-server/commands/annotation-strokes-command.ts
|
|
5
|
+
* @purpose Records a `Stroke[]` pair (before/after) and routes both
|
|
6
|
+
* directions through a single `putFn`. The server endpoint is
|
|
7
|
+
* `PUT /_api/annotations` which replaces the entire SVG; we
|
|
8
|
+
* match that shape rather than diffing.
|
|
9
|
+
*
|
|
10
|
+
* Per DDR-050 rev 2 the runtime command is rebuilt per iframe
|
|
11
|
+
* mount from a serializable `CommandRecord` so the stack
|
|
12
|
+
* survives canvas switches. The `putFn` here also updates the
|
|
13
|
+
* iframe's local React strokes state (see annotations-layer
|
|
14
|
+
* `putStrokes`) so undo/redo visibly refresh, not just PUT.
|
|
15
|
+
*
|
|
16
|
+
* Why per-stroke commands (not coalesced into a 300 ms window):
|
|
17
|
+
* matches Figma/FigJam — Cmd+Z reverts the most recent tap or
|
|
18
|
+
* pen stroke individually. Coalescing into windows led to a
|
|
19
|
+
* matoucí "why did Cmd+Z erase two of my last lines?" UX.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { Stroke } from '../annotations-layer.tsx';
|
|
23
|
+
import type { CommandRecord, CommandSinks, EditCommand } from '../undo-stack.ts';
|
|
24
|
+
import { registerCommand } from '../undo-stack.ts';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Push-once callable that submits a full `Stroke[]` to the server (or its
|
|
28
|
+
* test stub) AND updates the iframe's local strokes state. The
|
|
29
|
+
* annotations-layer `putStrokes` is the production binding.
|
|
30
|
+
*/
|
|
31
|
+
export type StrokesPutFn = (next: readonly Stroke[]) => void | Promise<void>;
|
|
32
|
+
|
|
33
|
+
export interface AnnotationStrokesPayload {
|
|
34
|
+
before: readonly Stroke[];
|
|
35
|
+
after: readonly Stroke[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const ANNOTATION_STROKES_KIND = 'annotation-strokes';
|
|
39
|
+
|
|
40
|
+
export interface AnnotationStrokesCommandInit {
|
|
41
|
+
before: readonly Stroke[];
|
|
42
|
+
after: readonly Stroke[];
|
|
43
|
+
putFn: StrokesPutFn;
|
|
44
|
+
/** Optional label override (eraser → "erase 1 stroke"). */
|
|
45
|
+
label?: string;
|
|
46
|
+
/** Telemetry kind. */
|
|
47
|
+
kind?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createAnnotationStrokesCommand(init: AnnotationStrokesCommandInit): EditCommand {
|
|
51
|
+
const { putFn } = init;
|
|
52
|
+
const before = init.before.map(cloneStroke);
|
|
53
|
+
const after = init.after.map(cloneStroke);
|
|
54
|
+
const label = init.label ?? defaultLabel(before, after);
|
|
55
|
+
const kind = init.kind ?? ANNOTATION_STROKES_KIND;
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
kind,
|
|
59
|
+
label,
|
|
60
|
+
async do() {
|
|
61
|
+
await putFn(after.map(cloneStroke));
|
|
62
|
+
},
|
|
63
|
+
async undo() {
|
|
64
|
+
await putFn(before.map(cloneStroke));
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Build a persistable record from the same inputs as `createAnnotationStrokesCommand`.
|
|
71
|
+
* Used by the consumer alongside the EditCommand so the runtime side-effect
|
|
72
|
+
* and the stored shape share one snapshot.
|
|
73
|
+
*/
|
|
74
|
+
export function buildAnnotationStrokesRecord(opts: {
|
|
75
|
+
before: readonly Stroke[];
|
|
76
|
+
after: readonly Stroke[];
|
|
77
|
+
label?: string;
|
|
78
|
+
}): CommandRecord<AnnotationStrokesPayload> {
|
|
79
|
+
const before = opts.before.map(cloneStroke);
|
|
80
|
+
const after = opts.after.map(cloneStroke);
|
|
81
|
+
return {
|
|
82
|
+
kind: ANNOTATION_STROKES_KIND,
|
|
83
|
+
label: opts.label ?? defaultLabel(before, after),
|
|
84
|
+
payload: { before, after },
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
89
|
+
// Registry
|
|
90
|
+
|
|
91
|
+
registerCommand<AnnotationStrokesPayload>(ANNOTATION_STROKES_KIND, (record, sinks) => {
|
|
92
|
+
const putFn = sinks.strokesPutFn as StrokesPutFn | undefined;
|
|
93
|
+
if (!putFn) return null;
|
|
94
|
+
return createAnnotationStrokesCommand({
|
|
95
|
+
before: record.payload.before,
|
|
96
|
+
after: record.payload.after,
|
|
97
|
+
putFn,
|
|
98
|
+
label: record.label,
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
103
|
+
// Internals
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Structured clone — works for every Stroke variant because they're plain
|
|
107
|
+
* JSON-shaped (no Date / Map / DOM nodes). The pen `points: WorldPoint[]`
|
|
108
|
+
* array is nested-cloned too so callers can't poison the snapshot by
|
|
109
|
+
* mutating the source.
|
|
110
|
+
*/
|
|
111
|
+
function cloneStroke<T extends Stroke>(s: T): T {
|
|
112
|
+
// structuredClone is available on every runtime we ship to (Bun, modern
|
|
113
|
+
// browsers, Node 17+). Fall back to JSON for the rare older harness.
|
|
114
|
+
if (typeof structuredClone === 'function') return structuredClone(s);
|
|
115
|
+
return JSON.parse(JSON.stringify(s)) as T;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function defaultLabel(before: readonly Stroke[], after: readonly Stroke[]): string {
|
|
119
|
+
const added = after.length - before.length;
|
|
120
|
+
if (added > 0) return `add ${added} stroke${added === 1 ? '' : 's'}`;
|
|
121
|
+
if (added < 0) {
|
|
122
|
+
const erased = -added;
|
|
123
|
+
return `erase ${erased} stroke${erased === 1 ? '' : 's'}`;
|
|
124
|
+
}
|
|
125
|
+
// Same count — could be translate / edit / fill-change.
|
|
126
|
+
return `edit ${after.length} stroke${after.length === 1 ? '' : 's'}`;
|
|
127
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file commands/equal-spacing-command.ts — undo label helpers for
|
|
3
|
+
* equal-spacing / align gestures.
|
|
4
|
+
* @scope plugins/design/dev-server/commands/equal-spacing-command.ts
|
|
5
|
+
* @purpose Distribute + align both call `dragBus.commitPositions(moved)`,
|
|
6
|
+
* which wraps the move in a generic MoveArtboardsCommand. Per
|
|
7
|
+
* DDR-050 the underlying command type is shared (the inverse
|
|
8
|
+
* payload is identical — a layout snapshot pair) and only the
|
|
9
|
+
* HUD label differs. These helpers compute the labels.
|
|
10
|
+
*
|
|
11
|
+
* Why not a separate Command class? The wrapping logic would
|
|
12
|
+
* duplicate `before`/`after`/`patchFn` + add an indirection.
|
|
13
|
+
* A label is a one-line string; a factory is overkill.
|
|
14
|
+
* Keeping `MoveArtboardsCommand` as the only artboard-layout
|
|
15
|
+
* command keeps the future Yjs swap (Phase 8) one-for-one.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type AlignMode = 'left' | 'right' | 'center-x' | 'top' | 'bottom' | 'center-y';
|
|
19
|
+
|
|
20
|
+
/** "equal-space 3 artboards" — N must be ≥ 3 (distribute requires it). */
|
|
21
|
+
export function equalSpacingLabel(n: number): string {
|
|
22
|
+
return `equal-space ${n} artboard${n === 1 ? '' : 's'}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** "align left 4 artboards" — N must be ≥ 2 (align requires it). */
|
|
26
|
+
export function alignLabel(mode: AlignMode, n: number): string {
|
|
27
|
+
return `align ${mode} ${n} artboard${n === 1 ? '' : 's'}`;
|
|
28
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file commands/move-artboards-command.ts — undo entry for artboard moves
|
|
3
|
+
* @scope plugins/design/dev-server/commands/move-artboards-command.ts
|
|
4
|
+
* @purpose Reversible record of an artboard-layout PATCH. Pairs the full
|
|
5
|
+
* `before` and `after` layout snapshots with an injected
|
|
6
|
+
* `patchFn` (in production, `applyArtboardLayout`; in tests, a
|
|
7
|
+
* spy). The command is rebuilt per iframe mount from a
|
|
8
|
+
* `CommandRecord` so the stack survives canvas switches
|
|
9
|
+
* (DDR-050 rev 2).
|
|
10
|
+
*
|
|
11
|
+
* Why full snapshots, not a sparse diff. The server-side endpoint
|
|
12
|
+
* (`PATCH /_api/canvas-meta`, see api.ts:523–528) shallow-merges patch.layout
|
|
13
|
+
* over the existing meta. A sparse `{ artboards: [movedOnly] }` would drop
|
|
14
|
+
* every unchanged rect on its head. Storing the full array is simpler and
|
|
15
|
+
* resilient — the external-edit invalidation (DDR-050 rule 6) clears the
|
|
16
|
+
* stack whenever the file changes outside our PATCH so we never restore a
|
|
17
|
+
* stale layout.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { CommandRecord, CommandSinks, EditCommand } from '../undo-stack.ts';
|
|
21
|
+
import { registerCommand } from '../undo-stack.ts';
|
|
22
|
+
|
|
23
|
+
export interface ArtboardLayoutEntry {
|
|
24
|
+
id: string;
|
|
25
|
+
x: number;
|
|
26
|
+
y: number;
|
|
27
|
+
/** Width — JSX-authoritative; carried through for shape-completeness. */
|
|
28
|
+
w?: number;
|
|
29
|
+
h?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Signature compatible with canvas-lib's `applyArtboardLayout(layout)`.
|
|
34
|
+
* Tests pass a spy; production wiring passes the real React-state-+-PATCH
|
|
35
|
+
* applier.
|
|
36
|
+
*/
|
|
37
|
+
export type LayoutPatchFn = (layout: ArtboardLayoutEntry[]) => void | Promise<void>;
|
|
38
|
+
|
|
39
|
+
export interface MoveArtboardsPayload {
|
|
40
|
+
before: readonly ArtboardLayoutEntry[];
|
|
41
|
+
after: readonly ArtboardLayoutEntry[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Convenience constant — keeps spelling consistent across files. */
|
|
45
|
+
export const MOVE_ARTBOARDS_KIND = 'move-artboards';
|
|
46
|
+
|
|
47
|
+
export interface MoveArtboardsCommandInit {
|
|
48
|
+
before: readonly ArtboardLayoutEntry[];
|
|
49
|
+
after: readonly ArtboardLayoutEntry[];
|
|
50
|
+
patchFn: LayoutPatchFn;
|
|
51
|
+
/** Optional label override (equal-spacing wraps with its own copy). */
|
|
52
|
+
label?: string;
|
|
53
|
+
/** Telemetry kind. Defaults to `MOVE_ARTBOARDS_KIND`. */
|
|
54
|
+
kind?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createMoveArtboardsCommand(init: MoveArtboardsCommandInit): EditCommand {
|
|
58
|
+
const { before, after, patchFn } = init;
|
|
59
|
+
// Snapshot once — mutating the source arrays later cannot poison the command.
|
|
60
|
+
const beforeSnapshot = before.map(cloneEntry);
|
|
61
|
+
const afterSnapshot = after.map(cloneEntry);
|
|
62
|
+
const movedCount = countMoved(beforeSnapshot, afterSnapshot);
|
|
63
|
+
const label = init.label ?? `move ${movedCount} artboard${movedCount === 1 ? '' : 's'}`;
|
|
64
|
+
const kind = init.kind ?? MOVE_ARTBOARDS_KIND;
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
kind,
|
|
68
|
+
label,
|
|
69
|
+
async do() {
|
|
70
|
+
await patchFn(afterSnapshot.map(cloneEntry));
|
|
71
|
+
},
|
|
72
|
+
async undo() {
|
|
73
|
+
await patchFn(beforeSnapshot.map(cloneEntry));
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build a persistable record from the same inputs. Use this together with
|
|
80
|
+
* the EditCommand so the runtime side-effect AND the persisted shape share
|
|
81
|
+
* one snapshot.
|
|
82
|
+
*/
|
|
83
|
+
export function buildMoveArtboardsRecord(opts: {
|
|
84
|
+
before: readonly ArtboardLayoutEntry[];
|
|
85
|
+
after: readonly ArtboardLayoutEntry[];
|
|
86
|
+
label?: string;
|
|
87
|
+
}): CommandRecord<MoveArtboardsPayload> {
|
|
88
|
+
const before = opts.before.map(cloneEntry);
|
|
89
|
+
const after = opts.after.map(cloneEntry);
|
|
90
|
+
const moved = countMoved(before, after);
|
|
91
|
+
const label = opts.label ?? `move ${moved} artboard${moved === 1 ? '' : 's'}`;
|
|
92
|
+
return { kind: MOVE_ARTBOARDS_KIND, label, payload: { before, after } };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Diff helper — returns `null` when `before` and `after` describe the same
|
|
97
|
+
* layout (call sites use this to skip pushing a no-op drag onto the stack).
|
|
98
|
+
* Compares positions only; size diffs are ignored (size is JSX-authoritative
|
|
99
|
+
* per DDR-027, position is the only mutable channel).
|
|
100
|
+
*/
|
|
101
|
+
export function diffLayoutPositions(
|
|
102
|
+
before: readonly ArtboardLayoutEntry[],
|
|
103
|
+
after: readonly ArtboardLayoutEntry[]
|
|
104
|
+
): { changed: number } | null {
|
|
105
|
+
if (before.length !== after.length) return { changed: Math.max(before.length, after.length) };
|
|
106
|
+
const byId = new Map<string, ArtboardLayoutEntry>();
|
|
107
|
+
for (const r of before) byId.set(r.id, r);
|
|
108
|
+
let changed = 0;
|
|
109
|
+
for (const r of after) {
|
|
110
|
+
const prev = byId.get(r.id);
|
|
111
|
+
if (!prev || prev.x !== r.x || prev.y !== r.y) changed++;
|
|
112
|
+
}
|
|
113
|
+
if (changed === 0) return null;
|
|
114
|
+
return { changed };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
118
|
+
// Registry — rebuild EditCommand from a persisted CommandRecord + current
|
|
119
|
+
// iframe's sinks. Runs once on module load (top-level side-effect intentional).
|
|
120
|
+
|
|
121
|
+
registerCommand<MoveArtboardsPayload>(MOVE_ARTBOARDS_KIND, (record, sinks) => {
|
|
122
|
+
const patchFn = sinks.layoutPatchFn as LayoutPatchFn | undefined;
|
|
123
|
+
if (!patchFn) return null;
|
|
124
|
+
return createMoveArtboardsCommand({
|
|
125
|
+
before: record.payload.before,
|
|
126
|
+
after: record.payload.after,
|
|
127
|
+
patchFn,
|
|
128
|
+
label: record.label,
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
133
|
+
// Internals
|
|
134
|
+
|
|
135
|
+
function cloneEntry(r: ArtboardLayoutEntry): ArtboardLayoutEntry {
|
|
136
|
+
const out: ArtboardLayoutEntry = { id: r.id, x: r.x, y: r.y };
|
|
137
|
+
if (typeof r.w === 'number') out.w = r.w;
|
|
138
|
+
if (typeof r.h === 'number') out.h = r.h;
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function countMoved(
|
|
143
|
+
before: readonly ArtboardLayoutEntry[],
|
|
144
|
+
after: readonly ArtboardLayoutEntry[]
|
|
145
|
+
): number {
|
|
146
|
+
const byId = new Map<string, ArtboardLayoutEntry>();
|
|
147
|
+
for (const r of before) byId.set(r.id, r);
|
|
148
|
+
let n = 0;
|
|
149
|
+
for (const r of after) {
|
|
150
|
+
const prev = byId.get(r.id);
|
|
151
|
+
if (!prev) {
|
|
152
|
+
n++;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (prev.x !== r.x || prev.y !== r.y) n++;
|
|
156
|
+
}
|
|
157
|
+
return n;
|
|
158
|
+
}
|
|
@@ -1143,14 +1143,22 @@ function computeThreadAnchor(comment: OverlayComment): { x: number; y: number }
|
|
|
1143
1143
|
}
|
|
1144
1144
|
|
|
1145
1145
|
function computeAnchor(state: ComposerState): { x: number; y: number } {
|
|
1146
|
-
//
|
|
1147
|
-
//
|
|
1146
|
+
// G4 — anchor to the cursor click point first. Earlier versions anchored to
|
|
1147
|
+
// the selected element's bottom-left, which landed the composer flush in
|
|
1148
|
+
// the corner regardless of where the user clicked — surprising for the
|
|
1149
|
+
// common case of "I clicked the middle of an element, expecting the
|
|
1150
|
+
// composer to appear near my cursor". The element-rect path remains as a
|
|
1151
|
+
// fallback for entry points that don't carry a cursor (e.g. opening the
|
|
1152
|
+
// composer from a contextual toolbar button — those should set clientX/Y
|
|
1153
|
+
// to a sensible anchor before dispatching).
|
|
1154
|
+
if (state.clientX || state.clientY) {
|
|
1155
|
+
return { x: state.clientX, y: state.clientY + 8 };
|
|
1156
|
+
}
|
|
1148
1157
|
if (state.selection.selector) {
|
|
1149
1158
|
const rect = screenRectFor(state.selection.selector);
|
|
1150
1159
|
if (rect) {
|
|
1151
1160
|
return { x: rect.x, y: rect.y + rect.h + 8 };
|
|
1152
1161
|
}
|
|
1153
1162
|
}
|
|
1154
|
-
|
|
1155
|
-
return { x: state.clientX, y: state.clientY + 8 };
|
|
1163
|
+
return { x: 16, y: 16 };
|
|
1156
1164
|
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file contextual-toolbar.tsx — T30 (Wave 3)
|
|
3
|
+
* @scope plugins/design/dev-server/contextual-toolbar.tsx
|
|
4
|
+
* @purpose Selection-anchored floating chrome for ELEMENT selections
|
|
5
|
+
* (cd-id entries in the selection set). Mirrors the
|
|
6
|
+
* MultiArtboardToolbar pattern but scopes to user content inside
|
|
7
|
+
* artboards. Actions surface the existing right-click handlers
|
|
8
|
+
* one click closer:
|
|
9
|
+
*
|
|
10
|
+
* • Copy CSS path
|
|
11
|
+
* • Copy data-cd-id
|
|
12
|
+
* • Add comment
|
|
13
|
+
*
|
|
14
|
+
* Hides when:
|
|
15
|
+
* - no selection,
|
|
16
|
+
* - selection contains only artboards (MultiArtboardToolbar
|
|
17
|
+
* covers that),
|
|
18
|
+
* - selection contains only annotations (their own toolbar
|
|
19
|
+
* fires from `annotations-context-toolbar.tsx`).
|
|
20
|
+
*
|
|
21
|
+
* Anchored 14 px above the element selection union bbox; flips
|
|
22
|
+
* below when bbox top is < 60 px from the viewport edge — same
|
|
23
|
+
* contract as MultiArtboardToolbar so the two never collide.
|
|
24
|
+
*
|
|
25
|
+
* Selection-change tweens are NOT animated here in v1; the
|
|
26
|
+
* instant reposition is acceptable given the toolbar fades in
|
|
27
|
+
* only when stickily relevant. A future polish wave can add
|
|
28
|
+
* the 180 ms position tween per the plan note.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { useEffect, useRef } from 'react';
|
|
32
|
+
|
|
33
|
+
import { useSelectionSet } from './use-selection-set.tsx';
|
|
34
|
+
|
|
35
|
+
const CTX_TOOLBAR_CSS = `
|
|
36
|
+
.dc-elem-ctx-tb {
|
|
37
|
+
position: fixed;
|
|
38
|
+
pointer-events: auto;
|
|
39
|
+
z-index: 6;
|
|
40
|
+
display: none;
|
|
41
|
+
align-items: stretch;
|
|
42
|
+
gap: 2px;
|
|
43
|
+
padding: 4px;
|
|
44
|
+
background: var(--u-bg-0, var(--bg-0, #ffffff));
|
|
45
|
+
border: 1px solid var(--u-fg-0, #1c1917);
|
|
46
|
+
border-radius: 8px;
|
|
47
|
+
box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
|
|
48
|
+
font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
49
|
+
font-size: 11px;
|
|
50
|
+
letter-spacing: 0.02em;
|
|
51
|
+
color: var(--u-fg-0, #1a1a1a);
|
|
52
|
+
user-select: none;
|
|
53
|
+
opacity: 0;
|
|
54
|
+
transition: opacity 100ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
55
|
+
}
|
|
56
|
+
.dc-elem-ctx-tb[data-on="true"] {
|
|
57
|
+
opacity: 1;
|
|
58
|
+
}
|
|
59
|
+
.dc-elem-ctx-tb button {
|
|
60
|
+
appearance: none;
|
|
61
|
+
background: transparent;
|
|
62
|
+
border: 0;
|
|
63
|
+
border-radius: 6px;
|
|
64
|
+
padding: 4px 10px;
|
|
65
|
+
font: inherit;
|
|
66
|
+
cursor: pointer;
|
|
67
|
+
color: inherit;
|
|
68
|
+
transition: background-color 80ms linear;
|
|
69
|
+
}
|
|
70
|
+
.dc-elem-ctx-tb button:hover {
|
|
71
|
+
background: color-mix(in oklab, var(--accent, #d63b1f) 8%, transparent);
|
|
72
|
+
}
|
|
73
|
+
.dc-elem-ctx-tb .dc-elem-ctx-count {
|
|
74
|
+
padding: 4px 8px 4px 10px;
|
|
75
|
+
color: var(--fg-1, rgba(40,30,20,0.7));
|
|
76
|
+
border-right: 1px solid var(--u-border-subtle, rgba(0,0,0,0.08));
|
|
77
|
+
margin-right: 2px;
|
|
78
|
+
font-variant-numeric: tabular-nums;
|
|
79
|
+
}
|
|
80
|
+
@media (prefers-reduced-motion: reduce) {
|
|
81
|
+
.dc-elem-ctx-tb, .dc-elem-ctx-tb button { transition: none; }
|
|
82
|
+
}
|
|
83
|
+
`.trim();
|
|
84
|
+
|
|
85
|
+
function ensureStyles(): void {
|
|
86
|
+
if (typeof document === 'undefined') return;
|
|
87
|
+
if (document.getElementById('dc-elem-ctx-tb-css')) return;
|
|
88
|
+
const s = document.createElement('style');
|
|
89
|
+
s.id = 'dc-elem-ctx-tb-css';
|
|
90
|
+
s.textContent = CTX_TOOLBAR_CSS;
|
|
91
|
+
document.head.appendChild(s);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function copyText(text: string): void {
|
|
95
|
+
if (typeof navigator === 'undefined' || !navigator.clipboard) return;
|
|
96
|
+
void navigator.clipboard.writeText(text).catch(() => {
|
|
97
|
+
/* clipboard blocked */
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function openComposerForSelection(
|
|
102
|
+
sel: { id?: string; selector: string },
|
|
103
|
+
anchorEl: Element | null
|
|
104
|
+
): void {
|
|
105
|
+
if (typeof document === 'undefined') return;
|
|
106
|
+
// Anchor near the top-right of the targeted element so the composer drops
|
|
107
|
+
// into a clear area next to it. Matches the comment-tool click affordance
|
|
108
|
+
// post-G4 (cursor-anchored composer).
|
|
109
|
+
let clientX = 0;
|
|
110
|
+
let clientY = 0;
|
|
111
|
+
if (anchorEl) {
|
|
112
|
+
const r = (anchorEl as HTMLElement).getBoundingClientRect();
|
|
113
|
+
clientX = r.right - 8;
|
|
114
|
+
clientY = r.top + r.height / 2;
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
document.dispatchEvent(
|
|
118
|
+
new CustomEvent('cm:open-composer', {
|
|
119
|
+
detail: { selection: sel, clientX, clientY },
|
|
120
|
+
})
|
|
121
|
+
);
|
|
122
|
+
} catch {
|
|
123
|
+
/* CustomEvent unsupported — fall through */
|
|
124
|
+
}
|
|
125
|
+
// Back-compat parent post for legacy mocks.
|
|
126
|
+
if (typeof window !== 'undefined') {
|
|
127
|
+
try {
|
|
128
|
+
window.parent.postMessage({ dgn: 'comment-compose', selection: sel }, '*');
|
|
129
|
+
} catch {
|
|
130
|
+
/* parent detached */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function ContextualToolbar() {
|
|
136
|
+
ensureStyles();
|
|
137
|
+
const { selected } = useSelectionSet();
|
|
138
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
139
|
+
const rafRef = useRef<number | null>(null);
|
|
140
|
+
|
|
141
|
+
// Element selections = entries with `id` (data-cd-id) set. Pure artboard
|
|
142
|
+
// selections (artboardId-only, no id) and empty sets fall through.
|
|
143
|
+
const elementSelections = selected.filter((s) => !!s.id);
|
|
144
|
+
const visible = elementSelections.length > 0;
|
|
145
|
+
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
const div = ref.current;
|
|
148
|
+
if (!div) return;
|
|
149
|
+
if (!visible) {
|
|
150
|
+
div.style.display = 'none';
|
|
151
|
+
div.setAttribute('data-on', 'false');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const tick = () => {
|
|
155
|
+
rafRef.current = null;
|
|
156
|
+
let xMin = Number.POSITIVE_INFINITY;
|
|
157
|
+
let yMin = Number.POSITIVE_INFINITY;
|
|
158
|
+
let xMax = Number.NEGATIVE_INFINITY;
|
|
159
|
+
let yMax = Number.NEGATIVE_INFINITY;
|
|
160
|
+
let any = false;
|
|
161
|
+
for (const sel of elementSelections) {
|
|
162
|
+
const node = sel.id
|
|
163
|
+
? document.querySelector(`[data-cd-id="${sel.id}"]`)
|
|
164
|
+
: document.querySelector(sel.selector);
|
|
165
|
+
if (!node) continue;
|
|
166
|
+
const r = (node as HTMLElement).getBoundingClientRect();
|
|
167
|
+
if (r.width === 0 && r.height === 0) continue;
|
|
168
|
+
any = true;
|
|
169
|
+
if (r.left < xMin) xMin = r.left;
|
|
170
|
+
if (r.top < yMin) yMin = r.top;
|
|
171
|
+
if (r.right > xMax) xMax = r.right;
|
|
172
|
+
if (r.bottom > yMax) yMax = r.bottom;
|
|
173
|
+
}
|
|
174
|
+
if (!any) {
|
|
175
|
+
div.style.display = 'none';
|
|
176
|
+
div.setAttribute('data-on', 'false');
|
|
177
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
div.style.display = 'flex';
|
|
181
|
+
const tw = div.offsetWidth || 0;
|
|
182
|
+
const centerX = (xMin + xMax) / 2;
|
|
183
|
+
const top = yMin;
|
|
184
|
+
const gap = 14;
|
|
185
|
+
let anchorY = top - div.offsetHeight - gap;
|
|
186
|
+
if (anchorY < 60) anchorY = yMax + gap;
|
|
187
|
+
div.style.left = `${Math.round(centerX - tw / 2)}px`;
|
|
188
|
+
div.style.top = `${Math.round(anchorY)}px`;
|
|
189
|
+
div.setAttribute('data-on', 'true');
|
|
190
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
191
|
+
};
|
|
192
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
193
|
+
return () => {
|
|
194
|
+
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
|
195
|
+
};
|
|
196
|
+
}, [visible, elementSelections]);
|
|
197
|
+
|
|
198
|
+
if (!visible) {
|
|
199
|
+
return <div ref={ref} className="dc-elem-ctx-tb" aria-hidden="true" />;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// For multi-selection, Copy actions operate on the FIRST element — same
|
|
203
|
+
// convention as the right-click menu (single-target). Future polish: a
|
|
204
|
+
// mini-menu that lets the user pick which selection's CSS to copy.
|
|
205
|
+
const primary = elementSelections[0];
|
|
206
|
+
const count = elementSelections.length;
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<div ref={ref} className="dc-elem-ctx-tb" role="toolbar" aria-label="Element actions">
|
|
210
|
+
<span className="dc-elem-ctx-count">{count === 1 ? '1 element' : `${count} elements`}</span>
|
|
211
|
+
<button
|
|
212
|
+
type="button"
|
|
213
|
+
title="Copy CSS selector"
|
|
214
|
+
onClick={() => primary && copyText(primary.selector)}
|
|
215
|
+
>
|
|
216
|
+
Copy CSS
|
|
217
|
+
</button>
|
|
218
|
+
<button
|
|
219
|
+
type="button"
|
|
220
|
+
title="Copy data-cd-id"
|
|
221
|
+
disabled={!primary?.id}
|
|
222
|
+
onClick={() => primary?.id && copyText(primary.id)}
|
|
223
|
+
>
|
|
224
|
+
Copy ID
|
|
225
|
+
</button>
|
|
226
|
+
<button
|
|
227
|
+
type="button"
|
|
228
|
+
title="Add comment on this element"
|
|
229
|
+
onClick={() => {
|
|
230
|
+
if (!primary) return;
|
|
231
|
+
const node = primary.id
|
|
232
|
+
? document.querySelector(`[data-cd-id="${primary.id}"]`)
|
|
233
|
+
: document.querySelector(primary.selector);
|
|
234
|
+
openComposerForSelection(primary, node);
|
|
235
|
+
}}
|
|
236
|
+
>
|
|
237
|
+
Comment
|
|
238
|
+
</button>
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
}
|