@1agh/maude 0.39.1 → 0.40.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 +1 -1
- package/apps/studio/annotations-context-toolbar.tsx +44 -1
- package/apps/studio/annotations-layer.tsx +253 -3
- package/apps/studio/annotations-model.ts +107 -6
- package/apps/studio/api.ts +812 -64
- package/apps/studio/bin/_html-playwright.mjs +9 -1
- package/apps/studio/bin/_pdf-playwright.mjs +8 -1
- package/apps/studio/bin/_png-playwright.mjs +8 -1
- package/apps/studio/bin/_pw-launch.mjs +54 -0
- package/apps/studio/bin/_svg-playwright.mjs +8 -1
- package/apps/studio/bin/_video-playwright.mjs +452 -0
- package/apps/studio/bin/prep.sh +8 -1
- package/apps/studio/canvas-edit.ts +1885 -104
- package/apps/studio/canvas-lib.tsx +19 -0
- package/apps/studio/canvas-list-watch.ts +6 -2
- package/apps/studio/canvas-shell.tsx +27 -0
- package/apps/studio/client/app.jsx +1029 -30
- package/apps/studio/client/github.js +7 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
- package/apps/studio/client/panels/timeline-parse.js +229 -0
- package/apps/studio/client/panels/timeline-snap.js +55 -0
- package/apps/studio/client/styles/3-shell-maude.css +107 -0
- package/apps/studio/config.schema.json +14 -0
- package/apps/studio/context-menu.tsx +1 -1
- package/apps/studio/context.ts +113 -1
- package/apps/studio/dist/client.bundle.js +88 -16
- package/apps/studio/dist/comment-mount.js +1 -1
- package/apps/studio/dist/runtime/.min-sizes.json +11 -1
- package/apps/studio/dist/runtime/@remotion_media.js +491 -0
- package/apps/studio/dist/runtime/@remotion_player.js +56 -0
- package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
- package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
- package/apps/studio/dist/runtime/remotion.js +42 -0
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +117 -0
- package/apps/studio/exporters/_runtime.ts +69 -0
- package/apps/studio/exporters/html.ts +4 -3
- package/apps/studio/exporters/index.ts +28 -2
- package/apps/studio/exporters/pdf.ts +4 -3
- package/apps/studio/exporters/png.ts +5 -3
- package/apps/studio/exporters/pptx.ts +6 -4
- package/apps/studio/exporters/svg.ts +9 -5
- package/apps/studio/exporters/video-encode-lib.ts +200 -0
- package/apps/studio/exporters/video-render-lib.ts +108 -0
- package/apps/studio/exporters/video.ts +184 -0
- package/apps/studio/http.ts +535 -27
- package/apps/studio/input-router.tsx +7 -1
- package/apps/studio/runtime-bundle.ts +30 -0
- package/apps/studio/server.ts +34 -9
- package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
- package/apps/studio/test/canvas-create-api.test.ts +76 -0
- package/apps/studio/test/canvas-edit.test.ts +90 -0
- package/apps/studio/test/canvas-list-watch.test.ts +49 -0
- package/apps/studio/test/canvas-media-drop.test.ts +30 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +36 -0
- package/apps/studio/test/clip-addressing.test.ts +732 -0
- package/apps/studio/test/config-reload.test.ts +230 -0
- package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
- package/apps/studio/test/edit-persistence.test.ts +91 -0
- package/apps/studio/test/exporters/runtime.test.ts +59 -0
- package/apps/studio/test/file-lock.test.ts +84 -0
- package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
- package/apps/studio/test/timeline-parse.test.ts +127 -0
- package/apps/studio/test/timeline-snap.test.ts +85 -0
- package/apps/studio/test/video-asset.test.ts +163 -0
- package/apps/studio/test/video-comp-fixture.test.ts +50 -0
- package/apps/studio/test/video-comp.test.ts +168 -0
- package/apps/studio/test/video-render-bridge.test.ts +149 -0
- package/apps/studio/use-annotation-resize.tsx +6 -3
- package/apps/studio/use-canvas-media-drop.tsx +66 -4
- package/apps/studio/video-comp.tsx +444 -0
- package/apps/studio/whats-new.json +27 -0
- package/apps/studio/ws.ts +5 -0
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +25 -2
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ A personal marketplace of Claude Code plugins. Two plugins today, plus a `maude`
|
|
|
11
11
|
|
|
12
12
|
| Plugin | What it does |
|
|
13
13
|
| ------ | ------------ |
|
|
14
|
-
| **`design`** | Canvas-first iteration on TSX/JSX mocks under `.design/` — element selection via Cmd+Click, auto-managed dev server, chained UX/DS critique. |
|
|
14
|
+
| **`design`** | Canvas-first iteration on TSX/JSX mocks under `.design/` — element selection via Cmd+Click, auto-managed dev server, chained UX/DS critique. Canvases can also be **video-comps** (Remotion compositions previewed in-app, exported to MP4/GIF through Maude's own capture engine — no extra install). |
|
|
15
15
|
| **`flow`** | Generic agentic workflow loop with a second-brain `.ai/` workspace. `/flow:plan`, `/flow:execute`, `/flow:utils-verify`, `/flow:validate`, `/flow:done`, `/flow:init`, `/flow:record-ddr`, `/flow:scenario`, …. Project-agnostic via `<project>` placeholders + per-repo `.ai/workflows.config.json`. |
|
|
16
16
|
|
|
17
17
|
Plus the **`maude`** CLI — `maude init` scaffolds a fresh `.ai/` workspace from the flow plugin skeleton; `maude design serve` boots the design dev server. The legacy `mdcc` alias still works (prints a deprecation warning) and will be removed in v0.17.x.
|
|
@@ -827,9 +827,52 @@ export function AnnotationContextToolbar({
|
|
|
827
827
|
// fall through to the generic bar below.
|
|
828
828
|
const soleMedia =
|
|
829
829
|
selectedStrokes.length === 1 &&
|
|
830
|
-
(selectedStrokes[0]?.tool === 'image' ||
|
|
830
|
+
(selectedStrokes[0]?.tool === 'image' ||
|
|
831
|
+
selectedStrokes[0]?.tool === 'link' ||
|
|
832
|
+
selectedStrokes[0]?.tool === 'mediaref')
|
|
831
833
|
? selectedStrokes[0]
|
|
832
834
|
: null;
|
|
835
|
+
if (soleMedia?.tool === 'mediaref') {
|
|
836
|
+
// DDR-150 dogfood — a media-reference chip has no color to set (the generic
|
|
837
|
+
// swatch palette was meaningless noise). Focused panel: filename + delete.
|
|
838
|
+
const mr = soleMedia;
|
|
839
|
+
return (
|
|
840
|
+
<div
|
|
841
|
+
ref={ref}
|
|
842
|
+
className="dc-annot-ctx"
|
|
843
|
+
role="toolbar"
|
|
844
|
+
aria-label="Media reference properties"
|
|
845
|
+
style={{ display: 'flex', top: -9999, left: -9999 }}
|
|
846
|
+
>
|
|
847
|
+
<span
|
|
848
|
+
className="dc-annot-ctx-label"
|
|
849
|
+
style={{
|
|
850
|
+
padding: '0 8px',
|
|
851
|
+
fontSize: 12,
|
|
852
|
+
opacity: 0.85,
|
|
853
|
+
maxWidth: 220,
|
|
854
|
+
overflow: 'hidden',
|
|
855
|
+
textOverflow: 'ellipsis',
|
|
856
|
+
whiteSpace: 'nowrap',
|
|
857
|
+
}}
|
|
858
|
+
title={`${mr.mediaKind} · ${mr.src}`}
|
|
859
|
+
>
|
|
860
|
+
{mr.mediaKind === 'audio' ? '♪ ' : '▶ '}
|
|
861
|
+
{mr.title}
|
|
862
|
+
</span>
|
|
863
|
+
<div className="dc-annot-ctx-sep" />
|
|
864
|
+
<button
|
|
865
|
+
type="button"
|
|
866
|
+
className="dc-annot-ctx-ibtn dc-annot-ctx-ibtn--danger"
|
|
867
|
+
aria-label="Delete media reference"
|
|
868
|
+
title="Delete"
|
|
869
|
+
onClick={remove}
|
|
870
|
+
>
|
|
871
|
+
<IconTrash />
|
|
872
|
+
</button>
|
|
873
|
+
</div>
|
|
874
|
+
);
|
|
875
|
+
}
|
|
833
876
|
if (soleMedia?.tool === 'image') {
|
|
834
877
|
const img = soleMedia;
|
|
835
878
|
const commitAlt = (value: string) => {
|
|
@@ -93,6 +93,12 @@ import {
|
|
|
93
93
|
linkCardLayout,
|
|
94
94
|
listPrefixedBody,
|
|
95
95
|
listPrefixedLine,
|
|
96
|
+
MEDIAREF_AUDIO_GLYPH,
|
|
97
|
+
MEDIAREF_DEFAULT_H,
|
|
98
|
+
MEDIAREF_DEFAULT_W,
|
|
99
|
+
MEDIAREF_VIDEO_GLYPH,
|
|
100
|
+
MEDIAREF_VIDEO_H,
|
|
101
|
+
type MediaRefStroke,
|
|
96
102
|
normalizeBox,
|
|
97
103
|
normalizeRect,
|
|
98
104
|
normalizeSticky,
|
|
@@ -380,6 +386,19 @@ function resolveAssetHref(href: string): string {
|
|
|
380
386
|
return /^assets\//.test(href) ? `/${canvasDesignRel()}/${href}` : href;
|
|
381
387
|
}
|
|
382
388
|
|
|
389
|
+
/**
|
|
390
|
+
* DDR-150 dogfood #8 — true when a pointer/click event targets the inline
|
|
391
|
+
* media player inside a mediaref chip. Every document-capture annotation
|
|
392
|
+
* handler early-returns on it, so the player's native controls (and its
|
|
393
|
+
* click-to-toggle) work instead of starting a stroke select/drag/draw.
|
|
394
|
+
* (NOT a stopPropagation guard — that would also kill React's delegated
|
|
395
|
+
* listeners, which attach later on the capture path.)
|
|
396
|
+
*/
|
|
397
|
+
function isMediaPlayerTarget(e: Event): boolean {
|
|
398
|
+
const t = e.target as Element | null;
|
|
399
|
+
return !!(t && typeof t.closest === 'function' && t.closest('[data-mediaref-player]'));
|
|
400
|
+
}
|
|
401
|
+
|
|
383
402
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
384
403
|
// Styles
|
|
385
404
|
|
|
@@ -1167,15 +1186,63 @@ export function AnnotationsLayer() {
|
|
|
1167
1186
|
[commitStrokes, annotSel]
|
|
1168
1187
|
);
|
|
1169
1188
|
|
|
1189
|
+
// Media reference (DDR-150 P4): a video/audio file dropped on the canvas BODY
|
|
1190
|
+
// becomes a versioned reference chip (NOT a source insert, NOT a played
|
|
1191
|
+
// element) carrying its assets/ path — the "nahazet klipy → agent z toho udělá
|
|
1192
|
+
// video" artifact. Upload to assets/, then commit a MediaRefStroke; on failure
|
|
1193
|
+
// toast. No poster probe in v1 — a media glyph tile (▶/♪) + filename.
|
|
1194
|
+
const createMediaReference = useCallback(
|
|
1195
|
+
(file: File, mediaKind: 'video' | 'audio', world: [number, number]) => {
|
|
1196
|
+
const w = MEDIAREF_DEFAULT_W;
|
|
1197
|
+
// Video chips are taller — they host the inline 16:9 player (dogfood #8).
|
|
1198
|
+
const h = mediaKind === 'video' ? MEDIAREF_VIDEO_H : MEDIAREF_DEFAULT_H;
|
|
1199
|
+
void uploadAsset(file).then((res) => {
|
|
1200
|
+
if (!('path' in res)) {
|
|
1201
|
+
showCanvasToast(`Couldn't add ${mediaKind}: ${res.error}`);
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
const id = rid();
|
|
1205
|
+
const ref: MediaRefStroke = {
|
|
1206
|
+
id,
|
|
1207
|
+
tool: 'mediaref',
|
|
1208
|
+
x: world[0] - w / 2,
|
|
1209
|
+
y: world[1] - h / 2,
|
|
1210
|
+
w,
|
|
1211
|
+
h,
|
|
1212
|
+
src: res.path,
|
|
1213
|
+
mediaKind,
|
|
1214
|
+
title: (file.name || res.path).slice(0, 300),
|
|
1215
|
+
};
|
|
1216
|
+
const before = strokesRef.current;
|
|
1217
|
+
commitStrokes(before, [...before, ref], `add ${mediaKind} reference`);
|
|
1218
|
+
annotSel?.replace([id]);
|
|
1219
|
+
const sizeMb = file.size / (1024 * 1024);
|
|
1220
|
+
showCanvasToast(
|
|
1221
|
+
`Added ${mediaKind} reference · ${res.path}${sizeMb > 20 ? ' · ⚠ >20 MB rides git + sync' : ''}`
|
|
1222
|
+
);
|
|
1223
|
+
});
|
|
1224
|
+
},
|
|
1225
|
+
[commitStrokes, annotSel]
|
|
1226
|
+
);
|
|
1227
|
+
|
|
1170
1228
|
const mediaCallbacks = useMemo(
|
|
1171
|
-
() => ({ onImage: createImageFromFile, onLink: createLink }),
|
|
1172
|
-
[createImageFromFile, createLink]
|
|
1229
|
+
() => ({ onImage: createImageFromFile, onLink: createLink, onMedia: createMediaReference }),
|
|
1230
|
+
[createImageFromFile, createLink, createMediaReference]
|
|
1173
1231
|
);
|
|
1174
1232
|
// Media intake is paste/drop only (per product steer — no toolbar buttons):
|
|
1175
1233
|
// drop an image / URL or Cmd+V a clipboard image / link straight onto the
|
|
1176
1234
|
// canvas. The hook owns the dragover/drop/paste wiring; the create callbacks
|
|
1177
1235
|
// hold the commit/undo sink + screenToWorld.
|
|
1178
|
-
|
|
1236
|
+
//
|
|
1237
|
+
// DDR-150 dogfood #8 — intake was gated on `visible` (annotations toggled on),
|
|
1238
|
+
// so with annotations hidden (⇧P) a Finder drop silently did NOTHING. Intake
|
|
1239
|
+
// now stays live whenever we're not presenting; the committed stroke simply
|
|
1240
|
+
// shows once annotations are visible again.
|
|
1241
|
+
useCanvasMediaDrop({
|
|
1242
|
+
enabled: !(chrome?.present ?? false),
|
|
1243
|
+
screenToWorld,
|
|
1244
|
+
callbacks: mediaCallbacks,
|
|
1245
|
+
});
|
|
1179
1246
|
|
|
1180
1247
|
const eraseAt = useCallback(
|
|
1181
1248
|
(wx: number, wy: number) => {
|
|
@@ -1636,6 +1703,7 @@ export function AnnotationsLayer() {
|
|
|
1636
1703
|
t === 'sticky' ||
|
|
1637
1704
|
t === 'image' ||
|
|
1638
1705
|
t === 'link' ||
|
|
1706
|
+
t === 'mediaref' ||
|
|
1639
1707
|
t === 'section')
|
|
1640
1708
|
) {
|
|
1641
1709
|
return id;
|
|
@@ -1644,6 +1712,7 @@ export function AnnotationsLayer() {
|
|
|
1644
1712
|
};
|
|
1645
1713
|
|
|
1646
1714
|
const onDown = (e: PointerEvent) => {
|
|
1715
|
+
if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
|
|
1647
1716
|
if (e.button !== 0) return;
|
|
1648
1717
|
if (e.metaKey || e.ctrlKey) return; // escape hatch into element-selection
|
|
1649
1718
|
const target = e.target as Element | null;
|
|
@@ -1937,6 +2006,7 @@ export function AnnotationsLayer() {
|
|
|
1937
2006
|
if (typeof document === 'undefined') return;
|
|
1938
2007
|
if (tool !== 'move') return;
|
|
1939
2008
|
const onDbl = (e: MouseEvent) => {
|
|
2009
|
+
if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
|
|
1940
2010
|
const target = e.target as Element | null;
|
|
1941
2011
|
const node = target?.closest?.('[data-id][data-tool]');
|
|
1942
2012
|
if (!node) return;
|
|
@@ -2460,6 +2530,7 @@ export function AnnotationsLayer() {
|
|
|
2460
2530
|
if (typeof document === 'undefined') return;
|
|
2461
2531
|
if (tool !== 'move') return;
|
|
2462
2532
|
const onDown = (e: PointerEvent) => {
|
|
2533
|
+
if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
|
|
2463
2534
|
if (e.button !== 0) return;
|
|
2464
2535
|
const dot = (e.target as Element | null)?.closest?.('.dc-annot-conn-dot');
|
|
2465
2536
|
if (!dot) return;
|
|
@@ -2575,6 +2646,7 @@ export function AnnotationsLayer() {
|
|
|
2575
2646
|
// propagation WITHOUT preventDefault, so the native contextmenu event
|
|
2576
2647
|
// (which opens OUR menu above) still follows.
|
|
2577
2648
|
const onDown = (e: PointerEvent) => {
|
|
2649
|
+
if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
|
|
2578
2650
|
if (e.button !== 2) return;
|
|
2579
2651
|
if (!strokeAt(e.target as Element | null)) return;
|
|
2580
2652
|
e.stopImmediatePropagation();
|
|
@@ -2724,6 +2796,10 @@ export function AnnotationsLayer() {
|
|
|
2724
2796
|
onCancelEdit={cancelEditing}
|
|
2725
2797
|
/>
|
|
2726
2798
|
) : null}
|
|
2799
|
+
{/* DDR-150 dogfood #8 — inline players for media-reference chips (HTML
|
|
2800
|
+
overlay in the world div; see MediaRefPlayers for why not
|
|
2801
|
+
foreignObject). */}
|
|
2802
|
+
<MediaRefPlayers worldRef={worldRef} strokes={renderStrokes} visible={visible} />
|
|
2727
2803
|
<AnnotationContextToolbar
|
|
2728
2804
|
editingId={
|
|
2729
2805
|
editingTarget?.kind === 'anchored'
|
|
@@ -3110,6 +3186,92 @@ function AnnotationsSvg({
|
|
|
3110
3186
|
);
|
|
3111
3187
|
}
|
|
3112
3188
|
|
|
3189
|
+
/**
|
|
3190
|
+
* DDR-150 dogfood #8 — the mediaref chips' inline players, rendered as PLAIN
|
|
3191
|
+
* HTML absolutely positioned in the world div (which carries the pan/zoom CSS
|
|
3192
|
+
* transform), NOT as SVG foreignObject: Chromium hit-tests foreignObject
|
|
3193
|
+
* content under a transformed ancestor in the un-transformed coordinate space,
|
|
3194
|
+
* so real clicks miss the player at most zoom levels. HTML children of the
|
|
3195
|
+
* transformed div hit-test correctly. The [data-mediaref-player] attr keeps
|
|
3196
|
+
* every document-capture annotation handler out (isMediaPlayerTarget guard).
|
|
3197
|
+
*/
|
|
3198
|
+
function MediaRefPlayers({
|
|
3199
|
+
worldRef,
|
|
3200
|
+
strokes,
|
|
3201
|
+
visible,
|
|
3202
|
+
}: {
|
|
3203
|
+
worldRef: React.RefObject<HTMLElement | null>;
|
|
3204
|
+
strokes: readonly Stroke[];
|
|
3205
|
+
visible: boolean;
|
|
3206
|
+
}) {
|
|
3207
|
+
const target = worldRef.current;
|
|
3208
|
+
if (!target || !visible) return null;
|
|
3209
|
+
const HEADER = 26;
|
|
3210
|
+
const refs = strokes.filter(
|
|
3211
|
+
(s): s is MediaRefStroke => s.tool === 'mediaref' && !!s.src && Math.abs(s.h) > HEADER + 12
|
|
3212
|
+
);
|
|
3213
|
+
if (refs.length === 0) return null;
|
|
3214
|
+
return createPortal(
|
|
3215
|
+
<>
|
|
3216
|
+
{refs.map((s) => {
|
|
3217
|
+
const x = Math.min(s.x, s.x + s.w);
|
|
3218
|
+
const y = Math.min(s.y, s.y + s.h);
|
|
3219
|
+
const w = Math.abs(s.w);
|
|
3220
|
+
const h = Math.abs(s.h);
|
|
3221
|
+
const mediaUrl = resolveAssetHref(s.src);
|
|
3222
|
+
const isAudio = s.mediaKind === 'audio';
|
|
3223
|
+
return (
|
|
3224
|
+
<div
|
|
3225
|
+
key={`mrp-${s.id}`}
|
|
3226
|
+
data-mediaref-player="1"
|
|
3227
|
+
style={{
|
|
3228
|
+
position: 'absolute',
|
|
3229
|
+
left: x + 4,
|
|
3230
|
+
top: y + HEADER,
|
|
3231
|
+
width: Math.max(8, w - 8),
|
|
3232
|
+
height: Math.max(8, h - HEADER - 4),
|
|
3233
|
+
borderRadius: 6,
|
|
3234
|
+
overflow: 'hidden',
|
|
3235
|
+
zIndex: 4,
|
|
3236
|
+
}}
|
|
3237
|
+
>
|
|
3238
|
+
{isAudio ? (
|
|
3239
|
+
// biome-ignore lint/a11y/useMediaCaption: user-dropped reference media — there is no caption source to point a <track> at.
|
|
3240
|
+
<audio
|
|
3241
|
+
controls
|
|
3242
|
+
preload="metadata"
|
|
3243
|
+
src={mediaUrl}
|
|
3244
|
+
style={{ width: '100%', height: '100%' }}
|
|
3245
|
+
/>
|
|
3246
|
+
) : (
|
|
3247
|
+
// biome-ignore lint/a11y/useMediaCaption: user-dropped reference media — no caption source exists for an arbitrary dragged-in clip.
|
|
3248
|
+
<video
|
|
3249
|
+
// Chromium's native controls already toggle play/pause on a
|
|
3250
|
+
// content-area click (a custom click listener would double-fire
|
|
3251
|
+
// against it and cancel itself out — verified live). The router
|
|
3252
|
+
// overlay-skip + the annotation-handler guards are what make
|
|
3253
|
+
// these native interactions reachable.
|
|
3254
|
+
controls
|
|
3255
|
+
preload="metadata"
|
|
3256
|
+
playsInline
|
|
3257
|
+
src={mediaUrl}
|
|
3258
|
+
style={{
|
|
3259
|
+
width: '100%',
|
|
3260
|
+
height: '100%',
|
|
3261
|
+
objectFit: 'contain',
|
|
3262
|
+
background: '#000',
|
|
3263
|
+
display: 'block',
|
|
3264
|
+
}}
|
|
3265
|
+
/>
|
|
3266
|
+
)}
|
|
3267
|
+
</div>
|
|
3268
|
+
);
|
|
3269
|
+
})}
|
|
3270
|
+
</>,
|
|
3271
|
+
target
|
|
3272
|
+
);
|
|
3273
|
+
}
|
|
3274
|
+
|
|
3113
3275
|
function TextEditor({
|
|
3114
3276
|
anchorId,
|
|
3115
3277
|
host,
|
|
@@ -3165,6 +3327,7 @@ function TextEditor({
|
|
|
3165
3327
|
useEffect(() => {
|
|
3166
3328
|
if (typeof document === 'undefined') return;
|
|
3167
3329
|
const onDown = (e: PointerEvent) => {
|
|
3330
|
+
if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
|
|
3168
3331
|
const el = ref.current;
|
|
3169
3332
|
if (!el) return;
|
|
3170
3333
|
if (el.contains(e.target as Node)) return;
|
|
@@ -3427,6 +3590,7 @@ function StandaloneTextEditor({
|
|
|
3427
3590
|
useEffect(() => {
|
|
3428
3591
|
if (typeof document === 'undefined') return;
|
|
3429
3592
|
const onDown = (e: PointerEvent) => {
|
|
3593
|
+
if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
|
|
3430
3594
|
const el = ref.current;
|
|
3431
3595
|
if (!el) return;
|
|
3432
3596
|
if (el.contains(e.target as Node)) return;
|
|
@@ -3609,6 +3773,7 @@ function AnnotationContextMenu({
|
|
|
3609
3773
|
if (nx !== at.x || ny !== at.y) setAt({ x: nx, y: ny });
|
|
3610
3774
|
el.querySelector<HTMLButtonElement>('button.dc-menu-item:not([disabled])')?.focus();
|
|
3611
3775
|
const onDown = (e: PointerEvent) => {
|
|
3776
|
+
if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
|
|
3612
3777
|
if (!el.contains(e.target as Node)) onClose();
|
|
3613
3778
|
};
|
|
3614
3779
|
const onKey = (e: KeyboardEvent) => {
|
|
@@ -4176,6 +4341,91 @@ function StrokeNodeBase({
|
|
|
4176
4341
|
</g>
|
|
4177
4342
|
);
|
|
4178
4343
|
}
|
|
4344
|
+
if (stroke.tool === 'mediaref') {
|
|
4345
|
+
// DDR-150 P4 + dogfood #8 — reference chip with a REAL inline player.
|
|
4346
|
+
// LIVE-RENDER ONLY: the <foreignObject> + <video>/<audio> below never
|
|
4347
|
+
// persist — the model serializer still writes the sanitizer-safe data-*
|
|
4348
|
+
// card (foreignObject is stripped by sanitizeAnnotationSvg by design).
|
|
4349
|
+
// The 26px header strip (badge + filename) is the select/drag handle; the
|
|
4350
|
+
// player area is fenced off from the annotation handlers by the
|
|
4351
|
+
// [data-mediaref-player] window-capture guard.
|
|
4352
|
+
const x = Math.min(stroke.x, stroke.x + stroke.w);
|
|
4353
|
+
const y = Math.min(stroke.y, stroke.y + stroke.h);
|
|
4354
|
+
const w = Math.abs(stroke.w);
|
|
4355
|
+
const h = Math.abs(stroke.h);
|
|
4356
|
+
const HEADER = 26;
|
|
4357
|
+
const isAudio = stroke.mediaKind === 'audio';
|
|
4358
|
+
const mediaUrl = stroke.src ? resolveAssetHref(stroke.src) : '';
|
|
4359
|
+
const shownTitle = clampLinkTitle(stroke.title, Math.max(8, Math.floor((w - 40) / 7)));
|
|
4360
|
+
const textFont = {
|
|
4361
|
+
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
4362
|
+
} as const;
|
|
4363
|
+
return (
|
|
4364
|
+
<g
|
|
4365
|
+
data-id={stroke.id}
|
|
4366
|
+
data-tool="mediaref"
|
|
4367
|
+
data-src={stroke.src}
|
|
4368
|
+
data-media-kind={stroke.mediaKind}
|
|
4369
|
+
data-title={stroke.title}
|
|
4370
|
+
pointerEvents={hitMode}
|
|
4371
|
+
>
|
|
4372
|
+
<rect
|
|
4373
|
+
x={x}
|
|
4374
|
+
y={y}
|
|
4375
|
+
width={w}
|
|
4376
|
+
height={h}
|
|
4377
|
+
rx={8}
|
|
4378
|
+
ry={8}
|
|
4379
|
+
fill={LINK_CARD_FILL}
|
|
4380
|
+
stroke={LINK_CARD_STROKE}
|
|
4381
|
+
strokeWidth={1}
|
|
4382
|
+
vectorEffect="non-scaling-stroke"
|
|
4383
|
+
filter="url(#dc-sticky-shadow)"
|
|
4384
|
+
/>
|
|
4385
|
+
<svg
|
|
4386
|
+
x={x + 8}
|
|
4387
|
+
y={y + 5}
|
|
4388
|
+
width={16}
|
|
4389
|
+
height={16}
|
|
4390
|
+
viewBox="0 0 24 24"
|
|
4391
|
+
fill={LINK_GLYPH_STROKE}
|
|
4392
|
+
stroke="none"
|
|
4393
|
+
aria-hidden="true"
|
|
4394
|
+
>
|
|
4395
|
+
<path d={isAudio ? MEDIAREF_AUDIO_GLYPH : MEDIAREF_VIDEO_GLYPH} />
|
|
4396
|
+
</svg>
|
|
4397
|
+
<text
|
|
4398
|
+
x={x + 30}
|
|
4399
|
+
y={y + 9}
|
|
4400
|
+
fontSize={11}
|
|
4401
|
+
fill={LINK_TITLE_FILL}
|
|
4402
|
+
fontWeight={600}
|
|
4403
|
+
dominantBaseline="hanging"
|
|
4404
|
+
style={textFont}
|
|
4405
|
+
>
|
|
4406
|
+
{shownTitle}
|
|
4407
|
+
</text>
|
|
4408
|
+
{/* The inline player itself is an HTML overlay portaled beside this SVG
|
|
4409
|
+
(MediaRefPlayers below) — NOT a foreignObject: Chromium hit-tests
|
|
4410
|
+
foreignObject content under a CSS-transformed ancestor in the WRONG
|
|
4411
|
+
coordinate space (the un-panned/un-zoomed one), so real clicks miss
|
|
4412
|
+
the player at most zoom levels while elementFromPoint lies that
|
|
4413
|
+
they'd land. Plain HTML in the transformed world hit-tests right. */}
|
|
4414
|
+
{!mediaUrl ? (
|
|
4415
|
+
<text
|
|
4416
|
+
x={x + 30}
|
|
4417
|
+
y={y + HEADER + 10}
|
|
4418
|
+
fontSize={10}
|
|
4419
|
+
fill={LINK_DOMAIN_FILL}
|
|
4420
|
+
dominantBaseline="hanging"
|
|
4421
|
+
style={textFont}
|
|
4422
|
+
>
|
|
4423
|
+
(missing media reference)
|
|
4424
|
+
</text>
|
|
4425
|
+
) : null}
|
|
4426
|
+
</g>
|
|
4427
|
+
);
|
|
4428
|
+
}
|
|
4179
4429
|
if (stroke.tool === 'section') {
|
|
4180
4430
|
const x = Math.min(stroke.x, stroke.x + stroke.w);
|
|
4181
4431
|
const y = Math.min(stroke.y, stroke.y + stroke.h);
|
|
@@ -266,6 +266,30 @@ export interface LinkStroke extends StrokeBase {
|
|
|
266
266
|
title: string;
|
|
267
267
|
domain: string;
|
|
268
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* DDR-150 P4 — a video/audio file dropped on the canvas BODY (not the timeline)
|
|
271
|
+
* as a reference chip. It's the "nahazet klipy → agent z toho udělá video"
|
|
272
|
+
* artifact: a non-destructive, versioned pointer to an `assets/…` clip the agent
|
|
273
|
+
* can enumerate off the saved `.annotations.svg` (via `data-src`), then assemble
|
|
274
|
+
* into a comp. Distinct from a timeline drop (which INSERTS a `<Sequence>`) and
|
|
275
|
+
* from an ImageStroke (a rendered picture). Renders as a card: a media glyph
|
|
276
|
+
* (▶ video / ♪ audio) + the filename. `src` is ALWAYS a relative `assets/<sha8>`
|
|
277
|
+
* path (never seeked by the capture spine — it's a reference, excluded from
|
|
278
|
+
* export by `?hide-chrome`). Persists as an allowlisted `<g>` like LinkStroke —
|
|
279
|
+
* `data-src`/`data-media-kind`/`data-title` are the round-trip source of truth.
|
|
280
|
+
*/
|
|
281
|
+
export interface MediaRefStroke extends StrokeBase {
|
|
282
|
+
tool: 'mediaref';
|
|
283
|
+
x: number;
|
|
284
|
+
y: number;
|
|
285
|
+
w: number;
|
|
286
|
+
h: number;
|
|
287
|
+
/** Relative `assets/<sha8>.<ext>` path — the agent reads this off the SVG. */
|
|
288
|
+
src: string;
|
|
289
|
+
mediaKind: 'video' | 'audio';
|
|
290
|
+
/** Human-facing label (the dropped file's name). */
|
|
291
|
+
title: string;
|
|
292
|
+
}
|
|
269
293
|
/**
|
|
270
294
|
* FigJam v3 — section: a labelled organizing container. Renders as a soft
|
|
271
295
|
* rounded region with a name chip above the top-left corner; its INTERIOR is
|
|
@@ -294,6 +318,7 @@ export type Stroke =
|
|
|
294
318
|
| StickyStroke
|
|
295
319
|
| ImageStroke
|
|
296
320
|
| LinkStroke
|
|
321
|
+
| MediaRefStroke
|
|
297
322
|
| SectionStroke;
|
|
298
323
|
|
|
299
324
|
/**
|
|
@@ -528,6 +553,11 @@ export const IMAGE_MIN_SIZE = 16;
|
|
|
528
553
|
export const IMAGE_MAX_DROP_SIDE = 480;
|
|
529
554
|
export const LINK_DEFAULT_W = 260;
|
|
530
555
|
export const LINK_DEFAULT_H = 76;
|
|
556
|
+
// DDR-150 P4 — media-reference chip (dropped video/audio → assets/ pointer).
|
|
557
|
+
export const MEDIAREF_DEFAULT_W = 280;
|
|
558
|
+
export const MEDIAREF_DEFAULT_H = 76;
|
|
559
|
+
/** Video chips are taller: 26px header + a 16:9 inline player area (dogfood #8). */
|
|
560
|
+
export const MEDIAREF_VIDEO_H = 190;
|
|
531
561
|
export const LINK_CARD_FILL = '#ffffff';
|
|
532
562
|
export const LINK_CARD_STROKE = '#d4d4d8';
|
|
533
563
|
export const LINK_DOMAIN_FILL = '#71717a';
|
|
@@ -539,6 +569,12 @@ export const LINK_GLYPH_STROKE = '#52525b';
|
|
|
539
569
|
// <rect> geometry), so render/serialize only need to agree visually.
|
|
540
570
|
export const LINK_GLYPH_D1 = 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71';
|
|
541
571
|
export const LINK_GLYPH_D2 = 'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71';
|
|
572
|
+
// DDR-150 P4 — media-reference chip glyphs (24×24 viewBox, filled). One source
|
|
573
|
+
// for serialize + StrokeNode render, like the link glyph. Video = play triangle;
|
|
574
|
+
// audio = a filled note. The parser ignores the glyph (reads data-* + geometry).
|
|
575
|
+
export const MEDIAREF_VIDEO_GLYPH = 'M8 5v14l11-7z';
|
|
576
|
+
export const MEDIAREF_AUDIO_GLYPH =
|
|
577
|
+
'M9 18V6l10-2v11.5a2.5 2.5 0 1 1-2-2.45V7.3L11 8.6v6.9a2.5 2.5 0 1 1-2-2.45z';
|
|
542
578
|
|
|
543
579
|
/** Card text positions, derived purely from the bbox (idempotent round-trip). */
|
|
544
580
|
export function linkCardLayout(x: number, y: number, w: number, h: number) {
|
|
@@ -1038,6 +1074,37 @@ function strokeToSvgElBase(s: Stroke): string {
|
|
|
1038
1074
|
`</g>`
|
|
1039
1075
|
);
|
|
1040
1076
|
}
|
|
1077
|
+
if (s.tool === 'mediaref') {
|
|
1078
|
+
// DDR-150 P4 — reference chip for a dropped clip. Like the link card: the
|
|
1079
|
+
// data-* are the round-trip source of truth (the agent reads data-src to
|
|
1080
|
+
// enumerate refs); the inner rect/glyph/text are the inert, sanitizer-safe
|
|
1081
|
+
// visual. NEVER a <Video>/<Audio> element (that would try to play/seek) —
|
|
1082
|
+
// it's a pointer, drawn as a still card.
|
|
1083
|
+
const nx = Math.min(s.x, s.x + s.w);
|
|
1084
|
+
const ny = Math.min(s.y, s.y + s.h);
|
|
1085
|
+
const nw = Math.abs(s.w);
|
|
1086
|
+
const nh = Math.abs(s.h);
|
|
1087
|
+
const lay = linkCardLayout(nx, ny, nw, nh);
|
|
1088
|
+
const shownTitle = clampLinkTitle(s.title, lay.textMaxChars);
|
|
1089
|
+
const glyph =
|
|
1090
|
+
s.mediaKind === 'audio'
|
|
1091
|
+
? `<path d="${MEDIAREF_AUDIO_GLYPH}"/>`
|
|
1092
|
+
: `<path d="${MEDIAREF_VIDEO_GLYPH}"/>`;
|
|
1093
|
+
return (
|
|
1094
|
+
`<g data-id="${esc(s.id)}" data-tool="mediaref" data-src="${escAttr(s.src)}" data-media-kind="${escAttr(
|
|
1095
|
+
s.mediaKind
|
|
1096
|
+
)}" data-title="${escAttr(s.title)}">` +
|
|
1097
|
+
`<rect x="${nx}" y="${ny}" width="${nw}" height="${nh}" rx="8" ry="8" fill="${LINK_CARD_FILL}" stroke="${LINK_CARD_STROKE}" stroke-width="1"/>` +
|
|
1098
|
+
`<svg x="${lay.glyph.x}" y="${lay.glyph.y}" width="${lay.glyph.size}" height="${lay.glyph.size}" viewBox="0 0 24 24" fill="${LINK_GLYPH_STROKE}" stroke="none">${glyph}</svg>` +
|
|
1099
|
+
`<text x="${lay.textX}" y="${lay.domain.y}" font-size="${lay.domain.fontSize}" fill="${LINK_DOMAIN_FILL}" dominant-baseline="hanging">${esc(
|
|
1100
|
+
`${s.mediaKind} · reference`
|
|
1101
|
+
)}</text>` +
|
|
1102
|
+
`<text x="${lay.textX}" y="${lay.title.y}" font-size="${lay.title.fontSize}" fill="${LINK_TITLE_FILL}" font-weight="600" dominant-baseline="hanging">${esc(
|
|
1103
|
+
shownTitle
|
|
1104
|
+
)}</text>` +
|
|
1105
|
+
`</g>`
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
1041
1108
|
if (s.tool === 'section') {
|
|
1042
1109
|
// FigJam v3 — inert persisted form: region rect + chip label text. Colors
|
|
1043
1110
|
// are render-time chrome; the parser reads geometry off the rect and the
|
|
@@ -1463,6 +1530,22 @@ export function svgToStrokes(svgText: string): Stroke[] {
|
|
|
1463
1530
|
push({ id, tool: 'link', x, y, w, h, url, title, domain });
|
|
1464
1531
|
continue;
|
|
1465
1532
|
}
|
|
1533
|
+
if (tool === 'mediaref') {
|
|
1534
|
+
// DDR-150 P4 — geometry off the <rect> child (mirrors link/sticky);
|
|
1535
|
+
// data-src is the pointer the agent enumerates. A src that fails the
|
|
1536
|
+
// assets/ shape is dropped to '' (an inert chip that references nothing).
|
|
1537
|
+
const rectEl = el.querySelector('rect');
|
|
1538
|
+
const x = pfloat(rectEl?.getAttribute('x'));
|
|
1539
|
+
const y = pfloat(rectEl?.getAttribute('y'));
|
|
1540
|
+
const w = pfloat(rectEl?.getAttribute('width'), MEDIAREF_DEFAULT_W);
|
|
1541
|
+
const h = pfloat(rectEl?.getAttribute('height'), MEDIAREF_DEFAULT_H);
|
|
1542
|
+
const rawSrc = el.getAttribute('data-src') || '';
|
|
1543
|
+
const src = ASSET_MEDIA_SRC_RE.test(rawSrc) ? rawSrc : '';
|
|
1544
|
+
const mediaKind = el.getAttribute('data-media-kind') === 'audio' ? 'audio' : 'video';
|
|
1545
|
+
const title = el.getAttribute('data-title') || src;
|
|
1546
|
+
push({ id, tool: 'mediaref', x, y, w, h, src, mediaKind, title });
|
|
1547
|
+
continue;
|
|
1548
|
+
}
|
|
1466
1549
|
if (tool === 'text') {
|
|
1467
1550
|
const rawAnchor = el.getAttribute('data-anchor-id');
|
|
1468
1551
|
const fontSize =
|
|
@@ -1650,8 +1733,8 @@ export function strokeHitTest(s: Stroke, wx: number, wy: number, tol: number): b
|
|
|
1650
1733
|
return onEdge;
|
|
1651
1734
|
}
|
|
1652
1735
|
const t = Math.max(tol, 'width' in s ? s.width : 2);
|
|
1653
|
-
if (s.tool === 'sticky' || s.tool === 'image' || s.tool === 'link') {
|
|
1654
|
-
// Sticky / image / link are solid cards — filled-rect hit
|
|
1736
|
+
if (s.tool === 'sticky' || s.tool === 'image' || s.tool === 'link' || s.tool === 'mediaref') {
|
|
1737
|
+
// Sticky / image / link / mediaref are solid cards — filled-rect hit inside.
|
|
1655
1738
|
const xMin = Math.min(s.x, s.x + s.w);
|
|
1656
1739
|
const xMax = Math.max(s.x, s.x + s.w);
|
|
1657
1740
|
const yMin = Math.min(s.y, s.y + s.h);
|
|
@@ -1844,6 +1927,8 @@ export function isStrokeMeaningful(s: Stroke): boolean {
|
|
|
1844
1927
|
// Phase 23 — an image needs real extent; a link needs a non-empty URL.
|
|
1845
1928
|
if (s.tool === 'image') return Math.abs(s.w) >= IMAGE_MIN_SIZE && Math.abs(s.h) >= IMAGE_MIN_SIZE;
|
|
1846
1929
|
if (s.tool === 'link') return s.url.trim().length > 0;
|
|
1930
|
+
// A media-reference chip needs a non-empty assets/ src (its whole purpose).
|
|
1931
|
+
if (s.tool === 'mediaref') return s.src.trim().length > 0;
|
|
1847
1932
|
if (s.tool === 'section')
|
|
1848
1933
|
return Math.abs(s.w) >= SECTION_MIN_SIZE && Math.abs(s.h) >= SECTION_MIN_SIZE;
|
|
1849
1934
|
return Math.hypot(s.x2 - s.x1, s.y2 - s.y1) >= 4;
|
|
@@ -1886,9 +1971,15 @@ export function strokeBBox(
|
|
|
1886
1971
|
h: Math.abs(s.y2 - s.y1),
|
|
1887
1972
|
};
|
|
1888
1973
|
}
|
|
1889
|
-
if (
|
|
1890
|
-
|
|
1891
|
-
|
|
1974
|
+
if (
|
|
1975
|
+
s.tool === 'sticky' ||
|
|
1976
|
+
s.tool === 'image' ||
|
|
1977
|
+
s.tool === 'link' ||
|
|
1978
|
+
s.tool === 'mediaref' ||
|
|
1979
|
+
s.tool === 'section'
|
|
1980
|
+
) {
|
|
1981
|
+
// Phase 23 — image + link + mediaref are rect-shaped cards, same bbox as a
|
|
1982
|
+
// sticky; FigJam v3 sections share it too.
|
|
1892
1983
|
return {
|
|
1893
1984
|
x: Math.min(s.x, s.x + s.w),
|
|
1894
1985
|
y: Math.min(s.y, s.y + s.h),
|
|
@@ -1928,7 +2019,13 @@ export function translateOne(s: Stroke, dx: number, dy: number): Stroke {
|
|
|
1928
2019
|
if (s.tool === 'ellipse') return { ...s, cx: s.cx + dx, cy: s.cy + dy };
|
|
1929
2020
|
if (s.tool === 'arrow')
|
|
1930
2021
|
return { ...s, x1: s.x1 + dx, y1: s.y1 + dy, x2: s.x2 + dx, y2: s.y2 + dy };
|
|
1931
|
-
if (
|
|
2022
|
+
if (
|
|
2023
|
+
s.tool === 'sticky' ||
|
|
2024
|
+
s.tool === 'image' ||
|
|
2025
|
+
s.tool === 'link' ||
|
|
2026
|
+
s.tool === 'mediaref' ||
|
|
2027
|
+
s.tool === 'section'
|
|
2028
|
+
)
|
|
1932
2029
|
return { ...s, x: s.x + dx, y: s.y + dy };
|
|
1933
2030
|
// text — anchored inherits its host's bbox (moves with the host); standalone
|
|
1934
2031
|
// (Phase 21) carries its own world (x, y) and translates directly.
|
|
@@ -1997,6 +2094,10 @@ export const ANNOTATION_SVG_ELEMENTS = new Set([
|
|
|
1997
2094
|
* is stripped by Rule 3. Pairs with the asset-write caps (DDR Task 9).
|
|
1998
2095
|
*/
|
|
1999
2096
|
export const ASSET_IMAGE_HREF_RE = /^assets\/[A-Za-z0-9._-]+\.(?:png|jpe?g|webp|gif)$/;
|
|
2097
|
+
// DDR-150 P4 — a media-reference chip's data-src: a relative assets/ path to a
|
|
2098
|
+
// video/audio clip (no scheme, no traversal). Same shape as the image href gate.
|
|
2099
|
+
export const ASSET_MEDIA_SRC_RE =
|
|
2100
|
+
/^assets\/[A-Za-z0-9._-]+\.(?:mp4|webm|mov|m4v|mp3|wav|m4a|aac|ogg)$/;
|
|
2000
2101
|
|
|
2001
2102
|
/**
|
|
2002
2103
|
* A3 (DDR-060 F1 re-audit) — sanitize an annotation SVG before it is persisted /
|