@1agh/maude 0.39.0 → 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/acp/bridge.ts +144 -5
- package/apps/studio/acp/index.ts +11 -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/acp-bridge.test.ts +132 -0
- package/apps/studio/test/acp-commands.test.ts +8 -3
- 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/mock-acp-agent.mjs +24 -1
- 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 +36 -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.
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// (env.ts): the child inherits the environment MINUS `ANTHROPIC_API_KEY`, so
|
|
9
9
|
// auth precedence falls through to the user's Pro/Max subscription.
|
|
10
10
|
|
|
11
|
-
import { appendFile, mkdir } from 'node:fs/promises';
|
|
11
|
+
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
12
12
|
import { dirname } from 'node:path';
|
|
13
13
|
|
|
14
14
|
import {
|
|
@@ -71,6 +71,29 @@ const EFFORT_THINKING_TOKENS: Record<string, number | null> = {
|
|
|
71
71
|
|
|
72
72
|
export type AcpEffort = keyof typeof EFFORT_THINKING_TOKENS;
|
|
73
73
|
|
|
74
|
+
// Real sessionIds are adapter-generated `randomUUID()`s. A persisted value that
|
|
75
|
+
// doesn't look like one (corrupt sidecar, or a tracked file a cloned repo
|
|
76
|
+
// shipped despite `_chat/` being gitignored — DDR-115) is rejected rather than
|
|
77
|
+
// forwarded into the privileged `loadSession` ACP call.
|
|
78
|
+
const VALID_SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
79
|
+
|
|
80
|
+
// `loadSession`'s replay can, in principle, never settle if the underlying
|
|
81
|
+
// transport dies mid-call (adapter crash, a concurrent `stop()` from another
|
|
82
|
+
// chat sharing this bridge). Bound it so `replaying` always resets and a
|
|
83
|
+
// resume attempt always falls back to `newSession` instead of wedging the
|
|
84
|
+
// bridge silent forever. Mirrors the `withTimeout`/`TIMED_OUT` pattern already
|
|
85
|
+
// used for network calls in `apps/studio/git/service.ts`.
|
|
86
|
+
const LOAD_SESSION_TIMEOUT_MS = 15_000;
|
|
87
|
+
const TIMED_OUT = Symbol('maude-acp-load-session-timeout');
|
|
88
|
+
function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | typeof TIMED_OUT> {
|
|
89
|
+
p.catch(() => {});
|
|
90
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
91
|
+
const t = new Promise<typeof TIMED_OUT>((res) => {
|
|
92
|
+
timer = setTimeout(() => res(TIMED_OUT), ms);
|
|
93
|
+
});
|
|
94
|
+
return Promise.race([p, t]).finally(() => clearTimeout(timer));
|
|
95
|
+
}
|
|
96
|
+
|
|
74
97
|
/**
|
|
75
98
|
* Build the `session/new` params, carrying TWO adapter-internal `_meta` payloads
|
|
76
99
|
* (both spread by the installed `claude-agent-acp@0.49.x` `newSession`):
|
|
@@ -90,6 +113,10 @@ export type AcpEffort = keyof typeof EFFORT_THINKING_TOKENS;
|
|
|
90
113
|
* must fail the presence tests LOUDLY (acp-bootstrap-brief.test.ts +
|
|
91
114
|
* acp-session-plugins.test.ts), not silently un-brief / un-plugin every session.
|
|
92
115
|
* Exported for those tests.
|
|
116
|
+
*
|
|
117
|
+
* `cwd`/`mcpServers`/`_meta` are also exactly the shared fields of a
|
|
118
|
+
* `LoadSessionRequest` (schema/types.gen.d.ts) — `sessionFor`'s resume path
|
|
119
|
+
* spreads this same object and adds `sessionId` rather than duplicating it.
|
|
93
120
|
*/
|
|
94
121
|
export function newSessionParams(
|
|
95
122
|
repoRoot: string,
|
|
@@ -136,12 +163,21 @@ export class AcpBridge {
|
|
|
136
163
|
// context. The adapter (one subprocess) holds them all; switching chats reuses
|
|
137
164
|
// the session, so claude remembers that chat while the app is open.
|
|
138
165
|
private sessions = new Map<string, string>(); // chatId → sessionId
|
|
166
|
+
// In-flight sessionFor() calls, keyed by chatId — lets a `warm` and a `prompt`
|
|
167
|
+
// racing for the same chat share one resume/create attempt instead of each
|
|
168
|
+
// running establishSession() and stomping the single shared `replaying` flag.
|
|
169
|
+
private sessionPromises = new Map<string, Promise<string>>();
|
|
139
170
|
private currentSession: string | null = null; // the in-flight prompt's session
|
|
140
171
|
/** Sessions whose bootstrap brief already hit the transcript (audit record). */
|
|
141
172
|
private briefLogged = new Set<string>();
|
|
142
173
|
private starting: Promise<void> | null = null;
|
|
143
174
|
/** Per-chat transcript file (`_chat/<id>.jsonl`); set per prompt. */
|
|
144
175
|
private transcriptPath: string | null = null;
|
|
176
|
+
/** Sidecar persisting this chat's ACP sessionId across restarts (`_chat/<id>.session.json`). */
|
|
177
|
+
private sessionStorePath: string | null = null;
|
|
178
|
+
/** True while `conn.loadSession()` is replaying a resumed session's history
|
|
179
|
+
* back through the `sessionUpdate` client callback — see the guard in `start()`. */
|
|
180
|
+
private replaying = false;
|
|
145
181
|
// Model + effort are env-at-spawn (ANTHROPIC_MODEL / MAX_THINKING_TOKENS), so a
|
|
146
182
|
// change re-spawns the adapter. `desired*` is what the UI asked for; `active*`
|
|
147
183
|
// is what the running session was spawned with.
|
|
@@ -165,6 +201,10 @@ export class AcpBridge {
|
|
|
165
201
|
this.transcriptPath = path;
|
|
166
202
|
}
|
|
167
203
|
|
|
204
|
+
setSessionStorePath(path: string | null): void {
|
|
205
|
+
this.sessionStorePath = path;
|
|
206
|
+
}
|
|
207
|
+
|
|
168
208
|
/** Desired model (alias/id, or null for the user's default) + effort. Applied
|
|
169
209
|
* on the next prompt — re-spawning the adapter only if it actually changed. */
|
|
170
210
|
setConfig(model: string | null, effort: AcpEffort): void {
|
|
@@ -187,18 +227,104 @@ export class AcpBridge {
|
|
|
187
227
|
return this.starting;
|
|
188
228
|
}
|
|
189
229
|
|
|
190
|
-
/**
|
|
230
|
+
/**
|
|
231
|
+
* Get-or-create the ACP session for a chat id (one claude context per chat).
|
|
232
|
+
* `warm` and `prompt` can both reach this for the same chat close together
|
|
233
|
+
* (composer autocomplete warm-up racing the user hitting send) — a second
|
|
234
|
+
* concurrent call for the same chatId shares the FIRST call's in-flight
|
|
235
|
+
* promise (mirrors `ensureStarted`'s `this.starting` pattern) rather than
|
|
236
|
+
* re-entering resume/create and stomping the single shared `replaying` flag.
|
|
237
|
+
*/
|
|
191
238
|
private async sessionFor(chatId: string): Promise<string> {
|
|
192
239
|
const existing = this.sessions.get(chatId);
|
|
193
240
|
if (existing) return existing;
|
|
241
|
+
const inFlight = this.sessionPromises.get(chatId);
|
|
242
|
+
if (inFlight) return inFlight;
|
|
243
|
+
|
|
244
|
+
const promise = this.establishSession(chatId).finally(() => {
|
|
245
|
+
this.sessionPromises.delete(chatId);
|
|
246
|
+
});
|
|
247
|
+
this.sessionPromises.set(chatId, promise);
|
|
248
|
+
return promise;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Resumes a session persisted from a PRIOR app/dev-server lifetime (the
|
|
253
|
+
* cross-restart memory gap tracked in DDR-125) via the adapter's `loadSession`
|
|
254
|
+
* before falling back to a brand-new `newSession` — either because this chat
|
|
255
|
+
* has never had a session, or because the resume attempt failed (e.g. the
|
|
256
|
+
* underlying claude session was pruned, `claude` was reinstalled, or the
|
|
257
|
+
* adapter's response never arrives — `loadSession` is time-boxed so a dead
|
|
258
|
+
* transport can't wedge `replaying` true forever and silently black-hole
|
|
259
|
+
* every future turn on this bridge).
|
|
260
|
+
*/
|
|
261
|
+
private async establishSession(chatId: string): Promise<string> {
|
|
194
262
|
if (!this.conn) throw new Error('ACP adapter not started');
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
);
|
|
263
|
+
|
|
264
|
+
const params = newSessionParams(this.opts.repoRoot, this.opts.studioBrief, this.opts.plugins);
|
|
265
|
+
const persistedId = await this.readPersistedSessionId();
|
|
266
|
+
if (persistedId) {
|
|
267
|
+
try {
|
|
268
|
+
this.replaying = true;
|
|
269
|
+
const result = await withTimeout(
|
|
270
|
+
this.conn.loadSession({ ...params, sessionId: persistedId }),
|
|
271
|
+
LOAD_SESSION_TIMEOUT_MS
|
|
272
|
+
);
|
|
273
|
+
if (result === TIMED_OUT) {
|
|
274
|
+
throw new Error(`loadSession timed out after ${LOAD_SESSION_TIMEOUT_MS}ms`);
|
|
275
|
+
}
|
|
276
|
+
this.sessions.set(chatId, persistedId);
|
|
277
|
+
return persistedId;
|
|
278
|
+
} catch (err) {
|
|
279
|
+
await this.appendTranscript({
|
|
280
|
+
role: 'bootstrap',
|
|
281
|
+
kind: 'resume-failed',
|
|
282
|
+
error: err instanceof Error ? err.message : String(err),
|
|
283
|
+
});
|
|
284
|
+
} finally {
|
|
285
|
+
this.replaying = false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const created = await this.conn.newSession(params);
|
|
198
290
|
this.sessions.set(chatId, created.sessionId);
|
|
291
|
+
await this.writePersistedSessionId(created.sessionId);
|
|
199
292
|
return created.sessionId;
|
|
200
293
|
}
|
|
201
294
|
|
|
295
|
+
/** Read the sessionId persisted for this chat by a prior bridge lifetime.
|
|
296
|
+
* Null when there's no sidecar wired (e.g. warm-up before any prompt), the
|
|
297
|
+
* file doesn't exist yet (first-ever turn), it's unreadable/corrupt, or its
|
|
298
|
+
* `sessionId` doesn't look like a real one (defense-in-depth — this file's
|
|
299
|
+
* directory is per-machine/gitignored per DDR-115, but a cloned repo could
|
|
300
|
+
* still ship a tracked file there, so bound what we'll forward into the
|
|
301
|
+
* privileged `loadSession` ACP call rather than trusting its shape blindly). */
|
|
302
|
+
private async readPersistedSessionId(): Promise<string | null> {
|
|
303
|
+
if (!this.sessionStorePath) return null;
|
|
304
|
+
try {
|
|
305
|
+
const raw = await readFile(this.sessionStorePath, 'utf8');
|
|
306
|
+
const data = JSON.parse(raw) as { sessionId?: unknown };
|
|
307
|
+
const id = data.sessionId;
|
|
308
|
+
return typeof id === 'string' && VALID_SESSION_ID.test(id) ? id : null;
|
|
309
|
+
} catch {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Persist a freshly-created sessionId so the NEXT bridge lifetime (app
|
|
315
|
+
* restart, dev-server restart) can resume this chat instead of starting
|
|
316
|
+
* fresh. Best-effort, like `appendTranscript` — a failed write just means
|
|
317
|
+
* the next restart falls back to a new session. */
|
|
318
|
+
private async writePersistedSessionId(sessionId: string): Promise<void> {
|
|
319
|
+
if (!this.sessionStorePath) return;
|
|
320
|
+
try {
|
|
321
|
+
await mkdir(dirname(this.sessionStorePath), { recursive: true });
|
|
322
|
+
await writeFile(this.sessionStorePath, JSON.stringify({ sessionId, updatedAt: Date.now() }));
|
|
323
|
+
} catch {
|
|
324
|
+
/* best-effort — see doc comment above */
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
202
328
|
private async start(): Promise<void> {
|
|
203
329
|
const adapterEntry = resolveAdapterEntry();
|
|
204
330
|
if (!adapterEntry) {
|
|
@@ -279,6 +405,12 @@ export class AcpBridge {
|
|
|
279
405
|
this.opts.onCommands?.(params.update.availableCommands ?? []);
|
|
280
406
|
return;
|
|
281
407
|
}
|
|
408
|
+
// `loadSession` replays the resumed session's entire prior history back
|
|
409
|
+
// through this SAME callback (claude-agent-acp's replaySessionHistory) to
|
|
410
|
+
// prime its own in-adapter state. That history is already on disk in the
|
|
411
|
+
// transcript and already rendered client-side, so forwarding/re-appending
|
|
412
|
+
// it here would duplicate every message in the panel and the jsonl file.
|
|
413
|
+
if (this.replaying) return;
|
|
282
414
|
this.opts.onUpdate(params.update);
|
|
283
415
|
void this.appendTranscript({ role: 'agent', update: params.update });
|
|
284
416
|
},
|
|
@@ -388,6 +520,13 @@ export class AcpBridge {
|
|
|
388
520
|
this.proc = null;
|
|
389
521
|
this.conn = null;
|
|
390
522
|
this.sessions.clear();
|
|
523
|
+
// Drop any in-flight sessionFor() promises too — they were bound to the
|
|
524
|
+
// now-dead `conn`; a subsequent sessionFor() for the same chatId must
|
|
525
|
+
// establish fresh against the respawned connection, not await a stale
|
|
526
|
+
// reference (each entry's own .finally() would eventually clear it once its
|
|
527
|
+
// bounded loadSession timeout fires, but a call landing before then would
|
|
528
|
+
// otherwise get back a result tied to the connection we just tore down).
|
|
529
|
+
this.sessionPromises.clear();
|
|
391
530
|
this.briefLogged.clear();
|
|
392
531
|
this.currentSession = null;
|
|
393
532
|
}
|
package/apps/studio/acp/index.ts
CHANGED
|
@@ -185,8 +185,16 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
|
|
|
185
185
|
const safe = id.replace(/[^a-z0-9_-]/gi, '').slice(0, 64);
|
|
186
186
|
return safe || 'default';
|
|
187
187
|
}
|
|
188
|
+
function chatFilePathFor(chatId: string, suffix: string): string {
|
|
189
|
+
return join(ctx.paths.designRoot, '_chat', `${sanitizeChatId(chatId)}${suffix}`);
|
|
190
|
+
}
|
|
188
191
|
function transcriptPathFor(chatId: string): string {
|
|
189
|
-
return
|
|
192
|
+
return chatFilePathFor(chatId, '.jsonl');
|
|
193
|
+
}
|
|
194
|
+
// Sidecar persisting this chat's ACP sessionId across restarts (bridge.ts
|
|
195
|
+
// sessionFor's resume path) — the cross-restart memory gap tracked in DDR-125.
|
|
196
|
+
function sessionStorePathFor(chatId: string): string {
|
|
197
|
+
return chatFilePathFor(chatId, '.session.json');
|
|
190
198
|
}
|
|
191
199
|
|
|
192
200
|
async function handlePrompt(
|
|
@@ -198,6 +206,7 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
|
|
|
198
206
|
): Promise<void> {
|
|
199
207
|
const bridge = getOrCreateBridge(ws);
|
|
200
208
|
bridge.setTranscriptPath(transcriptPathFor(chatId));
|
|
209
|
+
bridge.setSessionStorePath(sessionStorePathFor(chatId));
|
|
201
210
|
bridge.setConfig(model, effort);
|
|
202
211
|
try {
|
|
203
212
|
await bridge.ensureStarted();
|
|
@@ -226,6 +235,7 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
|
|
|
226
235
|
effort: AcpEffort
|
|
227
236
|
): Promise<void> {
|
|
228
237
|
const bridge = getOrCreateBridge(ws);
|
|
238
|
+
bridge.setSessionStorePath(sessionStorePathFor(chatId));
|
|
229
239
|
bridge.setConfig(model, effort);
|
|
230
240
|
try {
|
|
231
241
|
await bridge.warmUp(sanitizeChatId(chatId));
|
|
@@ -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);
|