@1agh/maude 0.19.1 → 0.21.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/README.md +7 -0
- package/cli/bin/maude.mjs +5 -1
- package/cli/commands/design-link.test.mjs +207 -0
- package/cli/commands/design.mjs +42 -12
- package/cli/commands/doctor.mjs +350 -0
- package/cli/commands/doctor.test.mjs +185 -0
- package/cli/commands/help.mjs +24 -0
- package/cli/commands/hub.mjs +231 -0
- package/cli/commands/hub.test.mjs +87 -0
- package/cli/lib/config-lint.mjs +141 -0
- package/cli/lib/config-lint.test.mjs +117 -0
- package/cli/lib/design-link.mjs +216 -0
- package/cli/lib/hubs-config.mjs +123 -0
- package/cli/lib/hubs-config.test.mjs +100 -0
- package/cli/lib/preflight.mjs +232 -0
- package/cli/lib/stack-detect.mjs +344 -0
- package/cli/lib/stack-detect.test.mjs +121 -0
- package/package.json +17 -9
- package/plugins/design/dependencies.json +147 -0
- package/plugins/design/dependencies.schema.json +107 -0
- package/plugins/design/dev-server/ai-banner.tsx +188 -0
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +115 -41
- package/plugins/design/dev-server/annotations-layer.tsx +256 -107
- package/plugins/design/dev-server/api.ts +17 -1
- package/plugins/design/dev-server/artboard-marquee.tsx +10 -6
- package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
- package/plugins/design/dev-server/bin/preflight.sh +32 -0
- package/plugins/design/dev-server/bin/runtime-health.sh +169 -0
- package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
- package/plugins/design/dev-server/canvas-lib.tsx +534 -32
- package/plugins/design/dev-server/canvas-shell.tsx +479 -29
- package/plugins/design/dev-server/client/app.jsx +72 -0
- package/plugins/design/dev-server/client/hmr.mjs +28 -0
- package/plugins/design/dev-server/collab/ai-activity.ts +127 -0
- package/plugins/design/dev-server/collab/git-lifecycle.ts +124 -0
- package/plugins/design/dev-server/collab/index.ts +47 -0
- package/plugins/design/dev-server/collab/persistence.ts +123 -0
- package/plugins/design/dev-server/collab/protocol.ts +108 -0
- package/plugins/design/dev-server/collab/registry.ts +110 -0
- package/plugins/design/dev-server/collab/room.ts +215 -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 +41 -4
- package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
- package/plugins/design/dev-server/cursors-overlay.tsx +296 -0
- package/plugins/design/dev-server/dist/client.bundle.js +75 -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/dist/runtime/y-protocols_awareness.js +587 -0
- package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +257 -0
- package/plugins/design/dev-server/dist/runtime/yjs.js +7107 -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/export-dialog.tsx +1 -1
- package/plugins/design/dev-server/handoff.ts +24 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +15 -2
- package/plugins/design/dev-server/http.ts +91 -1
- 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/participants-chrome.tsx +261 -0
- package/plugins/design/dev-server/runtime-bundle.ts +15 -0
- package/plugins/design/dev-server/server.ts +78 -11
- package/plugins/design/dev-server/test/ai-activity.test.ts +113 -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/collab-annotations-bridge.test.ts +55 -0
- package/plugins/design/dev-server/test/collab-bridge.test.ts +81 -0
- package/plugins/design/dev-server/test/collab-loopback.test.ts +63 -0
- package/plugins/design/dev-server/test/collab-protocol.test.ts +146 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +182 -0
- package/plugins/design/dev-server/test/collab-stress.test.ts +121 -0
- package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
- package/plugins/design/dev-server/test/git-lifecycle.test.ts +101 -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/participants-chrome.test.ts +30 -0
- package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
- package/plugins/design/dev-server/test/use-collab.test.ts +71 -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/tool-palette.tsx +7 -7
- 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-annotation-resize.tsx +1 -1
- package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
- package/plugins/design/dev-server/use-collab.tsx +478 -0
- 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/dev-server/ws.ts +123 -7
- package/plugins/design/templates/_shell.html +51 -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/flow/.claude-plugin/config.schema.json +12 -0
- package/plugins/flow/dependencies.json +143 -0
- package/plugins/flow/dependencies.schema.json +107 -0
- /package/plugins/design/templates/design-system-inspiration/core/preview/{motion.html → .archive/motion.html} +0 -0
|
@@ -29,6 +29,30 @@
|
|
|
29
29
|
|
|
30
30
|
import { type RefObject, useEffect } from 'react';
|
|
31
31
|
|
|
32
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
33
|
+
// Drag-vs-click threshold (T25)
|
|
34
|
+
//
|
|
35
|
+
// 4 px screen-pixel hypot separates "click" from "drag" — Microsoft Win32
|
|
36
|
+
// canonical (`SM_CXDRAG`/`SM_CYDRAG` default), also d3-drag and tldraw default.
|
|
37
|
+
// Owned here so artboard-drag, artboard-marquee, element-marquee, annotation-
|
|
38
|
+
// drag-vs-tap, and any future drag-class gesture all read the same constant.
|
|
39
|
+
// Wheel + pinch-zoom are EXEMPT — threshold is for `pointerdown → pointermove`
|
|
40
|
+
// drag classification only.
|
|
41
|
+
|
|
42
|
+
export const DRAG_THRESHOLD_PX = 4;
|
|
43
|
+
|
|
44
|
+
/** True once the pointer has moved ≥ DRAG_THRESHOLD_PX from its start. */
|
|
45
|
+
export function crossedDragThreshold(
|
|
46
|
+
startX: number,
|
|
47
|
+
startY: number,
|
|
48
|
+
curX: number,
|
|
49
|
+
curY: number
|
|
50
|
+
): boolean {
|
|
51
|
+
const dx = curX - startX;
|
|
52
|
+
const dy = curY - startY;
|
|
53
|
+
return Math.hypot(dx, dy) >= DRAG_THRESHOLD_PX;
|
|
54
|
+
}
|
|
55
|
+
|
|
32
56
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
33
57
|
// Types
|
|
34
58
|
|
|
@@ -68,7 +92,9 @@ export type RouterAction =
|
|
|
68
92
|
| { kind: 'drop-comment'; clientX: number; clientY: number }
|
|
69
93
|
| { kind: 'context-menu'; clientX: number; clientY: number }
|
|
70
94
|
| { kind: 'tool'; tool: Tool }
|
|
71
|
-
| { kind: 'escape' }
|
|
95
|
+
| { kind: 'escape' }
|
|
96
|
+
| { kind: 'undo' }
|
|
97
|
+
| { kind: 'redo' };
|
|
72
98
|
|
|
73
99
|
export interface ClassifyInput {
|
|
74
100
|
type: 'pointermove' | 'pointerdown' | 'contextmenu' | 'keydown';
|
|
@@ -101,6 +127,15 @@ export function classify(input: ClassifyInput): RouterAction {
|
|
|
101
127
|
if (input.metaKey || input.ctrlKey || input.altKey) {
|
|
102
128
|
// Esc with modifiers still dismisses.
|
|
103
129
|
if (input.key === 'Escape') return { kind: 'escape' };
|
|
130
|
+
// Undo / redo (Phase 20). Alt is reserved — Cmd+Opt+Z is a browser
|
|
131
|
+
// text-input gesture we don't claim. `metaKey || ctrlKey` covers both
|
|
132
|
+
// mac and Windows / Linux without a platform sniff.
|
|
133
|
+
const k = (input.key || '').toLowerCase();
|
|
134
|
+
if (!input.altKey && (input.metaKey || input.ctrlKey)) {
|
|
135
|
+
if (k === 'z' && input.shiftKey) return { kind: 'redo' };
|
|
136
|
+
if (k === 'z') return { kind: 'undo' };
|
|
137
|
+
if (k === 'y' && !input.shiftKey) return { kind: 'redo' };
|
|
138
|
+
}
|
|
104
139
|
return { kind: 'no-op' };
|
|
105
140
|
}
|
|
106
141
|
const k = (input.key || '').toLowerCase();
|
|
@@ -218,6 +253,10 @@ export interface RouterCallbacks {
|
|
|
218
253
|
onContextMenu?: (a: Extract<RouterAction, { kind: 'context-menu' }>) => void;
|
|
219
254
|
onTool?: (a: Extract<RouterAction, { kind: 'tool' }>) => void;
|
|
220
255
|
onEscape?: () => void;
|
|
256
|
+
/** Phase 20 — Cmd+Z / Ctrl+Z. */
|
|
257
|
+
onUndo?: () => void;
|
|
258
|
+
/** Phase 20 — Cmd+Shift+Z / Ctrl+Y / Cmd+Y. */
|
|
259
|
+
onRedo?: () => void;
|
|
221
260
|
}
|
|
222
261
|
|
|
223
262
|
export interface UseInputRouterOptions {
|
|
@@ -283,6 +322,12 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
|
283
322
|
case 'escape':
|
|
284
323
|
callbacks.onEscape?.();
|
|
285
324
|
break;
|
|
325
|
+
case 'undo':
|
|
326
|
+
callbacks.onUndo?.();
|
|
327
|
+
break;
|
|
328
|
+
case 'redo':
|
|
329
|
+
callbacks.onRedo?.();
|
|
330
|
+
break;
|
|
286
331
|
case 'no-op':
|
|
287
332
|
break;
|
|
288
333
|
}
|
|
@@ -404,7 +449,12 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
|
404
449
|
isEditable: isEditableTarget(e.target),
|
|
405
450
|
activeTool: getActiveTool(),
|
|
406
451
|
});
|
|
407
|
-
if (
|
|
452
|
+
if (
|
|
453
|
+
action.kind === 'tool' ||
|
|
454
|
+
action.kind === 'escape' ||
|
|
455
|
+
action.kind === 'undo' ||
|
|
456
|
+
action.kind === 'redo'
|
|
457
|
+
) {
|
|
408
458
|
e.preventDefault();
|
|
409
459
|
}
|
|
410
460
|
dispatch(action);
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file marquee-overlay.tsx — T26 (Wave 3)
|
|
3
|
+
* @scope plugins/design/dev-server/marquee-overlay.tsx
|
|
4
|
+
* @purpose Element-level marquee selection. Drag from empty padding inside
|
|
5
|
+
* an artboard body (NOT on top of a `[data-cd-id]` element) to
|
|
6
|
+
* lasso multiple stamped elements. Aseprite modifier vocabulary:
|
|
7
|
+
* bare = replace, Shift = add, Alt = subtract, Shift+Alt =
|
|
8
|
+
* intersect. Modifier state re-read at pointerup so the user can
|
|
9
|
+
* flip mid-drag.
|
|
10
|
+
*
|
|
11
|
+
* Co-exists with `artboard-marquee.tsx` — that overlay triggers
|
|
12
|
+
* when pointerdown lands OUTSIDE any artboard (empty world);
|
|
13
|
+
* this overlay triggers when pointerdown lands INSIDE an artboard
|
|
14
|
+
* body on padding (no cd-id under cursor). The two are mutually
|
|
15
|
+
* exclusive by start position.
|
|
16
|
+
*
|
|
17
|
+
* Renders the marquee rect per DDR-046 rev 2:
|
|
18
|
+
* 1 px solid accent + 8 % accent fill
|
|
19
|
+
* (same idiom as artboard marquee — active gesture is solid +
|
|
20
|
+
* tinted; dashed reserved for persistent group bbox.)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { useEffect, useRef } from 'react';
|
|
24
|
+
|
|
25
|
+
import { DRAG_THRESHOLD_PX } from './input-router.tsx';
|
|
26
|
+
import { type Selection, useSelectionSet } from './use-selection-set.tsx';
|
|
27
|
+
import { useToolMode } from './use-tool-mode.tsx';
|
|
28
|
+
|
|
29
|
+
const ELEM_MARQUEE_CSS = `
|
|
30
|
+
.dc-cv-elem-marquee {
|
|
31
|
+
position: fixed;
|
|
32
|
+
pointer-events: none;
|
|
33
|
+
z-index: 5;
|
|
34
|
+
border: 1px solid var(--maude-hud-accent, #0d99ff);
|
|
35
|
+
background: color-mix(in oklab, var(--maude-hud-accent, #0d99ff) 8%, transparent);
|
|
36
|
+
display: none;
|
|
37
|
+
}
|
|
38
|
+
`.trim();
|
|
39
|
+
|
|
40
|
+
function ensureMarqueeStyles(): void {
|
|
41
|
+
if (typeof document === 'undefined') return;
|
|
42
|
+
if (document.getElementById('dc-cv-elem-marquee-css')) return;
|
|
43
|
+
const s = document.createElement('style');
|
|
44
|
+
s.id = 'dc-cv-elem-marquee-css';
|
|
45
|
+
s.textContent = ELEM_MARQUEE_CSS;
|
|
46
|
+
document.head.appendChild(s);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Skip the same chrome / overlay surfaces as artboard-marquee — the user is
|
|
50
|
+
// interacting with floating UI, not the world.
|
|
51
|
+
function isChromeTarget(t: EventTarget | null): boolean {
|
|
52
|
+
if (!t || !(t as Element).closest) return true;
|
|
53
|
+
const el = t as Element;
|
|
54
|
+
if (
|
|
55
|
+
el.closest(
|
|
56
|
+
'.dc-mm, .dc-zoom-tb, .dc-tool-palette, .dc-context-menu, .dc-cv-halo, .dc-cv-group-bbox, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-svg, .dc-annot-ctx, .dc-tp-popover, .dc-annot-resize-handle, .dc-multi-artboard-tb, .dc-elem-ctx-tb, .dc-cv-eq-spacing-layer'
|
|
57
|
+
)
|
|
58
|
+
) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Walk from `hit` upward and decide if the pointerdown landed on "empty body
|
|
66
|
+
* padding" (no `[data-cd-id]` encountered before reaching `.dc-artboard-body`).
|
|
67
|
+
* Returns the body element when yes; null when the pointerdown should not
|
|
68
|
+
* start an element marquee.
|
|
69
|
+
*/
|
|
70
|
+
function resolveBodyOrNull(hit: Element): Element | null {
|
|
71
|
+
let cur: Element | null = hit;
|
|
72
|
+
while (cur) {
|
|
73
|
+
if (cur.hasAttribute?.('data-cd-id')) return null; // on user content
|
|
74
|
+
if ((cur as HTMLElement).classList?.contains('dc-artboard-body')) return cur;
|
|
75
|
+
cur = cur.parentElement;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Mode resolved from the current modifier state. Re-read at pointerup so a
|
|
82
|
+
* user can flip mid-drag — e.g. start without modifiers, press Shift before
|
|
83
|
+
* releasing, the gesture commits as `add` instead of `replace`.
|
|
84
|
+
*/
|
|
85
|
+
type Mode = 'replace' | 'add' | 'subtract' | 'intersect';
|
|
86
|
+
|
|
87
|
+
function modeOf(e: { shiftKey: boolean; altKey: boolean }): Mode {
|
|
88
|
+
if (e.shiftKey && e.altKey) return 'intersect';
|
|
89
|
+
if (e.shiftKey) return 'add';
|
|
90
|
+
if (e.altKey) return 'subtract';
|
|
91
|
+
return 'replace';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build a minimal `Selection` from a `[data-cd-id]` element — just the fields
|
|
96
|
+
* downstream consumers actually read for bulk marquee hits. The rich shape
|
|
97
|
+
* (html, dom_path, text) is only enriched when a single click sets focus
|
|
98
|
+
* through `hoverTargetToSelection`.
|
|
99
|
+
*/
|
|
100
|
+
function elementToSelection(el: Element, bodyAncestor: Element): Selection | null {
|
|
101
|
+
const cdId = el.getAttribute('data-cd-id');
|
|
102
|
+
if (!cdId) return null;
|
|
103
|
+
const artboardEl = bodyAncestor.closest('[data-dc-screen]');
|
|
104
|
+
const artboardId = artboardEl?.getAttribute('data-dc-screen') ?? null;
|
|
105
|
+
const rect = (el as HTMLElement).getBoundingClientRect();
|
|
106
|
+
return {
|
|
107
|
+
id: cdId,
|
|
108
|
+
selector: `[data-cd-id="${cdId}"]`,
|
|
109
|
+
artboardId,
|
|
110
|
+
tag: el.tagName.toLowerCase(),
|
|
111
|
+
classes: (el.getAttribute('class') ?? '')
|
|
112
|
+
.trim()
|
|
113
|
+
.split(/\s+/)
|
|
114
|
+
.filter((c) => c && !c.startsWith('dgn-') && !c.startsWith('dc-cv-'))
|
|
115
|
+
.join(' '),
|
|
116
|
+
bounds: {
|
|
117
|
+
x: Math.round(rect.left),
|
|
118
|
+
y: Math.round(rect.top),
|
|
119
|
+
w: Math.round(rect.width),
|
|
120
|
+
h: Math.round(rect.height),
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function rectsIntersect(
|
|
126
|
+
a: { left: number; top: number; right: number; bottom: number },
|
|
127
|
+
b: { left: number; top: number; right: number; bottom: number }
|
|
128
|
+
): boolean {
|
|
129
|
+
if (a.right < b.left || a.left > b.right) return false;
|
|
130
|
+
if (a.bottom < b.top || a.top > b.bottom) return false;
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function ElementMarqueeOverlay() {
|
|
135
|
+
ensureMarqueeStyles();
|
|
136
|
+
const selSet = useSelectionSet();
|
|
137
|
+
const { tool } = useToolMode();
|
|
138
|
+
const overlayRef = useRef<HTMLDivElement | null>(null);
|
|
139
|
+
const stateRef = useRef<{
|
|
140
|
+
pointerId: number;
|
|
141
|
+
startX: number;
|
|
142
|
+
startY: number;
|
|
143
|
+
crossed: boolean;
|
|
144
|
+
bodyEl: Element;
|
|
145
|
+
/** Snapshot of `selSet.selected` at gesture start — needed for subtract /
|
|
146
|
+
* intersect / add to compute deltas against the original set. */
|
|
147
|
+
startSet: Selection[];
|
|
148
|
+
} | null>(null);
|
|
149
|
+
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (tool !== 'move') return;
|
|
152
|
+
if (typeof document === 'undefined') return;
|
|
153
|
+
|
|
154
|
+
const onDown = (e: PointerEvent) => {
|
|
155
|
+
if (e.button !== 0) return;
|
|
156
|
+
// Cmd / Ctrl reserved for single-element select (Cmd-click). Never start
|
|
157
|
+
// an element marquee when those are held — that gesture means "select
|
|
158
|
+
// the single element under cursor."
|
|
159
|
+
if (e.metaKey || e.ctrlKey) return;
|
|
160
|
+
const target = e.target as Element | null;
|
|
161
|
+
if (!target || isChromeTarget(target)) return;
|
|
162
|
+
const bodyEl = resolveBodyOrNull(target);
|
|
163
|
+
if (!bodyEl) return; // either over user content or outside any artboard
|
|
164
|
+
stateRef.current = {
|
|
165
|
+
pointerId: e.pointerId,
|
|
166
|
+
startX: e.clientX,
|
|
167
|
+
startY: e.clientY,
|
|
168
|
+
crossed: false,
|
|
169
|
+
bodyEl,
|
|
170
|
+
startSet: selSet.selected.slice(),
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const onMove = (e: PointerEvent) => {
|
|
175
|
+
const s = stateRef.current;
|
|
176
|
+
if (!s || e.pointerId !== s.pointerId) return;
|
|
177
|
+
const dx = e.clientX - s.startX;
|
|
178
|
+
const dy = e.clientY - s.startY;
|
|
179
|
+
if (!s.crossed) {
|
|
180
|
+
if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
|
|
181
|
+
s.crossed = true;
|
|
182
|
+
}
|
|
183
|
+
const div = overlayRef.current;
|
|
184
|
+
if (!div) return;
|
|
185
|
+
const left = Math.min(s.startX, e.clientX);
|
|
186
|
+
const top = Math.min(s.startY, e.clientY);
|
|
187
|
+
div.style.display = 'block';
|
|
188
|
+
div.style.left = `${left}px`;
|
|
189
|
+
div.style.top = `${top}px`;
|
|
190
|
+
div.style.width = `${Math.abs(dx)}px`;
|
|
191
|
+
div.style.height = `${Math.abs(dy)}px`;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const finish = (e: PointerEvent) => {
|
|
195
|
+
const s = stateRef.current;
|
|
196
|
+
if (!s || e.pointerId !== s.pointerId) return;
|
|
197
|
+
stateRef.current = null;
|
|
198
|
+
const div = overlayRef.current;
|
|
199
|
+
if (div) div.style.display = 'none';
|
|
200
|
+
if (!s.crossed) return;
|
|
201
|
+
|
|
202
|
+
const left = Math.min(s.startX, e.clientX);
|
|
203
|
+
const top = Math.min(s.startY, e.clientY);
|
|
204
|
+
const right = Math.max(s.startX, e.clientX);
|
|
205
|
+
const bottom = Math.max(s.startY, e.clientY);
|
|
206
|
+
const marqueeBox = { left, top, right, bottom };
|
|
207
|
+
|
|
208
|
+
// Intersect against every stamped element inside the originating
|
|
209
|
+
// artboard body. Scoping to the start body is intentional — drags
|
|
210
|
+
// that extend across artboards still only capture from the artboard
|
|
211
|
+
// the gesture started in. Cross-artboard marquee is an unimplemented
|
|
212
|
+
// affordance (no peer tool ships it; users rely on multi-artboard
|
|
213
|
+
// distribute + per-artboard marquees).
|
|
214
|
+
const stamped = s.bodyEl.querySelectorAll('[data-cd-id]');
|
|
215
|
+
const hits: Selection[] = [];
|
|
216
|
+
for (const el of stamped) {
|
|
217
|
+
const b = (el as HTMLElement).getBoundingClientRect();
|
|
218
|
+
if (b.width === 0 && b.height === 0) continue;
|
|
219
|
+
if (!rectsIntersect(marqueeBox, b)) continue;
|
|
220
|
+
const sel = elementToSelection(el, s.bodyEl);
|
|
221
|
+
if (sel) hits.push(sel);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const mode = modeOf(e);
|
|
225
|
+
applyMarqueeMode(selSet, mode, s.startSet, hits);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
document.addEventListener('pointerdown', onDown, true);
|
|
229
|
+
document.addEventListener('pointermove', onMove);
|
|
230
|
+
document.addEventListener('pointerup', finish);
|
|
231
|
+
document.addEventListener('pointercancel', finish);
|
|
232
|
+
return () => {
|
|
233
|
+
document.removeEventListener('pointerdown', onDown, true);
|
|
234
|
+
document.removeEventListener('pointermove', onMove);
|
|
235
|
+
document.removeEventListener('pointerup', finish);
|
|
236
|
+
document.removeEventListener('pointercancel', finish);
|
|
237
|
+
};
|
|
238
|
+
}, [tool, selSet]);
|
|
239
|
+
|
|
240
|
+
return <div ref={overlayRef} className="dc-cv-elem-marquee" aria-hidden="true" />;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Apply marquee hits to `selSet` per Aseprite modifier semantics. Pulled out
|
|
245
|
+
* of the hook for unit-test reachability.
|
|
246
|
+
*/
|
|
247
|
+
export function applyMarqueeMode(
|
|
248
|
+
selSet: {
|
|
249
|
+
replace: (s: Selection | Selection[]) => void;
|
|
250
|
+
add: (s: Selection | Selection[]) => void;
|
|
251
|
+
},
|
|
252
|
+
mode: Mode,
|
|
253
|
+
startSet: Selection[],
|
|
254
|
+
hits: Selection[]
|
|
255
|
+
): void {
|
|
256
|
+
if (mode === 'replace') {
|
|
257
|
+
if (hits.length === 0) return; // empty marquee preserves selection
|
|
258
|
+
selSet.replace(hits);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (mode === 'add') {
|
|
262
|
+
if (hits.length === 0) return;
|
|
263
|
+
selSet.add(hits);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (mode === 'subtract') {
|
|
267
|
+
const keyOf = (x: Selection) => (x.id ? `id:${x.id}` : `sel:${x.selector}`);
|
|
268
|
+
const removeKeys = new Set(hits.map(keyOf));
|
|
269
|
+
const next = startSet.filter((x) => !removeKeys.has(keyOf(x)));
|
|
270
|
+
selSet.replace(next);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
// intersect
|
|
274
|
+
const keyOf = (x: Selection) => (x.id ? `id:${x.id}` : `sel:${x.selector}`);
|
|
275
|
+
const hitKeys = new Set(hits.map(keyOf));
|
|
276
|
+
const next = startSet.filter((x) => hitKeys.has(keyOf(x)));
|
|
277
|
+
selSet.replace(next);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Re-export for tests.
|
|
281
|
+
export { modeOf };
|
|
282
|
+
export type { Mode };
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file participants-chrome.tsx — top-right avatar stack of live peers
|
|
3
|
+
* @scope plugins/design/dev-server/participants-chrome.tsx
|
|
4
|
+
* @purpose Phase 8 Task 6 — show who's currently on the canvas. Colored
|
|
5
|
+
* initials in a circle per peer, with a popover for full name +
|
|
6
|
+
* "Follow" button. Follow mode publishes `followTarget` on my
|
|
7
|
+
* Awareness; a separate effect pulls the target's viewport on
|
|
8
|
+
* every update and calls controller.setViewport, snapping my pan
|
|
9
|
+
* + zoom to theirs in lockstep.
|
|
10
|
+
*
|
|
11
|
+
* Follow semantics — soft and one-way. The target peer doesn't know they're
|
|
12
|
+
* being followed (no UI, no Awareness echo). Either party panning the canvas
|
|
13
|
+
* breaks nothing on the other end; I just keep mirroring until I click my
|
|
14
|
+
* own avatar to release. tldraw-style.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
18
|
+
|
|
19
|
+
import { useViewportControllerContext } from './canvas-lib.tsx';
|
|
20
|
+
import { type ForeignAwareness, useCollab, useForeignAwareness } from './use-collab.tsx';
|
|
21
|
+
|
|
22
|
+
const CHROME_CSS = `
|
|
23
|
+
.dc-participants {
|
|
24
|
+
position: fixed;
|
|
25
|
+
top: 12px;
|
|
26
|
+
right: 12px;
|
|
27
|
+
z-index: 10000;
|
|
28
|
+
display: flex;
|
|
29
|
+
align-items: center;
|
|
30
|
+
pointer-events: auto;
|
|
31
|
+
font-family: var(--font-sans, system-ui, -apple-system, sans-serif);
|
|
32
|
+
}
|
|
33
|
+
.dc-participant {
|
|
34
|
+
width: 26px;
|
|
35
|
+
height: 26px;
|
|
36
|
+
border-radius: 50%;
|
|
37
|
+
display: inline-flex;
|
|
38
|
+
align-items: center;
|
|
39
|
+
justify-content: center;
|
|
40
|
+
font-weight: 600;
|
|
41
|
+
font-size: 10px;
|
|
42
|
+
line-height: 1;
|
|
43
|
+
letter-spacing: 0.02em;
|
|
44
|
+
color: var(--maude-hud-accent-fg, #fff);
|
|
45
|
+
border: 1.5px solid var(--bg-0, #fff);
|
|
46
|
+
margin-left: -6px;
|
|
47
|
+
cursor: pointer;
|
|
48
|
+
position: relative;
|
|
49
|
+
box-shadow: 0 1px 2px rgba(0,0,0,0.08);
|
|
50
|
+
user-select: none;
|
|
51
|
+
transition: transform 120ms ease, z-index 0ms 120ms;
|
|
52
|
+
}
|
|
53
|
+
.dc-participant:first-child { margin-left: 0; }
|
|
54
|
+
.dc-participant:hover {
|
|
55
|
+
transform: scale(1.06);
|
|
56
|
+
z-index: 10;
|
|
57
|
+
transition-delay: 0ms;
|
|
58
|
+
}
|
|
59
|
+
.dc-participant:focus-visible {
|
|
60
|
+
outline: 2px solid var(--maude-hud-accent, oklch(56% 0.170 50));
|
|
61
|
+
outline-offset: 2px;
|
|
62
|
+
}
|
|
63
|
+
.dc-participant--following {
|
|
64
|
+
outline: 1.5px solid var(--maude-hud-accent, oklch(56% 0.170 50));
|
|
65
|
+
outline-offset: 1.5px;
|
|
66
|
+
}
|
|
67
|
+
.dc-participant-popover {
|
|
68
|
+
position: absolute;
|
|
69
|
+
top: calc(100% + 8px);
|
|
70
|
+
right: 0;
|
|
71
|
+
min-width: 168px;
|
|
72
|
+
padding: 10px 12px;
|
|
73
|
+
background: var(--bg-1, #fff);
|
|
74
|
+
color: var(--fg-0, #111);
|
|
75
|
+
border: 1px solid var(--border-default, rgba(0,0,0,0.16));
|
|
76
|
+
border-radius: var(--radius-md, 4px);
|
|
77
|
+
font-size: 12px;
|
|
78
|
+
line-height: 1.35;
|
|
79
|
+
white-space: nowrap;
|
|
80
|
+
box-shadow: 0 6px 18px rgba(0,0,0,0.10);
|
|
81
|
+
z-index: 11;
|
|
82
|
+
}
|
|
83
|
+
.dc-participant-popover__name {
|
|
84
|
+
font-weight: 600;
|
|
85
|
+
margin-bottom: 8px;
|
|
86
|
+
color: var(--fg-0, #111);
|
|
87
|
+
letter-spacing: 0.01em;
|
|
88
|
+
}
|
|
89
|
+
.dc-participant-popover__btn {
|
|
90
|
+
display: inline-block;
|
|
91
|
+
padding: 4px 10px;
|
|
92
|
+
background: var(--maude-hud-accent, oklch(56% 0.170 50));
|
|
93
|
+
color: var(--maude-hud-accent-fg, #fff);
|
|
94
|
+
border: none;
|
|
95
|
+
border-radius: var(--radius-sm, 2px);
|
|
96
|
+
font-family: inherit;
|
|
97
|
+
font-weight: 500;
|
|
98
|
+
font-size: 11px;
|
|
99
|
+
line-height: 1.3;
|
|
100
|
+
cursor: pointer;
|
|
101
|
+
letter-spacing: 0.02em;
|
|
102
|
+
}
|
|
103
|
+
.dc-participant-popover__btn:hover { background: var(--maude-hud-accent-hover, var(--maude-hud-accent, oklch(50% 0.170 50))); }
|
|
104
|
+
.dc-participant-popover__btn--stop {
|
|
105
|
+
background: var(--bg-3, #e5e5e5);
|
|
106
|
+
color: var(--fg-0, #111);
|
|
107
|
+
border: 1px solid var(--border-subtle, rgba(0,0,0,0.12));
|
|
108
|
+
}
|
|
109
|
+
.dc-participant-popover__btn--stop:hover { background: var(--bg-4, #d8d8d8); }
|
|
110
|
+
@media (prefers-reduced-motion: reduce) {
|
|
111
|
+
.dc-participant { transition: none; }
|
|
112
|
+
}
|
|
113
|
+
`.trim();
|
|
114
|
+
|
|
115
|
+
function ensureChromeStyles(): void {
|
|
116
|
+
if (typeof document === 'undefined') return;
|
|
117
|
+
if (document.getElementById('dc-participants-css')) return;
|
|
118
|
+
const s = document.createElement('style');
|
|
119
|
+
s.id = 'dc-participants-css';
|
|
120
|
+
s.textContent = CHROME_CSS;
|
|
121
|
+
document.head.appendChild(s);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function initialsFor(name: string): string {
|
|
125
|
+
const cleaned = name.trim();
|
|
126
|
+
if (!cleaned) return '?';
|
|
127
|
+
const parts = cleaned.split(/\s+/).filter(Boolean);
|
|
128
|
+
const first = parts[0] ?? '';
|
|
129
|
+
if (parts.length === 1) return first.slice(0, 2).toUpperCase();
|
|
130
|
+
const second = parts[1] ?? '';
|
|
131
|
+
return ((first[0] ?? '') + (second[0] ?? '')).toUpperCase();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface AvatarProps {
|
|
135
|
+
peer: ForeignAwareness;
|
|
136
|
+
isFollowing: boolean;
|
|
137
|
+
onToggleFollow: (clientID: number) => void;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function Avatar({ peer, isFollowing, onToggleFollow }: AvatarProps): JSX.Element {
|
|
141
|
+
const [open, setOpen] = useState(false);
|
|
142
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
143
|
+
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
if (!open) return;
|
|
146
|
+
const onDocClick = (e: MouseEvent) => {
|
|
147
|
+
if (!rootRef.current) return;
|
|
148
|
+
if (!rootRef.current.contains(e.target as Node)) setOpen(false);
|
|
149
|
+
};
|
|
150
|
+
document.addEventListener('mousedown', onDocClick);
|
|
151
|
+
return () => document.removeEventListener('mousedown', onDocClick);
|
|
152
|
+
}, [open]);
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<div
|
|
156
|
+
ref={rootRef}
|
|
157
|
+
className={`dc-participant${isFollowing ? ' dc-participant--following' : ''}`}
|
|
158
|
+
style={{ background: peer.color }}
|
|
159
|
+
onClick={() => setOpen((v) => !v)}
|
|
160
|
+
onKeyDown={(e) => {
|
|
161
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
162
|
+
e.preventDefault();
|
|
163
|
+
setOpen((v) => !v);
|
|
164
|
+
}
|
|
165
|
+
}}
|
|
166
|
+
// biome-ignore lint/a11y/useSemanticElements: 28px round chip needs raw <div> + inline bg + custom focus ring; <button> reset is more code than the rule saves.
|
|
167
|
+
role="button"
|
|
168
|
+
tabIndex={0}
|
|
169
|
+
title={peer.name}
|
|
170
|
+
aria-label={peer.name}
|
|
171
|
+
aria-expanded={open}
|
|
172
|
+
>
|
|
173
|
+
{initialsFor(peer.name)}
|
|
174
|
+
{open && (
|
|
175
|
+
// biome-ignore lint/a11y/useKeyWithClickEvents: stop-propagation wrapper; keyboard focus stays in the button child.
|
|
176
|
+
// biome-ignore lint/a11y/useSemanticElements: popover wrapper carries no semantic role; the inner <button> is the actionable element.
|
|
177
|
+
<div className="dc-participant-popover" onClick={(e) => e.stopPropagation()}>
|
|
178
|
+
<div className="dc-participant-popover__name">{peer.name}</div>
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
className={`dc-participant-popover__btn${isFollowing ? ' dc-participant-popover__btn--stop' : ''}`}
|
|
182
|
+
onClick={() => {
|
|
183
|
+
onToggleFollow(peer.clientID);
|
|
184
|
+
setOpen(false);
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
{isFollowing ? 'Stop following' : `Follow ${peer.name.split(/\s+/)[0]}`}
|
|
188
|
+
</button>
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function ParticipantsChrome(): JSX.Element | null {
|
|
196
|
+
ensureChromeStyles();
|
|
197
|
+
const collab = useCollab();
|
|
198
|
+
const peers = useForeignAwareness();
|
|
199
|
+
const controller = useViewportControllerContext();
|
|
200
|
+
|
|
201
|
+
const [followTarget, setFollowTarget] = useState<number | null>(null);
|
|
202
|
+
|
|
203
|
+
const onToggleFollow = useCallback(
|
|
204
|
+
(clientID: number) => {
|
|
205
|
+
setFollowTarget((prev) => (prev === clientID ? null : clientID));
|
|
206
|
+
if (collab) {
|
|
207
|
+
collab.publishAwareness({
|
|
208
|
+
selection: collab.awareness.getLocalState()?.selection ?? null,
|
|
209
|
+
});
|
|
210
|
+
// Tag follow target as an Awareness key so Phase 9 follow-back UIs
|
|
211
|
+
// can read it; not currently used by anything else.
|
|
212
|
+
const state = collab.awareness.getLocalState() ?? {};
|
|
213
|
+
collab.awareness.setLocalState({
|
|
214
|
+
...state,
|
|
215
|
+
followTarget: clientID === followTarget ? null : clientID,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
[collab, followTarget]
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
// Apply follow — when followTarget is set, mirror the target's viewport
|
|
223
|
+
// onto our local controller. Each peer-Awareness update re-fires this; we
|
|
224
|
+
// skip when the viewport hasn't actually changed (saves a controller write).
|
|
225
|
+
const lastAppliedRef = useRef<{ x: number; y: number; zoom: number } | null>(null);
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
if (followTarget === null) {
|
|
228
|
+
lastAppliedRef.current = null;
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (!controller) return;
|
|
232
|
+
const target = peers.find((p) => p.clientID === followTarget);
|
|
233
|
+
if (!target) {
|
|
234
|
+
// Target peer disconnected; release follow.
|
|
235
|
+
setFollowTarget(null);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const v = target.viewport;
|
|
239
|
+
const last = lastAppliedRef.current;
|
|
240
|
+
if (last && last.x === v.x && last.y === v.y && last.zoom === v.zoom) return;
|
|
241
|
+
lastAppliedRef.current = { x: v.x, y: v.y, zoom: v.zoom };
|
|
242
|
+
controller.setViewport(v);
|
|
243
|
+
}, [controller, followTarget, peers]);
|
|
244
|
+
|
|
245
|
+
if (peers.length === 0) return null;
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<div className="dc-participants" aria-label="Active collaborators">
|
|
249
|
+
{peers.map((p) => (
|
|
250
|
+
<Avatar
|
|
251
|
+
key={p.clientID}
|
|
252
|
+
peer={p}
|
|
253
|
+
isFollowing={followTarget === p.clientID}
|
|
254
|
+
onToggleFollow={onToggleFollow}
|
|
255
|
+
/>
|
|
256
|
+
))}
|
|
257
|
+
</div>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export { initialsFor };
|
|
@@ -53,6 +53,21 @@ export const RUNTIME_PACKAGES = [
|
|
|
53
53
|
// (DDR-024 deferred path) and any high-end designer-tool overlays a canvas
|
|
54
54
|
// wants to draw via WebGL.
|
|
55
55
|
'pixi.js',
|
|
56
|
+
// Phase 3.7 / DDR-049 — Motion One (motion/react) is the canonical motion
|
|
57
|
+
// library for the canvas-lib + handoff pipeline. Externalised here so the
|
|
58
|
+
// canvas-lib motion helpers (<MotionDemo>, etc.) resolve through the same
|
|
59
|
+
// importmap path; consumers of /design:handoff still see a "motion" peer
|
|
60
|
+
// dep declaration on the registry-item.json output.
|
|
61
|
+
'motion',
|
|
62
|
+
'motion/react',
|
|
63
|
+
// Phase 8 / DDR-051 — Yjs + y-protocols for the canvas-shell collab client.
|
|
64
|
+
// Each canvas iframe opens a WS to /_ws/collab/:slug and owns a per-iframe
|
|
65
|
+
// Y.Doc + Awareness instance. Externalised here so the client-side
|
|
66
|
+
// use-collab.tsx + cursors-overlay.tsx resolve via the importmap; canvases
|
|
67
|
+
// that don't use collab pay zero bundle cost (tree-shaken).
|
|
68
|
+
'yjs',
|
|
69
|
+
'y-protocols/sync',
|
|
70
|
+
'y-protocols/awareness',
|
|
56
71
|
] as const;
|
|
57
72
|
|
|
58
73
|
export type RuntimePackage = (typeof RUNTIME_PACKAGES)[number];
|