plotlink-ows 1.2.94 → 1.2.96
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/app/lib/active-wallet.ts +260 -0
- package/app/lib/cartoon-coach.ts +1 -1
- package/app/lib/cartoon-readiness.ts +12 -10
- package/app/lib/cuts.ts +135 -18
- package/app/lib/lettering-status.ts +64 -6
- package/app/lib/story-progress.ts +2 -3
- package/app/routes/dashboard.ts +6 -4
- package/app/routes/publish.ts +56 -23
- package/app/routes/settings.ts +92 -37
- package/app/routes/wallet.ts +58 -30
- package/app/web/components/CartoonNextAction.tsx +145 -0
- package/app/web/components/CartoonPublishPage.tsx +1 -1
- package/app/web/components/CutListPanel.tsx +1198 -488
- package/app/web/components/Dashboard.tsx +15 -6
- package/app/web/components/FinishEpisodePanel.tsx +57 -46
- package/app/web/components/LetteringEditor.tsx +867 -366
- package/app/web/components/PreviewPanel.tsx +1459 -844
- package/app/web/components/StoriesPage.tsx +985 -475
- package/app/web/components/StoryProgressPanel.tsx +32 -102
- package/app/web/components/WalletCard.tsx +110 -8
- package/app/web/components/WorkflowCoach.tsx +63 -35
- package/app/web/dist/assets/{export-cut-nKQ_n2-J.js → export-cut-BqZI0-Rv.js} +1 -1
- package/app/web/dist/assets/index-C43toXVm.js +141 -0
- package/app/web/dist/assets/index-CcfChGEK.css +32 -0
- package/app/web/dist/index.html +2 -2
- package/package.json +1 -1
- package/app/web/dist/assets/index-BAZGwVwj.js +0 -143
- package/app/web/dist/assets/index-DoXH2OlP.css +0 -32
|
@@ -20,8 +20,14 @@ import {
|
|
|
20
20
|
type OverlayType,
|
|
21
21
|
} from "@app-lib/overlays";
|
|
22
22
|
import { layoutBubbleText } from "@app-lib/bubble-text";
|
|
23
|
-
import {
|
|
24
|
-
|
|
23
|
+
import {
|
|
24
|
+
cutLetteringChecklist,
|
|
25
|
+
cutScriptLines,
|
|
26
|
+
isExportStale,
|
|
27
|
+
overlaysSignature,
|
|
28
|
+
type ScriptLine,
|
|
29
|
+
} from "@app-lib/lettering-status";
|
|
30
|
+
import { textPanelDimensions, type CutAiDraft } from "@app-lib/cuts";
|
|
25
31
|
import { useAuthedAsset } from "./asset-image";
|
|
26
32
|
|
|
27
33
|
function toPixel(norm: number, size: number): number {
|
|
@@ -61,17 +67,29 @@ interface Cut {
|
|
|
61
67
|
kind?: "image" | "text";
|
|
62
68
|
background?: string;
|
|
63
69
|
aspectRatio?: string;
|
|
70
|
+
aiDraft?: CutAiDraft | null;
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
interface LetteringEditorProps {
|
|
67
74
|
storyName: string;
|
|
68
75
|
cut: Cut;
|
|
69
76
|
plotFile: string;
|
|
70
|
-
onSave: (
|
|
77
|
+
onSave: (
|
|
78
|
+
overlays: Overlay[],
|
|
79
|
+
aiDraft?: CutAiDraft | null,
|
|
80
|
+
) => void | Promise<void>;
|
|
71
81
|
onClose: () => void;
|
|
72
82
|
onExported?: () => void;
|
|
73
83
|
language?: string;
|
|
74
84
|
authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
|
|
85
|
+
/** Focused-editor header label supplied by the review board (#488). */
|
|
86
|
+
targetLabel?: string;
|
|
87
|
+
/** When true, the Save button returns to the review board after persisting. */
|
|
88
|
+
returnOnSave?: boolean;
|
|
89
|
+
/** Whether the wider app work area / terminal is currently restored. */
|
|
90
|
+
workspaceVisible?: boolean;
|
|
91
|
+
/** Toggle the surrounding app work area while staying in the editor. */
|
|
92
|
+
onToggleWorkspaceVisible?: () => void;
|
|
75
93
|
}
|
|
76
94
|
|
|
77
95
|
const TYPE_LABEL: Record<OverlayType, string> = {
|
|
@@ -90,7 +108,8 @@ const TYPE_BORDER: Record<OverlayType, string> = {
|
|
|
90
108
|
// a trimmed text snippet, falling back to the type name for empty bubbles.
|
|
91
109
|
function overlapLabel(o: Overlay): string {
|
|
92
110
|
const snippet = (o.speaker || o.text || "").trim().replace(/\s+/g, " ");
|
|
93
|
-
if (snippet)
|
|
111
|
+
if (snippet)
|
|
112
|
+
return `“${snippet.length > 18 ? `${snippet.slice(0, 18)}…` : snippet}”`;
|
|
94
113
|
return TYPE_LABEL[o.type];
|
|
95
114
|
}
|
|
96
115
|
|
|
@@ -106,7 +125,20 @@ function clamp(v: number, min: number, max: number): number {
|
|
|
106
125
|
return Math.min(max, Math.max(min, v));
|
|
107
126
|
}
|
|
108
127
|
|
|
109
|
-
export function LetteringEditor({
|
|
128
|
+
export function LetteringEditor({
|
|
129
|
+
storyName,
|
|
130
|
+
cut,
|
|
131
|
+
plotFile,
|
|
132
|
+
onSave,
|
|
133
|
+
onClose,
|
|
134
|
+
onExported,
|
|
135
|
+
language = "English",
|
|
136
|
+
authFetch,
|
|
137
|
+
targetLabel,
|
|
138
|
+
returnOnSave = false,
|
|
139
|
+
workspaceVisible = false,
|
|
140
|
+
onToggleWorkspaceVisible,
|
|
141
|
+
}: LetteringEditorProps) {
|
|
110
142
|
const bodyFont = getDefaultFont(language);
|
|
111
143
|
const displayFont = getDisplayFont();
|
|
112
144
|
const bodyFontFamily = getFontFamily(bodyFont);
|
|
@@ -126,10 +158,14 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
126
158
|
try {
|
|
127
159
|
const { ensureFontsReady } = await import("./export-cut");
|
|
128
160
|
await ensureFontsReady([bodyFont.family, displayFont.family]);
|
|
129
|
-
} catch {
|
|
161
|
+
} catch {
|
|
162
|
+
/* best effort — still render the preview */
|
|
163
|
+
}
|
|
130
164
|
if (!cancelled) setFontsReady(true);
|
|
131
165
|
})();
|
|
132
|
-
return () => {
|
|
166
|
+
return () => {
|
|
167
|
+
cancelled = true;
|
|
168
|
+
};
|
|
133
169
|
}, [bodyFont.family, displayFont.family]);
|
|
134
170
|
|
|
135
171
|
// Clean image lives behind requireAuth, so a raw <img src> would 401. Load it
|
|
@@ -138,15 +174,22 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
138
174
|
// Repair agent-authored overlays (e.g. semantic `position` strings with no
|
|
139
175
|
// numeric geometry) on load so the bubbles actually render and export — and
|
|
140
176
|
// surface a note when some could not be auto-placed (#309).
|
|
141
|
-
const overlayNormalization = useMemo(
|
|
177
|
+
const overlayNormalization = useMemo(
|
|
178
|
+
() => normalizeOverlays(cut.overlays),
|
|
179
|
+
[cut.overlays],
|
|
180
|
+
);
|
|
142
181
|
const invalidOverlayCount = overlayNormalization.invalid.length;
|
|
143
182
|
// Overlays that could not be placed (no geometry, no recognizable position)
|
|
144
183
|
// are NOT exported. Exporting silently would produce a final missing that
|
|
145
184
|
// bubble/text, so block export until the writer explicitly discards them (#309).
|
|
146
185
|
const [acknowledgedInvalid, setAcknowledgedInvalid] = useState(false);
|
|
147
186
|
const autoPlacedOverlays =
|
|
148
|
-
invalidOverlayCount === 0 &&
|
|
149
|
-
|
|
187
|
+
invalidOverlayCount === 0 &&
|
|
188
|
+
overlayNormalization.changed &&
|
|
189
|
+
overlayNormalization.overlays.length > 0;
|
|
190
|
+
const [overlays, setOverlays] = useState<Overlay[]>(
|
|
191
|
+
() => overlayNormalization.overlays as Overlay[],
|
|
192
|
+
);
|
|
150
193
|
// Signature of the overlays that match the current export/upload (#336, re1).
|
|
151
194
|
// Captured (already normalized like the live `overlays`) when the cut opens so
|
|
152
195
|
// a load-time normalization isn't mistaken for a user edit, and advanced to the
|
|
@@ -158,15 +201,19 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
158
201
|
// Offscreen canvas to measure text exactly like the export canvas, so the
|
|
159
202
|
// preview wraps/sizes bubble text identically to the final image (#310).
|
|
160
203
|
const measureCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
161
|
-
const measureWidth = useCallback(
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
204
|
+
const measureWidth = useCallback(
|
|
205
|
+
(fontFamily: string) =>
|
|
206
|
+
(text: string, fontSize: number, fontWeight: 400 | 700 = 400): number => {
|
|
207
|
+
if (!measureCanvasRef.current && typeof document !== "undefined") {
|
|
208
|
+
measureCanvasRef.current = document.createElement("canvas");
|
|
209
|
+
}
|
|
210
|
+
const mctx = measureCanvasRef.current?.getContext("2d");
|
|
211
|
+
if (!mctx) return text.length * fontSize * 0.5; // jsdom fallback
|
|
212
|
+
mctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
|
|
213
|
+
return mctx.measureText(text).width;
|
|
214
|
+
},
|
|
215
|
+
[],
|
|
216
|
+
);
|
|
170
217
|
// Gate the exact (canvas-measured) preview layout on the SAME font-readiness
|
|
171
218
|
// signal export uses (ensureFontsReady), so the preview does not freeze line
|
|
172
219
|
// breaks computed from fallback-font metrics that would diverge from the
|
|
@@ -176,10 +223,25 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
176
223
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
177
224
|
const [exporting, setExporting] = useState(false);
|
|
178
225
|
const [exportError, setExportError] = useState<string | null>(null);
|
|
179
|
-
const [
|
|
226
|
+
const [saveError, setSaveError] = useState<string | null>(null);
|
|
227
|
+
const [imageBounds, setImageBounds] = useState({
|
|
228
|
+
x: 0,
|
|
229
|
+
y: 0,
|
|
230
|
+
width: 0,
|
|
231
|
+
height: 0,
|
|
232
|
+
});
|
|
180
233
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
181
234
|
const imgRef = useRef<HTMLImageElement>(null);
|
|
182
|
-
const dragRef = useRef<{
|
|
235
|
+
const dragRef = useRef<{
|
|
236
|
+
id: string;
|
|
237
|
+
mode: "move" | "resize";
|
|
238
|
+
startX: number;
|
|
239
|
+
startY: number;
|
|
240
|
+
origX: number;
|
|
241
|
+
origY: number;
|
|
242
|
+
origW: number;
|
|
243
|
+
origH: number;
|
|
244
|
+
} | null>(null);
|
|
183
245
|
|
|
184
246
|
const updateImageBounds = useCallback(() => {
|
|
185
247
|
const container = containerRef.current;
|
|
@@ -191,7 +253,10 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
191
253
|
if (cut.kind === "text") {
|
|
192
254
|
// A text panel has no image — size the editor canvas from the SAME aspect
|
|
193
255
|
// ratio the export uses, so lettering and the exported final agree (#351).
|
|
194
|
-
const dims = textPanelDimensions(cut.aspectRatio) ?? {
|
|
256
|
+
const dims = textPanelDimensions(cut.aspectRatio) ?? {
|
|
257
|
+
width: 800,
|
|
258
|
+
height: 600,
|
|
259
|
+
};
|
|
195
260
|
iw = dims.width;
|
|
196
261
|
ih = dims.height;
|
|
197
262
|
} else {
|
|
@@ -204,7 +269,12 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
204
269
|
const scale = Math.min(cw / iw, ch / ih);
|
|
205
270
|
const rw = iw * scale;
|
|
206
271
|
const rh = ih * scale;
|
|
207
|
-
setImageBounds({
|
|
272
|
+
setImageBounds({
|
|
273
|
+
x: (cw - rw) / 2,
|
|
274
|
+
y: (ch - rh) / 2,
|
|
275
|
+
width: rw,
|
|
276
|
+
height: rh,
|
|
277
|
+
});
|
|
208
278
|
}, [cut.kind, cut.aspectRatio]);
|
|
209
279
|
|
|
210
280
|
useEffect(() => {
|
|
@@ -221,76 +291,130 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
221
291
|
// default font; without measurement it falls back to a generous default that
|
|
222
292
|
// fits ordinary lines, instead of the tiny create-default. The writer can still
|
|
223
293
|
// resize freely afterward, and the overflow warning stays useful if they shrink it.
|
|
224
|
-
const fittedSize = useCallback(
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
294
|
+
const fittedSize = useCallback(
|
|
295
|
+
(o: Overlay): { width: number; height: number } => {
|
|
296
|
+
const comfortable = comfortableOverlaySize(o.type, o.x, o.y);
|
|
297
|
+
const width = comfortable.width;
|
|
298
|
+
const maxH = Math.max(0.08, 1 - o.y);
|
|
299
|
+
if (!o.text || !fontsReady || imageBounds.width <= 0) {
|
|
300
|
+
return comfortable;
|
|
301
|
+
}
|
|
302
|
+
const fontFamily = o.type === "sfx" ? displayFontFamily : bodyFontFamily;
|
|
303
|
+
const wPx = toPixel(width, imageBounds.width);
|
|
304
|
+
let height = o.type === "sfx" ? 0.08 : 0.12;
|
|
305
|
+
for (let i = 0; i < 24; i++) {
|
|
306
|
+
const h = Math.min(height, maxH);
|
|
307
|
+
const hPx = toPixel(h, imageBounds.height);
|
|
308
|
+
const layout = layoutBubbleText(
|
|
309
|
+
measureWidth(fontFamily),
|
|
310
|
+
o.text,
|
|
311
|
+
wPx,
|
|
312
|
+
hPx,
|
|
313
|
+
bubbleLayoutOptionsForOverlay(
|
|
314
|
+
{ ...o, width, height: h },
|
|
315
|
+
imageBounds.height || 300,
|
|
316
|
+
wPx,
|
|
317
|
+
hPx,
|
|
318
|
+
),
|
|
319
|
+
);
|
|
320
|
+
if (!layout.overflow || h >= maxH) return { width, height: h };
|
|
321
|
+
height += 0.03;
|
|
322
|
+
}
|
|
323
|
+
return { width, height: Math.min(height, maxH) };
|
|
324
|
+
},
|
|
325
|
+
[fontsReady, imageBounds, measureWidth, bodyFontFamily, displayFontFamily],
|
|
326
|
+
);
|
|
246
327
|
|
|
247
|
-
const addOverlay = useCallback(
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
328
|
+
const addOverlay = useCallback(
|
|
329
|
+
(type: OverlayType) => {
|
|
330
|
+
const o = createOverlay(
|
|
331
|
+
type,
|
|
332
|
+
0.1 + Math.random() * 0.3,
|
|
333
|
+
0.1 + Math.random() * 0.3,
|
|
334
|
+
);
|
|
335
|
+
const sized: Overlay = { ...o, ...fittedSize(o) };
|
|
336
|
+
setOverlays((prev) => [...prev, sized]);
|
|
337
|
+
setSelectedId(sized.id);
|
|
338
|
+
},
|
|
339
|
+
[fittedSize],
|
|
340
|
+
);
|
|
253
341
|
|
|
254
342
|
// Insert a line from the cut's cuts.json script (#336) as a prefilled overlay,
|
|
255
343
|
// so the writer never has to hand-copy dialogue/narration/SFX out of the JSON.
|
|
256
|
-
const addScriptLine = useCallback(
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
344
|
+
const addScriptLine = useCallback(
|
|
345
|
+
(line: ScriptLine) => {
|
|
346
|
+
const o = createOverlay(
|
|
347
|
+
line.type,
|
|
348
|
+
0.1 + Math.random() * 0.3,
|
|
349
|
+
0.1 + Math.random() * 0.3,
|
|
350
|
+
);
|
|
351
|
+
const filled: Overlay = {
|
|
352
|
+
...o,
|
|
353
|
+
text: line.text,
|
|
354
|
+
...(line.type === "speech" && line.speaker
|
|
355
|
+
? { speaker: line.speaker }
|
|
356
|
+
: {}),
|
|
357
|
+
};
|
|
358
|
+
const sized: Overlay = { ...filled, ...fittedSize(filled) };
|
|
359
|
+
setOverlays((prev) => [...prev, sized]);
|
|
360
|
+
setSelectedId(sized.id);
|
|
361
|
+
},
|
|
362
|
+
[fittedSize],
|
|
363
|
+
);
|
|
267
364
|
|
|
268
365
|
const updateOverlay = useCallback((id: string, changes: Partial<Overlay>) => {
|
|
269
|
-
setOverlays((prev) =>
|
|
366
|
+
setOverlays((prev) =>
|
|
367
|
+
prev.map((o) => (o.id === id ? { ...o, ...changes } : o)),
|
|
368
|
+
);
|
|
270
369
|
}, []);
|
|
271
370
|
|
|
272
|
-
const enableManualTypography = useCallback(
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
371
|
+
const enableManualTypography = useCallback(
|
|
372
|
+
(overlay: Overlay) => {
|
|
373
|
+
const renderHeight = imageBounds.height || 300;
|
|
374
|
+
const width =
|
|
375
|
+
imageBounds.width > 0 ? toPixel(overlay.width, imageBounds.width) : 200;
|
|
376
|
+
const height =
|
|
377
|
+
imageBounds.height > 0
|
|
378
|
+
? toPixel(overlay.height, imageBounds.height)
|
|
379
|
+
: 100;
|
|
380
|
+
const fontFamily =
|
|
381
|
+
overlay.type === "sfx" ? displayFontFamily : bodyFontFamily;
|
|
382
|
+
const autoLayout = layoutBubbleText(
|
|
383
|
+
measureWidth(fontFamily),
|
|
384
|
+
overlay.text,
|
|
385
|
+
width,
|
|
386
|
+
height,
|
|
387
|
+
bubbleLayoutOptionsForOverlay(
|
|
388
|
+
{ ...overlay, textStyle: undefined },
|
|
389
|
+
renderHeight,
|
|
390
|
+
width,
|
|
391
|
+
height,
|
|
392
|
+
),
|
|
393
|
+
);
|
|
394
|
+
updateOverlay(overlay.id, {
|
|
285
395
|
textStyle: {
|
|
286
396
|
mode: "manual",
|
|
287
397
|
fontScale: autoLayout.fontSize / Math.max(1, renderHeight),
|
|
288
398
|
fontWeight: overlay.textStyle?.fontWeight ?? 400,
|
|
289
|
-
lineHeightFactor:
|
|
290
|
-
|
|
399
|
+
lineHeightFactor:
|
|
400
|
+
autoLayout.fontSize > 0
|
|
401
|
+
? autoLayout.lineHeight / autoLayout.fontSize
|
|
402
|
+
: 1.2,
|
|
403
|
+
speakerScale:
|
|
404
|
+
autoLayout.fontSize > 0 && autoLayout.speakerFontSize > 0
|
|
405
|
+
? autoLayout.speakerFontSize / autoLayout.fontSize
|
|
406
|
+
: 0.8,
|
|
291
407
|
},
|
|
292
|
-
|
|
293
|
-
|
|
408
|
+
});
|
|
409
|
+
},
|
|
410
|
+
[
|
|
411
|
+
imageBounds,
|
|
412
|
+
displayFontFamily,
|
|
413
|
+
bodyFontFamily,
|
|
414
|
+
measureWidth,
|
|
415
|
+
updateOverlay,
|
|
416
|
+
],
|
|
417
|
+
);
|
|
294
418
|
|
|
295
419
|
const deleteOverlay = useCallback((id: string) => {
|
|
296
420
|
setOverlays((prev) => prev.filter((o) => o.id !== id));
|
|
@@ -309,23 +433,26 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
309
433
|
setConfirmDelete(false);
|
|
310
434
|
}, []);
|
|
311
435
|
|
|
312
|
-
const handleMouseDown = useCallback(
|
|
313
|
-
e.
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
436
|
+
const handleMouseDown = useCallback(
|
|
437
|
+
(e: React.MouseEvent, id: string, mode: "move" | "resize") => {
|
|
438
|
+
e.stopPropagation();
|
|
439
|
+
e.preventDefault();
|
|
440
|
+
const overlay = overlays.find((o) => o.id === id);
|
|
441
|
+
if (!overlay) return;
|
|
442
|
+
setSelectedId(id);
|
|
443
|
+
dragRef.current = {
|
|
444
|
+
id,
|
|
445
|
+
mode,
|
|
446
|
+
startX: e.clientX,
|
|
447
|
+
startY: e.clientY,
|
|
448
|
+
origX: overlay.x,
|
|
449
|
+
origY: overlay.y,
|
|
450
|
+
origW: overlay.width,
|
|
451
|
+
origH: overlay.height,
|
|
452
|
+
};
|
|
453
|
+
},
|
|
454
|
+
[overlays],
|
|
455
|
+
);
|
|
329
456
|
|
|
330
457
|
useEffect(() => {
|
|
331
458
|
const onMouseMove = (e: MouseEvent) => {
|
|
@@ -345,7 +472,9 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
345
472
|
}
|
|
346
473
|
};
|
|
347
474
|
|
|
348
|
-
const onMouseUp = () => {
|
|
475
|
+
const onMouseUp = () => {
|
|
476
|
+
dragRef.current = null;
|
|
477
|
+
};
|
|
349
478
|
|
|
350
479
|
window.addEventListener("mousemove", onMouseMove);
|
|
351
480
|
window.addEventListener("mouseup", onMouseUp);
|
|
@@ -355,9 +484,27 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
355
484
|
};
|
|
356
485
|
}, [imageBounds, updateOverlay]);
|
|
357
486
|
|
|
358
|
-
const handleSave = useCallback(() => {
|
|
359
|
-
|
|
360
|
-
|
|
487
|
+
const handleSave = useCallback(async () => {
|
|
488
|
+
setSaveError(null);
|
|
489
|
+
try {
|
|
490
|
+
const currentSig = overlaysSignature(overlays);
|
|
491
|
+
const nextAiDraft =
|
|
492
|
+
cut.aiDraft?.status === "generated" &&
|
|
493
|
+
currentSig !== (cut.aiDraft.baseSig ?? "")
|
|
494
|
+
? {
|
|
495
|
+
...cut.aiDraft,
|
|
496
|
+
status: "edited" as const,
|
|
497
|
+
updatedAt: new Date().toISOString(),
|
|
498
|
+
}
|
|
499
|
+
: (cut.aiDraft ?? undefined);
|
|
500
|
+
await onSave(overlays, nextAiDraft ?? null);
|
|
501
|
+
if (returnOnSave) onClose();
|
|
502
|
+
} catch (err) {
|
|
503
|
+
setSaveError(
|
|
504
|
+
err instanceof Error ? err.message : "Failed to save overlays",
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
}, [overlays, onSave, onClose, returnOnSave, cut.aiDraft]);
|
|
361
508
|
|
|
362
509
|
const handleExport = useCallback(async () => {
|
|
363
510
|
// Block export when the cut plan contained overlays that could not be placed
|
|
@@ -379,10 +526,15 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
379
526
|
const { exportCut, ensureFontsReady } = await import("./export-cut");
|
|
380
527
|
|
|
381
528
|
const usesSfx = overlays.some((o) => o.type === "sfx");
|
|
382
|
-
const fontsToCheck = [
|
|
529
|
+
const fontsToCheck = [
|
|
530
|
+
bodyFont.family,
|
|
531
|
+
...(usesSfx ? [displayFont.family] : []),
|
|
532
|
+
];
|
|
383
533
|
const { ready, missing } = await ensureFontsReady(fontsToCheck);
|
|
384
534
|
if (!ready) {
|
|
385
|
-
setExportError(
|
|
535
|
+
setExportError(
|
|
536
|
+
`Fonts not loaded: ${missing.join(", ")}. Check your connection and retry.`,
|
|
537
|
+
);
|
|
386
538
|
setExporting(false);
|
|
387
539
|
return;
|
|
388
540
|
}
|
|
@@ -407,7 +559,9 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
407
559
|
{ narration: cut.narration, dialogue: cut.dialogue },
|
|
408
560
|
// Text panels have no clean image — render the final on a styled
|
|
409
561
|
// background canvas sized by the panel's aspect ratio (#351).
|
|
410
|
-
cut.kind === "text"
|
|
562
|
+
cut.kind === "text"
|
|
563
|
+
? { background: cut.background, aspectRatio: cut.aspectRatio }
|
|
564
|
+
: undefined,
|
|
411
565
|
);
|
|
412
566
|
|
|
413
567
|
const fd = new FormData();
|
|
@@ -432,7 +586,22 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
432
586
|
} finally {
|
|
433
587
|
setExporting(false);
|
|
434
588
|
}
|
|
435
|
-
}, [
|
|
589
|
+
}, [
|
|
590
|
+
cut,
|
|
591
|
+
cleanAsset,
|
|
592
|
+
overlays,
|
|
593
|
+
storyName,
|
|
594
|
+
plotFile,
|
|
595
|
+
bodyFont,
|
|
596
|
+
displayFont,
|
|
597
|
+
bodyFontFamily,
|
|
598
|
+
displayFontFamily,
|
|
599
|
+
authFetch,
|
|
600
|
+
onSave,
|
|
601
|
+
onExported,
|
|
602
|
+
invalidOverlayCount,
|
|
603
|
+
acknowledgedInvalid,
|
|
604
|
+
]);
|
|
436
605
|
|
|
437
606
|
const selectedOverlay = overlays.find((o) => o.id === selectedId);
|
|
438
607
|
|
|
@@ -440,7 +609,10 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
440
609
|
// the writer gets a readability warning before export/publish (#318). Computed
|
|
441
610
|
// from the live overlay positions, so it clears as soon as bubbles are moved
|
|
442
611
|
// apart. Non-blocking: overlap can be intentional, so it never blocks export.
|
|
443
|
-
const overlapPairs = useMemo(
|
|
612
|
+
const overlapPairs = useMemo(
|
|
613
|
+
() => detectOverlappingOverlays(overlays),
|
|
614
|
+
[overlays],
|
|
615
|
+
);
|
|
444
616
|
|
|
445
617
|
// Re-baseline when a different cut opens without a remount (rare — the parent
|
|
446
618
|
// normally unmounts the editor between cuts).
|
|
@@ -448,7 +620,9 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
448
620
|
useEffect(() => {
|
|
449
621
|
if (baselineCutIdRef.current !== cut.id) {
|
|
450
622
|
baselineCutIdRef.current = cut.id;
|
|
451
|
-
setExportBaselineSig(
|
|
623
|
+
setExportBaselineSig(
|
|
624
|
+
overlaysSignature(overlayNormalization.overlays as Overlay[]),
|
|
625
|
+
);
|
|
452
626
|
}
|
|
453
627
|
}, [cut.id, overlayNormalization.overlays]);
|
|
454
628
|
|
|
@@ -482,7 +656,8 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
482
656
|
const outOfBounds = isOverlayOutOfBounds(o);
|
|
483
657
|
let overflow = false;
|
|
484
658
|
if (fontsReady && imageBounds.width > 0 && o.text) {
|
|
485
|
-
const fontFamily =
|
|
659
|
+
const fontFamily =
|
|
660
|
+
o.type === "sfx" ? displayFontFamily : bodyFontFamily;
|
|
486
661
|
const w = toPixel(o.width, imageBounds.width);
|
|
487
662
|
const h = toPixel(o.height, imageBounds.height);
|
|
488
663
|
const layout = layoutBubbleText(
|
|
@@ -497,7 +672,14 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
497
672
|
if (outOfBounds || overflow) out[o.id] = { outOfBounds, overflow };
|
|
498
673
|
}
|
|
499
674
|
return out;
|
|
500
|
-
}, [
|
|
675
|
+
}, [
|
|
676
|
+
overlays,
|
|
677
|
+
fontsReady,
|
|
678
|
+
imageBounds,
|
|
679
|
+
measureWidth,
|
|
680
|
+
bodyFontFamily,
|
|
681
|
+
displayFontFamily,
|
|
682
|
+
]);
|
|
501
683
|
const warningCount = Object.keys(overlayWarnings).length;
|
|
502
684
|
|
|
503
685
|
const isTextPanel = cut.kind === "text";
|
|
@@ -506,7 +688,13 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
506
688
|
// A text/interstitial panel (#351) is editable on a styled background canvas
|
|
507
689
|
// even when empty, so it skips the "no clean image" guard that applies to a
|
|
508
690
|
// would-be image cut with nothing placed yet.
|
|
509
|
-
if (
|
|
691
|
+
if (
|
|
692
|
+
!isTextPanel &&
|
|
693
|
+
isNarrationCut &&
|
|
694
|
+
overlays.length === 0 &&
|
|
695
|
+
!cut.narration &&
|
|
696
|
+
!cut.dialogue?.length
|
|
697
|
+
) {
|
|
510
698
|
return (
|
|
511
699
|
<div className="h-full flex items-center justify-center text-sm text-muted">
|
|
512
700
|
No clean image — upload one first, or add overlays for a narration cut.
|
|
@@ -515,48 +703,138 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
515
703
|
}
|
|
516
704
|
|
|
517
705
|
return (
|
|
518
|
-
<div
|
|
706
|
+
<div
|
|
707
|
+
className="h-full flex flex-col"
|
|
708
|
+
data-testid="focused-lettering-editor"
|
|
709
|
+
>
|
|
519
710
|
{/* Toolbar */}
|
|
520
|
-
<div className="px-
|
|
521
|
-
<div className="
|
|
522
|
-
<
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
<
|
|
527
|
-
|
|
711
|
+
<div className="px-4 py-3 border-b border-border bg-surface/40 flex items-center justify-between gap-3">
|
|
712
|
+
<div className="min-w-0">
|
|
713
|
+
<div className="flex items-center gap-2">
|
|
714
|
+
<span className="text-[10px] font-bold uppercase tracking-[0.16em] text-accent">
|
|
715
|
+
Focused lettering editor
|
|
716
|
+
</span>
|
|
717
|
+
<span className="text-xs font-mono text-muted">
|
|
718
|
+
{targetLabel ?? `Cut #${cut.id}`}
|
|
719
|
+
</span>
|
|
528
720
|
</div>
|
|
721
|
+
<p className="mt-0.5 text-[11px] text-muted">
|
|
722
|
+
Place bubbles, captions, SFX, or between-scene card text, then save
|
|
723
|
+
back to the full cut review.
|
|
724
|
+
</p>
|
|
725
|
+
<span className="text-[10px] text-muted" data-testid="overlay-count">
|
|
726
|
+
{overlays.length} overlays
|
|
727
|
+
</span>
|
|
529
728
|
</div>
|
|
530
|
-
<div className="flex items-center gap-2">
|
|
531
|
-
|
|
532
|
-
|
|
729
|
+
<div className="flex items-center gap-2 flex-wrap justify-end">
|
|
730
|
+
<button
|
|
731
|
+
onClick={onClose}
|
|
732
|
+
className="px-3 py-1 text-xs border border-border rounded text-muted hover:text-foreground"
|
|
733
|
+
data-testid="return-to-cut-review-btn"
|
|
734
|
+
>
|
|
735
|
+
Back to cut review
|
|
736
|
+
</button>
|
|
737
|
+
{onToggleWorkspaceVisible && (
|
|
738
|
+
<button
|
|
739
|
+
onClick={onToggleWorkspaceVisible}
|
|
740
|
+
className="px-3 py-1 text-xs border border-border rounded text-muted hover:border-accent hover:text-accent"
|
|
741
|
+
data-testid="toggle-work-area-btn"
|
|
742
|
+
>
|
|
743
|
+
{workspaceVisible ? "Hide work area" : "Show work area"}
|
|
744
|
+
</button>
|
|
745
|
+
)}
|
|
746
|
+
<div className="flex items-center gap-1 ml-2">
|
|
747
|
+
<button
|
|
748
|
+
onClick={() => addOverlay("speech")}
|
|
749
|
+
className="px-2 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
|
|
750
|
+
data-testid="add-speech"
|
|
751
|
+
>
|
|
752
|
+
Speech
|
|
753
|
+
</button>
|
|
754
|
+
<button
|
|
755
|
+
onClick={() => addOverlay("narration")}
|
|
756
|
+
className="px-2 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
|
|
757
|
+
data-testid="add-narration"
|
|
758
|
+
>
|
|
759
|
+
Narration
|
|
760
|
+
</button>
|
|
761
|
+
<button
|
|
762
|
+
onClick={() => addOverlay("sfx")}
|
|
763
|
+
className="px-2 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
|
|
764
|
+
data-testid="add-sfx"
|
|
765
|
+
>
|
|
766
|
+
SFX
|
|
767
|
+
</button>
|
|
768
|
+
</div>
|
|
769
|
+
{exportError && (
|
|
770
|
+
<span className="text-[10px] text-error">{exportError}</span>
|
|
771
|
+
)}
|
|
772
|
+
{saveError && (
|
|
773
|
+
<span className="text-[10px] text-error">{saveError}</span>
|
|
774
|
+
)}
|
|
775
|
+
<button
|
|
776
|
+
onClick={handleExport}
|
|
777
|
+
disabled={exporting}
|
|
778
|
+
className="px-3 py-1 text-xs border border-accent text-accent rounded hover:bg-accent/5 disabled:opacity-50"
|
|
779
|
+
data-testid="export-btn"
|
|
780
|
+
>
|
|
533
781
|
{exporting ? "Exporting..." : "Export"}
|
|
534
782
|
</button>
|
|
535
|
-
<button
|
|
536
|
-
|
|
783
|
+
<button
|
|
784
|
+
onClick={() => {
|
|
785
|
+
void handleSave();
|
|
786
|
+
}}
|
|
787
|
+
className="px-3 py-1 text-xs bg-accent text-white rounded hover:bg-accent-dim"
|
|
788
|
+
data-testid="save-lettering-btn"
|
|
789
|
+
>
|
|
790
|
+
Save
|
|
791
|
+
</button>
|
|
792
|
+
<button
|
|
793
|
+
onClick={onClose}
|
|
794
|
+
className="px-3 py-1 text-xs text-muted hover:text-foreground border border-border rounded"
|
|
795
|
+
data-testid="cancel-lettering-btn"
|
|
796
|
+
>
|
|
797
|
+
Cancel
|
|
798
|
+
</button>
|
|
537
799
|
</div>
|
|
538
800
|
</div>
|
|
539
801
|
|
|
540
802
|
{invalidOverlayCount > 0 && !acknowledgedInvalid ? (
|
|
541
|
-
<div
|
|
803
|
+
<div
|
|
804
|
+
className="px-3 py-1 border-b border-border bg-error/10 text-[10px] text-error flex items-center gap-2 flex-wrap"
|
|
805
|
+
data-testid="overlay-repair-note"
|
|
806
|
+
>
|
|
542
807
|
<span>
|
|
543
|
-
{invalidOverlayCount} overlay{invalidOverlayCount === 1 ? "" : "s"}
|
|
808
|
+
{invalidOverlayCount} overlay{invalidOverlayCount === 1 ? "" : "s"}{" "}
|
|
809
|
+
from the cut plan {invalidOverlayCount === 1 ? "has" : "have"} no
|
|
810
|
+
usable position and cannot be exported. Re-place{" "}
|
|
811
|
+
{invalidOverlayCount === 1 ? "it" : "them"}, or
|
|
544
812
|
</span>
|
|
545
813
|
<button
|
|
546
814
|
onClick={() => setAcknowledgedInvalid(true)}
|
|
547
815
|
data-testid="discard-invalid-overlays"
|
|
548
816
|
className="px-1.5 py-0.5 border border-error/40 rounded hover:bg-error/10"
|
|
549
817
|
>
|
|
550
|
-
discard {invalidOverlayCount} unplaceable overlay
|
|
818
|
+
discard {invalidOverlayCount} unplaceable overlay
|
|
819
|
+
{invalidOverlayCount === 1 ? "" : "s"}
|
|
551
820
|
</button>
|
|
552
821
|
</div>
|
|
553
822
|
) : invalidOverlayCount > 0 ? (
|
|
554
|
-
<div
|
|
555
|
-
|
|
823
|
+
<div
|
|
824
|
+
className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700"
|
|
825
|
+
data-testid="overlay-repair-note"
|
|
826
|
+
>
|
|
827
|
+
Discarded {invalidOverlayCount} unplaceable overlay
|
|
828
|
+
{invalidOverlayCount === 1 ? "" : "s"} — the export will not include{" "}
|
|
829
|
+
{invalidOverlayCount === 1 ? "it" : "them"}.
|
|
556
830
|
</div>
|
|
557
831
|
) : autoPlacedOverlays ? (
|
|
558
|
-
<div
|
|
559
|
-
|
|
832
|
+
<div
|
|
833
|
+
className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700"
|
|
834
|
+
data-testid="overlay-repair-note"
|
|
835
|
+
>
|
|
836
|
+
Auto-placed overlays from the cut plan — review their positions before
|
|
837
|
+
exporting.
|
|
560
838
|
</div>
|
|
561
839
|
) : null}
|
|
562
840
|
|
|
@@ -565,9 +843,14 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
565
843
|
className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700"
|
|
566
844
|
data-testid="overlay-overlap-warning"
|
|
567
845
|
>
|
|
568
|
-
Cut #{cut.id}: {overlapPairs.length} bubble
|
|
846
|
+
Cut #{cut.id}: {overlapPairs.length} bubble{" "}
|
|
847
|
+
{overlapPairs.length === 1 ? "pair overlaps" : "pairs overlap"} and
|
|
848
|
+
may be hard to read —{" "}
|
|
569
849
|
{overlapPairs
|
|
570
|
-
.map(
|
|
850
|
+
.map(
|
|
851
|
+
(p) =>
|
|
852
|
+
`#${p.indexA + 1} ${overlapLabel(overlays[p.indexA])} ↔ #${p.indexB + 1} ${overlapLabel(overlays[p.indexB])}`,
|
|
853
|
+
)
|
|
571
854
|
.join("; ")}
|
|
572
855
|
. Move them apart, or export as-is if the overlap is intended.
|
|
573
856
|
</div>
|
|
@@ -579,13 +862,19 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
579
862
|
className="px-3 py-1 border-b border-border flex items-center gap-3 flex-wrap text-[10px] text-muted"
|
|
580
863
|
data-testid="lettering-checklist"
|
|
581
864
|
>
|
|
582
|
-
{(
|
|
583
|
-
[
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
865
|
+
{(
|
|
866
|
+
[
|
|
867
|
+
["clean-image", "Clean image", checklist.hasCleanImage],
|
|
868
|
+
["script-text", "Script text", checklist.hasScriptText],
|
|
869
|
+
[
|
|
870
|
+
"bubbles",
|
|
871
|
+
`Bubbles placed${checklist.bubblesPlaced ? ` (${checklist.bubblesPlaced})` : ""}`,
|
|
872
|
+
checklist.bubblesPlaced > 0,
|
|
873
|
+
],
|
|
874
|
+
["exported", "Final exported", checklist.exported],
|
|
875
|
+
["uploaded", "Uploaded", checklist.uploaded],
|
|
876
|
+
] as [string, string, boolean][]
|
|
877
|
+
).map(([key, label, done]) => (
|
|
589
878
|
<span
|
|
590
879
|
key={key}
|
|
591
880
|
data-testid={`lettering-check-${key}`}
|
|
@@ -606,7 +895,8 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
606
895
|
className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700"
|
|
607
896
|
data-testid="lettering-stale-export-warning"
|
|
608
897
|
>
|
|
609
|
-
Bubbles changed since the last export — re-export this cut and upload
|
|
898
|
+
Bubbles changed since the last export — re-export this cut and upload
|
|
899
|
+
the new final image before publishing.
|
|
610
900
|
</div>
|
|
611
901
|
)}
|
|
612
902
|
|
|
@@ -617,11 +907,15 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
617
907
|
className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700"
|
|
618
908
|
data-testid="lettering-export-warning"
|
|
619
909
|
>
|
|
620
|
-
{warningCount} bubble{warningCount === 1 ? "" : "s"} may not export
|
|
910
|
+
{warningCount} bubble{warningCount === 1 ? "" : "s"} may not export
|
|
911
|
+
cleanly:{" "}
|
|
621
912
|
{Object.entries(overlayWarnings)
|
|
622
913
|
.map(([id, w]) => {
|
|
623
914
|
const idx = overlays.findIndex((o) => o.id === id);
|
|
624
|
-
const problems = [
|
|
915
|
+
const problems = [
|
|
916
|
+
w.outOfBounds ? "outside image" : null,
|
|
917
|
+
w.overflow ? "text overflow" : null,
|
|
918
|
+
]
|
|
625
919
|
.filter(Boolean)
|
|
626
920
|
.join(", ");
|
|
627
921
|
return `#${idx + 1} ${overlapLabel(overlays[idx])} (${problems})`;
|
|
@@ -635,16 +929,22 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
635
929
|
<div className="flex-1 min-h-0 flex">
|
|
636
930
|
<div
|
|
637
931
|
ref={containerRef}
|
|
638
|
-
className="flex-1 min-w-0 relative overflow-hidden"
|
|
932
|
+
className="flex-1 min-w-0 relative overflow-hidden bg-[#f8f5ef]"
|
|
639
933
|
onClick={handleBackgroundClick}
|
|
640
934
|
data-testid="editor-surface"
|
|
641
935
|
>
|
|
642
936
|
{cut.cleanImagePath && cleanAsset.error ? (
|
|
643
|
-
<div
|
|
937
|
+
<div
|
|
938
|
+
className="w-full h-full flex items-center justify-center text-muted text-xs"
|
|
939
|
+
data-testid="clean-image-error"
|
|
940
|
+
>
|
|
644
941
|
Clean image not available
|
|
645
942
|
</div>
|
|
646
943
|
) : cut.cleanImagePath && !cleanAsset.url ? (
|
|
647
|
-
<div
|
|
944
|
+
<div
|
|
945
|
+
className="w-full h-full flex items-center justify-center text-muted text-xs"
|
|
946
|
+
data-testid="clean-image-loading"
|
|
947
|
+
>
|
|
648
948
|
Loading clean image…
|
|
649
949
|
</div>
|
|
650
950
|
) : cut.cleanImagePath ? (
|
|
@@ -681,7 +981,12 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
681
981
|
if (el && imageBounds.width === 0) {
|
|
682
982
|
const rect = el.getBoundingClientRect();
|
|
683
983
|
if (rect.width > 0) {
|
|
684
|
-
setImageBounds({
|
|
984
|
+
setImageBounds({
|
|
985
|
+
x: 0,
|
|
986
|
+
y: 0,
|
|
987
|
+
width: rect.width,
|
|
988
|
+
height: rect.height,
|
|
989
|
+
});
|
|
685
990
|
}
|
|
686
991
|
}
|
|
687
992
|
}}
|
|
@@ -698,15 +1003,22 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
698
1003
|
Tailless speech (no tailAnchor, or a tip inside the bubble) traces
|
|
699
1004
|
a plain rounded rectangle. Tail-anchor edits update the path live. */}
|
|
700
1005
|
{imageBounds.width > 0 && (
|
|
701
|
-
<svg
|
|
1006
|
+
<svg
|
|
1007
|
+
className="absolute inset-0 w-full h-full pointer-events-none"
|
|
1008
|
+
data-testid="balloon-layer"
|
|
1009
|
+
>
|
|
702
1010
|
{overlays.map((overlay) => {
|
|
703
1011
|
if (overlay.type !== "speech") return null;
|
|
704
|
-
const ox =
|
|
705
|
-
|
|
1012
|
+
const ox =
|
|
1013
|
+
imageBounds.x + toPixel(overlay.x, imageBounds.width);
|
|
1014
|
+
const oy =
|
|
1015
|
+
imageBounds.y + toPixel(overlay.y, imageBounds.height);
|
|
706
1016
|
const ow = toPixel(overlay.width, imageBounds.width);
|
|
707
1017
|
const oh = toPixel(overlay.height, imageBounds.height);
|
|
708
1018
|
const radius = balloonRadiusForOverlay(overlay, ow, oh);
|
|
709
|
-
const tail = overlay.tailAnchor
|
|
1019
|
+
const tail = overlay.tailAnchor
|
|
1020
|
+
? speechTailPoints(ox, oy, ow, oh, overlay.tailAnchor, radius)
|
|
1021
|
+
: null;
|
|
710
1022
|
// Strong, clean near-black outline scaled to the preview size so
|
|
711
1023
|
// the bubble reads as a webtoon balloon (matching the export's
|
|
712
1024
|
// proportional stroke), not a faint UI box (#363).
|
|
@@ -726,116 +1038,164 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
726
1038
|
</svg>
|
|
727
1039
|
)}
|
|
728
1040
|
|
|
729
|
-
{imageBounds.width > 0 &&
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
1041
|
+
{imageBounds.width > 0 &&
|
|
1042
|
+
overlays.map((overlay) => {
|
|
1043
|
+
const left =
|
|
1044
|
+
imageBounds.x + toPixel(overlay.x, imageBounds.width);
|
|
1045
|
+
const top =
|
|
1046
|
+
imageBounds.y + toPixel(overlay.y, imageBounds.height);
|
|
1047
|
+
const width = toPixel(overlay.width, imageBounds.width);
|
|
1048
|
+
const height = toPixel(overlay.height, imageBounds.height);
|
|
1049
|
+
const isSelected = overlay.id === selectedId;
|
|
1050
|
+
// Speech bubbles draw no body border here — the integrated balloon
|
|
1051
|
+
// <path> in the layer below is their outline, so a box border would
|
|
1052
|
+
// re-introduce the body/tail seam (#327). Their selection cue is the
|
|
1053
|
+
// path's accent stroke (plus the resize handle). Narration/SFX keep
|
|
1054
|
+
// their bordered box + selection ring as before.
|
|
1055
|
+
const isSpeech = overlay.type === "speech";
|
|
1056
|
+
// Narration reads as an intentional parchment caption card (rounded,
|
|
1057
|
+
// filled), mirroring the export, instead of an empty bordered box (#363).
|
|
1058
|
+
const isNarration = overlay.type === "narration";
|
|
1059
|
+
const warned = !!overlayWarnings[overlay.id];
|
|
1060
|
+
|
|
1061
|
+
return (
|
|
1062
|
+
<div
|
|
1063
|
+
key={overlay.id}
|
|
1064
|
+
data-testid={`overlay-${overlay.id}`}
|
|
1065
|
+
data-warning={warned ? "true" : "false"}
|
|
1066
|
+
onClick={(e) => handleOverlayClick(e, overlay.id)}
|
|
1067
|
+
onMouseDown={(e) => handleMouseDown(e, overlay.id, "move")}
|
|
1068
|
+
className={`absolute rounded cursor-move select-none ${
|
|
1069
|
+
isSpeech ? "" : `border-2 ${TYPE_BORDER[overlay.type]}`
|
|
1070
|
+
} ${isNarration ? "bg-[#f4efe6]/85 rounded-md" : ""} ${
|
|
1071
|
+
isSelected && !isSpeech ? "ring-2 ring-accent" : ""
|
|
1072
|
+
} ${warned ? "ring-2 ring-amber-500" : ""}`}
|
|
1073
|
+
style={{ left, top, width, height }}
|
|
1074
|
+
>
|
|
1075
|
+
{(() => {
|
|
1076
|
+
const fontFamily =
|
|
1077
|
+
overlay.type === "sfx"
|
|
1078
|
+
? displayFontFamily
|
|
1079
|
+
: bodyFontFamily;
|
|
1080
|
+
if (!overlay.text) {
|
|
1081
|
+
return (
|
|
1082
|
+
<span
|
|
1083
|
+
className="text-[9px] px-1 text-muted truncate block pointer-events-none"
|
|
1084
|
+
style={{ fontFamily }}
|
|
1085
|
+
>
|
|
1086
|
+
{TYPE_LABEL[overlay.type]}
|
|
1087
|
+
</span>
|
|
1088
|
+
);
|
|
1089
|
+
}
|
|
1090
|
+
const hasSpeaker =
|
|
1091
|
+
overlay.type !== "sfx" && !!overlay.speaker;
|
|
1092
|
+
if (!fontsReady) {
|
|
1093
|
+
// Until the web font's metrics are available, don't freeze
|
|
1094
|
+
// canvas-measured line breaks from fallback metrics (they
|
|
1095
|
+
// would diverge from export). Show a CSS-wrapped transient;
|
|
1096
|
+
// the exact layout computes once fonts are ready (#310, re1).
|
|
1097
|
+
return (
|
|
1098
|
+
<div
|
|
1099
|
+
className="absolute inset-0 flex items-center justify-center px-1 overflow-hidden pointer-events-none text-center break-words"
|
|
1100
|
+
style={{
|
|
1101
|
+
fontFamily,
|
|
1102
|
+
fontSize: Math.max(9, Math.min(height * 0.05, 16)),
|
|
1103
|
+
fontWeight: overlay.textStyle?.fontWeight ?? 400,
|
|
1104
|
+
}}
|
|
1105
|
+
data-testid={`overlay-text-${overlay.id}`}
|
|
1106
|
+
data-fonts-ready="false"
|
|
1107
|
+
>
|
|
1108
|
+
{hasSpeaker
|
|
1109
|
+
? `${overlay.speaker}: ${overlay.text}`
|
|
1110
|
+
: overlay.text}
|
|
1111
|
+
</div>
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
const layout = layoutBubbleText(
|
|
1115
|
+
measureWidth(fontFamily),
|
|
1116
|
+
overlay.text,
|
|
1117
|
+
width,
|
|
1118
|
+
height,
|
|
1119
|
+
bubbleLayoutOptionsForOverlay(
|
|
1120
|
+
overlay,
|
|
1121
|
+
imageBounds.height,
|
|
1122
|
+
width,
|
|
1123
|
+
height,
|
|
1124
|
+
),
|
|
767
1125
|
);
|
|
768
|
-
}
|
|
769
|
-
const hasSpeaker = overlay.type !== "sfx" && !!overlay.speaker;
|
|
770
|
-
if (!fontsReady) {
|
|
771
|
-
// Until the web font's metrics are available, don't freeze
|
|
772
|
-
// canvas-measured line breaks from fallback metrics (they
|
|
773
|
-
// would diverge from export). Show a CSS-wrapped transient;
|
|
774
|
-
// the exact layout computes once fonts are ready (#310, re1).
|
|
775
1126
|
return (
|
|
776
1127
|
<div
|
|
777
|
-
className="absolute inset-0 flex items-center justify-center px-1 overflow-hidden pointer-events-none text-center
|
|
778
|
-
style={{
|
|
779
|
-
fontFamily,
|
|
780
|
-
fontSize: Math.max(9, Math.min(height * 0.05, 16)),
|
|
781
|
-
fontWeight: overlay.textStyle?.fontWeight ?? 400,
|
|
782
|
-
}}
|
|
1128
|
+
className="absolute inset-0 flex flex-col items-center justify-center px-1 overflow-hidden pointer-events-none text-center"
|
|
1129
|
+
style={{ fontFamily }}
|
|
783
1130
|
data-testid={`overlay-text-${overlay.id}`}
|
|
784
|
-
data-fonts-ready="
|
|
1131
|
+
data-fonts-ready="true"
|
|
785
1132
|
>
|
|
786
|
-
{hasSpeaker
|
|
1133
|
+
{hasSpeaker && (
|
|
1134
|
+
<span
|
|
1135
|
+
className="font-bold text-[#3a3a3a] block"
|
|
1136
|
+
style={{
|
|
1137
|
+
fontSize: layout.speakerFontSize,
|
|
1138
|
+
lineHeight: 1.2,
|
|
1139
|
+
}}
|
|
1140
|
+
>
|
|
1141
|
+
{overlay.speaker}
|
|
1142
|
+
</span>
|
|
1143
|
+
)}
|
|
1144
|
+
<span
|
|
1145
|
+
className="text-[#1a1a1a]"
|
|
1146
|
+
style={{
|
|
1147
|
+
fontSize: layout.fontSize,
|
|
1148
|
+
lineHeight: `${layout.lineHeight}px`,
|
|
1149
|
+
fontWeight: overlay.textStyle?.fontWeight ?? 400,
|
|
1150
|
+
}}
|
|
1151
|
+
>
|
|
1152
|
+
{layout.lines.map((line, i) => (
|
|
1153
|
+
<span key={i} className="block">
|
|
1154
|
+
{line}
|
|
1155
|
+
</span>
|
|
1156
|
+
))}
|
|
1157
|
+
</span>
|
|
787
1158
|
</div>
|
|
788
1159
|
);
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
measureWidth(fontFamily),
|
|
792
|
-
overlay.text,
|
|
793
|
-
width,
|
|
794
|
-
height,
|
|
795
|
-
bubbleLayoutOptionsForOverlay(overlay, imageBounds.height, width, height),
|
|
796
|
-
);
|
|
797
|
-
return (
|
|
1160
|
+
})()}
|
|
1161
|
+
{isSelected && (
|
|
798
1162
|
<div
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
{
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
className="text-[#1a1a1a]"
|
|
811
|
-
style={{ fontSize: layout.fontSize, lineHeight: `${layout.lineHeight}px`, fontWeight: overlay.textStyle?.fontWeight ?? 400 }}
|
|
812
|
-
>
|
|
813
|
-
{layout.lines.map((line, i) => (
|
|
814
|
-
<span key={i} className="block">{line}</span>
|
|
815
|
-
))}
|
|
816
|
-
</span>
|
|
817
|
-
</div>
|
|
818
|
-
);
|
|
819
|
-
})()}
|
|
820
|
-
{isSelected && (
|
|
821
|
-
<div
|
|
822
|
-
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, overlay.id, "resize"); }}
|
|
823
|
-
className="absolute bottom-0 right-0 w-2 h-2 bg-accent cursor-se-resize"
|
|
824
|
-
data-testid={`resize-${overlay.id}`}
|
|
825
|
-
/>
|
|
826
|
-
)}
|
|
827
|
-
</div>
|
|
828
|
-
);
|
|
829
|
-
})}
|
|
1163
|
+
onMouseDown={(e) => {
|
|
1164
|
+
e.stopPropagation();
|
|
1165
|
+
handleMouseDown(e, overlay.id, "resize");
|
|
1166
|
+
}}
|
|
1167
|
+
className="absolute bottom-0 right-0 w-2 h-2 bg-accent cursor-se-resize"
|
|
1168
|
+
data-testid={`resize-${overlay.id}`}
|
|
1169
|
+
/>
|
|
1170
|
+
)}
|
|
1171
|
+
</div>
|
|
1172
|
+
);
|
|
1173
|
+
})}
|
|
830
1174
|
</div>
|
|
831
1175
|
|
|
832
1176
|
{/* Inspector panel */}
|
|
833
|
-
<div className="w-
|
|
1177
|
+
<div className="w-64 border-l border-border p-3 overflow-y-auto flex-shrink-0">
|
|
1178
|
+
{cut.aiDraft?.status === "generated" && (
|
|
1179
|
+
<div
|
|
1180
|
+
className="mb-3 rounded border border-accent/30 bg-accent/5 p-2 space-y-1"
|
|
1181
|
+
data-testid="ai-draft-current-target"
|
|
1182
|
+
>
|
|
1183
|
+
<p className="text-[10px] font-bold uppercase tracking-[0.14em] text-accent">
|
|
1184
|
+
AI draft ready
|
|
1185
|
+
</p>
|
|
1186
|
+
<p className="text-[11px] text-muted">
|
|
1187
|
+
These first-pass overlays came from the cut script. Review and
|
|
1188
|
+
tune them here before exporting the final panel.
|
|
1189
|
+
</p>
|
|
1190
|
+
</div>
|
|
1191
|
+
)}
|
|
834
1192
|
{/* Insert-from-script (#336): drop the cut's planned dialogue/narration/
|
|
835
1193
|
SFX straight into a prefilled overlay — no copy/paste out of JSON. */}
|
|
836
1194
|
{scriptLines.length > 0 && (
|
|
837
1195
|
<div className="mb-3 space-y-1.5" data-testid="script-insert-panel">
|
|
838
|
-
<span className="text-[10px] font-medium text-muted">
|
|
1196
|
+
<span className="text-[10px] font-medium text-muted">
|
|
1197
|
+
From script
|
|
1198
|
+
</span>
|
|
839
1199
|
<div className="flex flex-col gap-1">
|
|
840
1200
|
{scriptLines.map((line) => (
|
|
841
1201
|
<button
|
|
@@ -845,10 +1205,14 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
845
1205
|
title={`Add ${line.type} overlay with this text`}
|
|
846
1206
|
className="text-left px-2 py-1 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
|
|
847
1207
|
>
|
|
848
|
-
<span className="font-medium text-accent"
|
|
1208
|
+
<span className="font-medium text-accent">
|
|
1209
|
+
+ {TYPE_LABEL[line.type]}
|
|
1210
|
+
</span>{" "}
|
|
849
1211
|
<span className="text-muted">
|
|
850
1212
|
{line.speaker ? `${line.speaker}: ` : ""}
|
|
851
|
-
{line.text.length > 32
|
|
1213
|
+
{line.text.length > 32
|
|
1214
|
+
? `${line.text.slice(0, 32)}…`
|
|
1215
|
+
: line.text}
|
|
852
1216
|
</span>
|
|
853
1217
|
</button>
|
|
854
1218
|
))}
|
|
@@ -857,14 +1221,22 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
857
1221
|
)}
|
|
858
1222
|
{selectedOverlay ? (
|
|
859
1223
|
<div className="space-y-3">
|
|
860
|
-
<p className="text-xs font-medium text-foreground">
|
|
1224
|
+
<p className="text-xs font-medium text-foreground">
|
|
1225
|
+
{TYPE_LABEL[selectedOverlay.type]}
|
|
1226
|
+
</p>
|
|
861
1227
|
|
|
862
1228
|
{selectedOverlay.speaker !== undefined && (
|
|
863
1229
|
<label className="block space-y-1">
|
|
864
|
-
<span className="text-[10px] font-medium text-muted">
|
|
1230
|
+
<span className="text-[10px] font-medium text-muted">
|
|
1231
|
+
Speaker
|
|
1232
|
+
</span>
|
|
865
1233
|
<input
|
|
866
1234
|
value={selectedOverlay.speaker || ""}
|
|
867
|
-
onChange={(e) =>
|
|
1235
|
+
onChange={(e) =>
|
|
1236
|
+
updateOverlay(selectedOverlay.id, {
|
|
1237
|
+
speaker: e.target.value,
|
|
1238
|
+
})
|
|
1239
|
+
}
|
|
868
1240
|
className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
869
1241
|
placeholder="Character name"
|
|
870
1242
|
data-testid="inspector-speaker"
|
|
@@ -876,7 +1248,9 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
876
1248
|
<span className="text-[10px] font-medium text-muted">Text</span>
|
|
877
1249
|
<textarea
|
|
878
1250
|
value={selectedOverlay.text}
|
|
879
|
-
onChange={(e) =>
|
|
1251
|
+
onChange={(e) =>
|
|
1252
|
+
updateOverlay(selectedOverlay.id, { text: e.target.value })
|
|
1253
|
+
}
|
|
880
1254
|
rows={3}
|
|
881
1255
|
className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent resize-none focus:border-accent focus:outline-none"
|
|
882
1256
|
placeholder="Overlay text"
|
|
@@ -887,7 +1261,9 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
887
1261
|
{/* One-click resize so a long line that overflows can be fitted
|
|
888
1262
|
without hand-dragging the box (#452). */}
|
|
889
1263
|
<button
|
|
890
|
-
onClick={() =>
|
|
1264
|
+
onClick={() =>
|
|
1265
|
+
updateOverlay(selectedOverlay.id, fittedSize(selectedOverlay))
|
|
1266
|
+
}
|
|
891
1267
|
data-testid="inspector-fit-text"
|
|
892
1268
|
className="w-full px-2 py-1 text-[11px] border border-border rounded hover:border-accent hover:text-accent"
|
|
893
1269
|
title="Resize this overlay so its text fits without overflowing"
|
|
@@ -895,13 +1271,22 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
895
1271
|
Fit box to text
|
|
896
1272
|
</button>
|
|
897
1273
|
|
|
898
|
-
<div
|
|
1274
|
+
<div
|
|
1275
|
+
className="space-y-1.5 rounded border border-border/70 p-2"
|
|
1276
|
+
data-testid="inspector-typography"
|
|
1277
|
+
>
|
|
899
1278
|
<div className="flex items-center justify-between gap-2">
|
|
900
|
-
<span className="text-[10px] font-medium text-muted">
|
|
1279
|
+
<span className="text-[10px] font-medium text-muted">
|
|
1280
|
+
Typography
|
|
1281
|
+
</span>
|
|
901
1282
|
{selectedOverlay.textStyle?.mode === "manual" ? (
|
|
902
1283
|
<button
|
|
903
1284
|
type="button"
|
|
904
|
-
onClick={() =>
|
|
1285
|
+
onClick={() =>
|
|
1286
|
+
updateOverlay(selectedOverlay.id, {
|
|
1287
|
+
textStyle: undefined,
|
|
1288
|
+
})
|
|
1289
|
+
}
|
|
905
1290
|
className="px-1.5 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
|
|
906
1291
|
data-testid="inspector-text-auto"
|
|
907
1292
|
>
|
|
@@ -921,20 +1306,32 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
921
1306
|
{selectedOverlay.textStyle?.mode === "manual" ? (
|
|
922
1307
|
<div className="space-y-1.5">
|
|
923
1308
|
<label className="block space-y-1">
|
|
924
|
-
<span className="text-[10px] text-muted">
|
|
1309
|
+
<span className="text-[10px] text-muted">
|
|
1310
|
+
Font size (% panel height)
|
|
1311
|
+
</span>
|
|
925
1312
|
<input
|
|
926
1313
|
type="number"
|
|
927
1314
|
step="0.1"
|
|
928
1315
|
min="1.5"
|
|
929
1316
|
max="12"
|
|
930
|
-
value={(
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
1317
|
+
value={(
|
|
1318
|
+
(selectedOverlay.textStyle.fontScale ?? 0.032) * 100
|
|
1319
|
+
).toFixed(1)}
|
|
1320
|
+
onChange={(e) =>
|
|
1321
|
+
updateOverlay(selectedOverlay.id, {
|
|
1322
|
+
textStyle: {
|
|
1323
|
+
...selectedOverlay.textStyle,
|
|
1324
|
+
mode: "manual",
|
|
1325
|
+
fontScale: Math.max(
|
|
1326
|
+
0.015,
|
|
1327
|
+
Math.min(
|
|
1328
|
+
0.12,
|
|
1329
|
+
(parseFloat(e.target.value) || 3.2) / 100,
|
|
1330
|
+
),
|
|
1331
|
+
),
|
|
1332
|
+
},
|
|
1333
|
+
})
|
|
1334
|
+
}
|
|
938
1335
|
className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
939
1336
|
data-testid="inspector-font-scale"
|
|
940
1337
|
/>
|
|
@@ -942,14 +1339,18 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
942
1339
|
<label className="block space-y-1">
|
|
943
1340
|
<span className="text-[10px] text-muted">Weight</span>
|
|
944
1341
|
<select
|
|
945
|
-
value={String(
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
1342
|
+
value={String(
|
|
1343
|
+
selectedOverlay.textStyle.fontWeight ?? 400,
|
|
1344
|
+
)}
|
|
1345
|
+
onChange={(e) =>
|
|
1346
|
+
updateOverlay(selectedOverlay.id, {
|
|
1347
|
+
textStyle: {
|
|
1348
|
+
...selectedOverlay.textStyle,
|
|
1349
|
+
mode: "manual",
|
|
1350
|
+
fontWeight: e.target.value === "700" ? 700 : 400,
|
|
1351
|
+
},
|
|
1352
|
+
})
|
|
1353
|
+
}
|
|
953
1354
|
className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
954
1355
|
data-testid="inspector-font-weight"
|
|
955
1356
|
>
|
|
@@ -958,40 +1359,61 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
958
1359
|
</select>
|
|
959
1360
|
</label>
|
|
960
1361
|
<label className="block space-y-1">
|
|
961
|
-
<span className="text-[10px] text-muted">
|
|
1362
|
+
<span className="text-[10px] text-muted">
|
|
1363
|
+
Line height
|
|
1364
|
+
</span>
|
|
962
1365
|
<input
|
|
963
1366
|
type="number"
|
|
964
1367
|
step="0.05"
|
|
965
1368
|
min="0.9"
|
|
966
1369
|
max="2"
|
|
967
|
-
value={(
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1370
|
+
value={(
|
|
1371
|
+
selectedOverlay.textStyle.lineHeightFactor ?? 1.2
|
|
1372
|
+
).toFixed(2)}
|
|
1373
|
+
onChange={(e) =>
|
|
1374
|
+
updateOverlay(selectedOverlay.id, {
|
|
1375
|
+
textStyle: {
|
|
1376
|
+
...selectedOverlay.textStyle,
|
|
1377
|
+
mode: "manual",
|
|
1378
|
+
lineHeightFactor: Math.max(
|
|
1379
|
+
0.9,
|
|
1380
|
+
Math.min(2, parseFloat(e.target.value) || 1.2),
|
|
1381
|
+
),
|
|
1382
|
+
},
|
|
1383
|
+
})
|
|
1384
|
+
}
|
|
975
1385
|
className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
976
1386
|
data-testid="inspector-line-height"
|
|
977
1387
|
/>
|
|
978
1388
|
</label>
|
|
979
1389
|
{selectedOverlay.type !== "sfx" && (
|
|
980
1390
|
<label className="block space-y-1">
|
|
981
|
-
<span className="text-[10px] text-muted">
|
|
1391
|
+
<span className="text-[10px] text-muted">
|
|
1392
|
+
Speaker scale
|
|
1393
|
+
</span>
|
|
982
1394
|
<input
|
|
983
1395
|
type="number"
|
|
984
1396
|
step="0.05"
|
|
985
1397
|
min="0.5"
|
|
986
1398
|
max="1.5"
|
|
987
|
-
value={(
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
1399
|
+
value={(
|
|
1400
|
+
selectedOverlay.textStyle.speakerScale ?? 0.8
|
|
1401
|
+
).toFixed(2)}
|
|
1402
|
+
onChange={(e) =>
|
|
1403
|
+
updateOverlay(selectedOverlay.id, {
|
|
1404
|
+
textStyle: {
|
|
1405
|
+
...selectedOverlay.textStyle,
|
|
1406
|
+
mode: "manual",
|
|
1407
|
+
speakerScale: Math.max(
|
|
1408
|
+
0.5,
|
|
1409
|
+
Math.min(
|
|
1410
|
+
1.5,
|
|
1411
|
+
parseFloat(e.target.value) || 0.8,
|
|
1412
|
+
),
|
|
1413
|
+
),
|
|
1414
|
+
},
|
|
1415
|
+
})
|
|
1416
|
+
}
|
|
995
1417
|
className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
996
1418
|
data-testid="inspector-speaker-scale"
|
|
997
1419
|
/>
|
|
@@ -999,109 +1421,176 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
999
1421
|
)}
|
|
1000
1422
|
</div>
|
|
1001
1423
|
) : (
|
|
1002
|
-
<p className="text-[10px] text-muted">
|
|
1424
|
+
<p className="text-[10px] text-muted">
|
|
1425
|
+
Auto-fit stays on by default and resizes text to the box.
|
|
1426
|
+
</p>
|
|
1003
1427
|
)}
|
|
1004
1428
|
</div>
|
|
1005
1429
|
|
|
1006
|
-
{selectedOverlay.type === "speech" &&
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
<
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1430
|
+
{selectedOverlay.type === "speech" &&
|
|
1431
|
+
(() => {
|
|
1432
|
+
const tail = selectedOverlay.tailAnchor || { x: 0.5, y: 1.2 };
|
|
1433
|
+
return (
|
|
1434
|
+
<div className="space-y-1">
|
|
1435
|
+
<span className="text-[10px] font-medium text-muted">
|
|
1436
|
+
Tail anchor
|
|
1437
|
+
</span>
|
|
1438
|
+
<div
|
|
1439
|
+
className="flex flex-wrap gap-1"
|
|
1440
|
+
data-testid="inspector-tail-presets"
|
|
1441
|
+
>
|
|
1442
|
+
{TAIL_PRESETS.map((preset) => (
|
|
1443
|
+
<button
|
|
1444
|
+
key={preset.key}
|
|
1445
|
+
type="button"
|
|
1446
|
+
onClick={() =>
|
|
1447
|
+
updateOverlay(selectedOverlay.id, {
|
|
1448
|
+
tailAnchor: preset.anchor,
|
|
1449
|
+
})
|
|
1450
|
+
}
|
|
1451
|
+
className="px-1.5 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
|
|
1452
|
+
data-testid={`inspector-tail-${preset.key}`}
|
|
1453
|
+
>
|
|
1454
|
+
{preset.label}
|
|
1455
|
+
</button>
|
|
1456
|
+
))}
|
|
1457
|
+
</div>
|
|
1458
|
+
<div className="flex gap-2">
|
|
1459
|
+
<label className="flex items-center gap-1 text-[10px] font-mono text-muted">
|
|
1460
|
+
x
|
|
1461
|
+
<input
|
|
1462
|
+
type="number"
|
|
1463
|
+
step="0.1"
|
|
1464
|
+
value={tail.x}
|
|
1465
|
+
onChange={(e) =>
|
|
1466
|
+
updateOverlay(selectedOverlay.id, {
|
|
1467
|
+
tailAnchor: {
|
|
1468
|
+
...tail,
|
|
1469
|
+
x: parseFloat(e.target.value) || 0,
|
|
1470
|
+
},
|
|
1471
|
+
})
|
|
1472
|
+
}
|
|
1473
|
+
className="w-14 px-1 py-0.5 text-[10px] border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
1474
|
+
data-testid="inspector-tail-x"
|
|
1475
|
+
/>
|
|
1476
|
+
</label>
|
|
1477
|
+
<label className="flex items-center gap-1 text-[10px] font-mono text-muted">
|
|
1478
|
+
y
|
|
1479
|
+
<input
|
|
1480
|
+
type="number"
|
|
1481
|
+
step="0.1"
|
|
1482
|
+
value={tail.y}
|
|
1483
|
+
onChange={(e) =>
|
|
1484
|
+
updateOverlay(selectedOverlay.id, {
|
|
1485
|
+
tailAnchor: {
|
|
1486
|
+
...tail,
|
|
1487
|
+
y: parseFloat(e.target.value) || 0,
|
|
1488
|
+
},
|
|
1489
|
+
})
|
|
1490
|
+
}
|
|
1491
|
+
className="w-14 px-1 py-0.5 text-[10px] border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
1492
|
+
data-testid="inspector-tail-y"
|
|
1493
|
+
/>
|
|
1494
|
+
</label>
|
|
1495
|
+
</div>
|
|
1047
1496
|
</div>
|
|
1048
|
-
|
|
1049
|
-
)
|
|
1050
|
-
})()}
|
|
1497
|
+
);
|
|
1498
|
+
})()}
|
|
1051
1499
|
|
|
1052
1500
|
{selectedOverlay.type !== "sfx" && (
|
|
1053
|
-
<div
|
|
1054
|
-
|
|
1501
|
+
<div
|
|
1502
|
+
className="space-y-1.5 rounded border border-border/70 p-2"
|
|
1503
|
+
data-testid="inspector-bubble-style"
|
|
1504
|
+
>
|
|
1505
|
+
<span className="text-[10px] font-medium text-muted">
|
|
1506
|
+
Bubble controls
|
|
1507
|
+
</span>
|
|
1055
1508
|
<label className="block space-y-1">
|
|
1056
|
-
<span className="text-[10px] text-muted">
|
|
1509
|
+
<span className="text-[10px] text-muted">
|
|
1510
|
+
Padding X (% width)
|
|
1511
|
+
</span>
|
|
1057
1512
|
<input
|
|
1058
1513
|
type="number"
|
|
1059
1514
|
step="1"
|
|
1060
1515
|
min="0"
|
|
1061
1516
|
max="25"
|
|
1062
|
-
value={(
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1517
|
+
value={(
|
|
1518
|
+
(selectedOverlay.bubbleStyle?.paddingX ?? 0.06) * 100
|
|
1519
|
+
).toFixed(0)}
|
|
1520
|
+
onChange={(e) =>
|
|
1521
|
+
updateOverlay(selectedOverlay.id, {
|
|
1522
|
+
bubbleStyle: {
|
|
1523
|
+
...selectedOverlay.bubbleStyle,
|
|
1524
|
+
paddingX: Math.max(
|
|
1525
|
+
0,
|
|
1526
|
+
Math.min(
|
|
1527
|
+
0.25,
|
|
1528
|
+
(parseFloat(e.target.value) || 6) / 100,
|
|
1529
|
+
),
|
|
1530
|
+
),
|
|
1531
|
+
},
|
|
1532
|
+
})
|
|
1533
|
+
}
|
|
1069
1534
|
className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
1070
1535
|
data-testid="inspector-padding-x"
|
|
1071
1536
|
/>
|
|
1072
1537
|
</label>
|
|
1073
1538
|
<label className="block space-y-1">
|
|
1074
|
-
<span className="text-[10px] text-muted">
|
|
1539
|
+
<span className="text-[10px] text-muted">
|
|
1540
|
+
Padding Y (% height)
|
|
1541
|
+
</span>
|
|
1075
1542
|
<input
|
|
1076
1543
|
type="number"
|
|
1077
1544
|
step="1"
|
|
1078
1545
|
min="0"
|
|
1079
1546
|
max="25"
|
|
1080
|
-
value={(
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1547
|
+
value={(
|
|
1548
|
+
(selectedOverlay.bubbleStyle?.paddingY ?? 0.08) * 100
|
|
1549
|
+
).toFixed(0)}
|
|
1550
|
+
onChange={(e) =>
|
|
1551
|
+
updateOverlay(selectedOverlay.id, {
|
|
1552
|
+
bubbleStyle: {
|
|
1553
|
+
...selectedOverlay.bubbleStyle,
|
|
1554
|
+
paddingY: Math.max(
|
|
1555
|
+
0,
|
|
1556
|
+
Math.min(
|
|
1557
|
+
0.25,
|
|
1558
|
+
(parseFloat(e.target.value) || 8) / 100,
|
|
1559
|
+
),
|
|
1560
|
+
),
|
|
1561
|
+
},
|
|
1562
|
+
})
|
|
1563
|
+
}
|
|
1087
1564
|
className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
1088
1565
|
data-testid="inspector-padding-y"
|
|
1089
1566
|
/>
|
|
1090
1567
|
</label>
|
|
1091
1568
|
<label className="block space-y-1">
|
|
1092
|
-
<span className="text-[10px] text-muted">
|
|
1569
|
+
<span className="text-[10px] text-muted">
|
|
1570
|
+
Corner roundness (% short side)
|
|
1571
|
+
</span>
|
|
1093
1572
|
<input
|
|
1094
1573
|
type="number"
|
|
1095
1574
|
step="1"
|
|
1096
1575
|
min="0"
|
|
1097
1576
|
max="49"
|
|
1098
|
-
value={(
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1577
|
+
value={(
|
|
1578
|
+
(selectedOverlay.bubbleStyle?.cornerRadius ?? 0.4) * 100
|
|
1579
|
+
).toFixed(0)}
|
|
1580
|
+
onChange={(e) =>
|
|
1581
|
+
updateOverlay(selectedOverlay.id, {
|
|
1582
|
+
bubbleStyle: {
|
|
1583
|
+
...selectedOverlay.bubbleStyle,
|
|
1584
|
+
cornerRadius: Math.max(
|
|
1585
|
+
0,
|
|
1586
|
+
Math.min(
|
|
1587
|
+
0.49,
|
|
1588
|
+
(parseFloat(e.target.value) || 40) / 100,
|
|
1589
|
+
),
|
|
1590
|
+
),
|
|
1591
|
+
},
|
|
1592
|
+
})
|
|
1593
|
+
}
|
|
1105
1594
|
className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
1106
1595
|
data-testid="inspector-corner-radius"
|
|
1107
1596
|
/>
|
|
@@ -1109,13 +1598,25 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
|
|
|
1109
1598
|
</div>
|
|
1110
1599
|
)}
|
|
1111
1600
|
|
|
1112
|
-
<div
|
|
1113
|
-
|
|
1601
|
+
<div
|
|
1602
|
+
className="text-[10px] text-muted"
|
|
1603
|
+
data-testid="inspector-font"
|
|
1604
|
+
>
|
|
1605
|
+
Font:{" "}
|
|
1606
|
+
{selectedOverlay.type === "sfx"
|
|
1607
|
+
? displayFont.family
|
|
1608
|
+
: bodyFont.family}
|
|
1114
1609
|
</div>
|
|
1115
1610
|
|
|
1116
1611
|
<div className="text-[10px] font-mono text-muted space-y-0.5">
|
|
1117
|
-
<p>
|
|
1118
|
-
|
|
1612
|
+
<p>
|
|
1613
|
+
x: {selectedOverlay.x.toFixed(3)}, y:{" "}
|
|
1614
|
+
{selectedOverlay.y.toFixed(3)}
|
|
1615
|
+
</p>
|
|
1616
|
+
<p>
|
|
1617
|
+
w: {selectedOverlay.width.toFixed(3)}, h:{" "}
|
|
1618
|
+
{selectedOverlay.height.toFixed(3)}
|
|
1619
|
+
</p>
|
|
1119
1620
|
</div>
|
|
1120
1621
|
|
|
1121
1622
|
<button
|