plotlink-ows 1.0.33 → 1.2.94
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 +4 -0
- package/app/lib/agent-command.ts +85 -0
- package/app/lib/agent-readiness.ts +133 -0
- package/app/lib/apply-schema.ts +55 -0
- package/app/lib/bubble-text.ts +160 -0
- package/app/lib/cartoon-coach.ts +198 -0
- package/app/lib/cartoon-markdown.ts +83 -0
- package/app/lib/cartoon-prompt.ts +122 -0
- package/app/lib/cartoon-readiness.ts +811 -0
- package/app/lib/clean-image-sync.ts +245 -0
- package/app/lib/codex-images.ts +152 -0
- package/app/lib/cut-asset-diagnostics.ts +120 -0
- package/app/lib/cuts.ts +302 -0
- package/app/lib/fonts.ts +109 -0
- package/app/lib/generate-claude-md.ts +8 -1
- package/app/lib/generate-story-instructions.ts +731 -0
- package/app/lib/image-asset-validate.ts +123 -0
- package/app/lib/lettering-status.ts +133 -0
- package/app/lib/overlays.ts +637 -0
- package/app/lib/paths.ts +10 -0
- package/app/lib/public-title.ts +65 -0
- package/app/lib/publish.ts +16 -2
- package/app/lib/story-progress.ts +243 -0
- package/app/lib/terminal-protocol.ts +16 -0
- package/app/lib/terminal-redact.ts +50 -0
- package/app/prisma/schema.sql +25 -0
- package/app/routes/agent.ts +42 -0
- package/app/routes/codex-images.ts +67 -0
- package/app/routes/publish.ts +203 -22
- package/app/routes/stories.ts +961 -5
- package/app/routes/terminal.ts +383 -31
- package/app/server.ts +47 -12
- package/app/vite.config.ts +6 -0
- package/app/web/components/CartoonPreview.tsx +267 -0
- package/app/web/components/CartoonPublishPage.tsx +407 -0
- package/app/web/components/CartoonPublishPreview.tsx +121 -0
- package/app/web/components/CartoonStepGuide.tsx +90 -0
- package/app/web/components/CartoonWorkflowNav.tsx +68 -0
- package/app/web/components/CodexImportPicker.tsx +230 -0
- package/app/web/components/CutListPanel.tsx +1299 -0
- package/app/web/components/EpisodesPage.tsx +80 -0
- package/app/web/components/FinishEpisodePanel.tsx +151 -0
- package/app/web/components/Layout.tsx +7 -4
- package/app/web/components/LetteringEditor.tsx +1141 -0
- package/app/web/components/PreviewPanel.tsx +951 -78
- package/app/web/components/Settings.tsx +63 -0
- package/app/web/components/StoriesPage.tsx +710 -33
- package/app/web/components/StoryBrowser.tsx +22 -14
- package/app/web/components/StoryInfoPage.tsx +266 -0
- package/app/web/components/StoryProgressPanel.tsx +516 -0
- package/app/web/components/TerminalPanel.tsx +233 -11
- package/app/web/components/WorkflowCoach.tsx +128 -0
- package/app/web/components/asset-image.tsx +114 -0
- package/app/web/components/asset-test-utils.ts +44 -0
- package/app/web/components/export-cut.ts +320 -0
- package/app/web/dist/assets/export-cut-nKQ_n2-J.js +1 -0
- package/app/web/dist/assets/index-BAZGwVwj.js +143 -0
- package/app/web/dist/assets/index-DoXH2OlP.css +32 -0
- package/app/web/dist/index.html +2 -2
- package/app/web/lib/cartoon-publish-summary.ts +43 -0
- package/app/web/lib/codex-import.ts +94 -0
- package/app/web/lib/image-compress.ts +53 -0
- package/app/web/lib/import-image.ts +58 -0
- package/app/web/lib/publish-helpers.ts +385 -0
- package/app/web/lib/upload-retry.ts +130 -0
- package/app/web/lib/verify-public-title.ts +105 -0
- package/app/web/styles.css +9 -0
- package/bin/plotlink-ows.js +53 -16
- package/bin/startup-plan.cjs +58 -0
- package/lib/genres.ts +92 -0
- package/package.json +60 -20
- package/scripts/gen-schema-sql.mjs +49 -0
- package/scripts/package-hygiene.mjs +116 -0
- package/scripts/preflight.mjs +173 -0
- package/scripts/start-smoke.mjs +128 -0
- package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/client.js +0 -5
- package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/default.js +0 -5
- package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/edge.js +0 -184
- package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
- package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
- package/app/node_modules/.prisma/local-client/index.js +0 -207
- package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
- package/app/node_modules/.prisma/local-client/package.json +0 -183
- package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
- package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
- package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
- package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
- package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
- package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
- package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
- package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
- package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
- package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
- package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
- package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
- package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
- package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
- package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/wasm.js +0 -191
- package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
- package/app/web/dist/assets/index-DxATSk7X.js +0 -134
- package/packages/cli/node_modules/commander/LICENSE +0 -22
- package/packages/cli/node_modules/commander/Readme.md +0 -1149
- package/packages/cli/node_modules/commander/esm.mjs +0 -16
- package/packages/cli/node_modules/commander/index.js +0 -24
- package/packages/cli/node_modules/commander/lib/argument.js +0 -149
- package/packages/cli/node_modules/commander/lib/command.js +0 -2662
- package/packages/cli/node_modules/commander/lib/error.js +0 -39
- package/packages/cli/node_modules/commander/lib/help.js +0 -709
- package/packages/cli/node_modules/commander/lib/option.js +0 -367
- package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
- package/packages/cli/node_modules/commander/package-support.json +0 -16
- package/packages/cli/node_modules/commander/package.json +0 -82
- package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
- package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
- package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
- package/packages/cli/node_modules/resolve-from/index.js +0 -47
- package/packages/cli/node_modules/resolve-from/license +0 -9
- package/packages/cli/node_modules/resolve-from/package.json +0 -36
- package/packages/cli/node_modules/resolve-from/readme.md +0 -72
- package/packages/cli/node_modules/tsup/LICENSE +0 -21
- package/packages/cli/node_modules/tsup/README.md +0 -75
- package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
- package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
- package/packages/cli/node_modules/tsup/assets/package.json +0 -3
- package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
- package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
- package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
- package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
- package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
- package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
- package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
- package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
- package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
- package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
- package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
- package/packages/cli/node_modules/tsup/package.json +0 -99
- package/packages/cli/node_modules/tsup/schema.json +0 -362
- package/public/screenshot-1.png +0 -0
- package/public/screenshot-2.png +0 -0
- package/public/screenshot-3.png +0 -0
- package/scripts/e2e-verify.ts +0 -1100
|
@@ -0,0 +1,1141 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
|
2
|
+
import {
|
|
3
|
+
getDefaultFont,
|
|
4
|
+
getDisplayFont,
|
|
5
|
+
getFontCdnUrl,
|
|
6
|
+
getFontFamily,
|
|
7
|
+
type FontEntry,
|
|
8
|
+
} from "@app-lib/fonts";
|
|
9
|
+
import {
|
|
10
|
+
speechTailPoints,
|
|
11
|
+
balloonPathD,
|
|
12
|
+
normalizeOverlays,
|
|
13
|
+
detectOverlappingOverlays,
|
|
14
|
+
isOverlayOutOfBounds,
|
|
15
|
+
createOverlay,
|
|
16
|
+
comfortableOverlaySize,
|
|
17
|
+
bubbleLayoutOptionsForOverlay,
|
|
18
|
+
balloonRadiusForOverlay,
|
|
19
|
+
type Overlay,
|
|
20
|
+
type OverlayType,
|
|
21
|
+
} from "@app-lib/overlays";
|
|
22
|
+
import { layoutBubbleText } from "@app-lib/bubble-text";
|
|
23
|
+
import { cutLetteringChecklist, cutScriptLines, isExportStale, overlaysSignature, type ScriptLine } from "@app-lib/lettering-status";
|
|
24
|
+
import { textPanelDimensions } from "@app-lib/cuts";
|
|
25
|
+
import { useAuthedAsset } from "./asset-image";
|
|
26
|
+
|
|
27
|
+
function toPixel(norm: number, size: number): number {
|
|
28
|
+
return norm * size;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function toNorm(pixel: number, size: number): number {
|
|
32
|
+
if (size === 0) return 0;
|
|
33
|
+
return pixel / size;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function loadFont(font: FontEntry) {
|
|
37
|
+
const id = `gfont-${font.googleFontsId}`;
|
|
38
|
+
if (document.getElementById(id)) return;
|
|
39
|
+
const link = document.createElement("link");
|
|
40
|
+
link.id = id;
|
|
41
|
+
link.rel = "stylesheet";
|
|
42
|
+
link.href = getFontCdnUrl(font);
|
|
43
|
+
document.head.appendChild(link);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface Cut {
|
|
47
|
+
id: number;
|
|
48
|
+
cleanImagePath: string | null;
|
|
49
|
+
overlays: Overlay[];
|
|
50
|
+
narration?: string;
|
|
51
|
+
sfx?: string;
|
|
52
|
+
dialogue?: { speaker: string; text: string }[];
|
|
53
|
+
// Export/upload status (#336) — used by the per-cut lettering checklist so the
|
|
54
|
+
// writer can see how far the cut has progressed without leaving the editor.
|
|
55
|
+
finalImagePath?: string | null;
|
|
56
|
+
exportedAt?: string | null;
|
|
57
|
+
uploadedUrl?: string | null;
|
|
58
|
+
uploadedCid?: string | null;
|
|
59
|
+
// Text/interstitial panel (#350/#351): no clean image — the editor uses a
|
|
60
|
+
// styled background canvas and exports it as the final image.
|
|
61
|
+
kind?: "image" | "text";
|
|
62
|
+
background?: string;
|
|
63
|
+
aspectRatio?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface LetteringEditorProps {
|
|
67
|
+
storyName: string;
|
|
68
|
+
cut: Cut;
|
|
69
|
+
plotFile: string;
|
|
70
|
+
onSave: (overlays: Overlay[]) => void | Promise<void>;
|
|
71
|
+
onClose: () => void;
|
|
72
|
+
onExported?: () => void;
|
|
73
|
+
language?: string;
|
|
74
|
+
authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const TYPE_LABEL: Record<OverlayType, string> = {
|
|
78
|
+
speech: "Speech",
|
|
79
|
+
narration: "Narration",
|
|
80
|
+
sfx: "SFX",
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const TYPE_BORDER: Record<OverlayType, string> = {
|
|
84
|
+
speech: "border-foreground/40",
|
|
85
|
+
narration: "border-muted/40",
|
|
86
|
+
sfx: "border-accent/40",
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Short human label for a bubble in the overlap warning (#318): its speaker or
|
|
90
|
+
// a trimmed text snippet, falling back to the type name for empty bubbles.
|
|
91
|
+
function overlapLabel(o: Overlay): string {
|
|
92
|
+
const snippet = (o.speaker || o.text || "").trim().replace(/\s+/g, " ");
|
|
93
|
+
if (snippet) return `“${snippet.length > 18 ? `${snippet.slice(0, 18)}…` : snippet}”`;
|
|
94
|
+
return TYPE_LABEL[o.type];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const MIN_SIZE = 0.05;
|
|
98
|
+
const TAIL_PRESETS = [
|
|
99
|
+
{ key: "down", label: "Down", anchor: { x: 0.5, y: 1.2 } },
|
|
100
|
+
{ key: "up", label: "Up", anchor: { x: 0.5, y: -0.2 } },
|
|
101
|
+
{ key: "left", label: "Left", anchor: { x: -0.2, y: 0.5 } },
|
|
102
|
+
{ key: "right", label: "Right", anchor: { x: 1.2, y: 0.5 } },
|
|
103
|
+
] as const;
|
|
104
|
+
|
|
105
|
+
function clamp(v: number, min: number, max: number): number {
|
|
106
|
+
return Math.min(max, Math.max(min, v));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onExported, language = "English", authFetch }: LetteringEditorProps) {
|
|
110
|
+
const bodyFont = getDefaultFont(language);
|
|
111
|
+
const displayFont = getDisplayFont();
|
|
112
|
+
const bodyFontFamily = getFontFamily(bodyFont);
|
|
113
|
+
const displayFontFamily = getFontFamily(displayFont);
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
loadFont(bodyFont);
|
|
117
|
+
loadFont(displayFont);
|
|
118
|
+
}, [bodyFont, displayFont]);
|
|
119
|
+
|
|
120
|
+
// Wait for the same fonts export waits on, then allow the exact preview layout
|
|
121
|
+
// to compute/recompute with the loaded font metrics (#310, re1).
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
let cancelled = false;
|
|
124
|
+
setFontsReady(false);
|
|
125
|
+
(async () => {
|
|
126
|
+
try {
|
|
127
|
+
const { ensureFontsReady } = await import("./export-cut");
|
|
128
|
+
await ensureFontsReady([bodyFont.family, displayFont.family]);
|
|
129
|
+
} catch { /* best effort — still render the preview */ }
|
|
130
|
+
if (!cancelled) setFontsReady(true);
|
|
131
|
+
})();
|
|
132
|
+
return () => { cancelled = true; };
|
|
133
|
+
}, [bodyFont.family, displayFont.family]);
|
|
134
|
+
|
|
135
|
+
// Clean image lives behind requireAuth, so a raw <img src> would 401. Load it
|
|
136
|
+
// via authFetch into a blob object URL and reuse that same URL for export.
|
|
137
|
+
const cleanAsset = useAuthedAsset(storyName, cut.cleanImagePath, authFetch);
|
|
138
|
+
// Repair agent-authored overlays (e.g. semantic `position` strings with no
|
|
139
|
+
// numeric geometry) on load so the bubbles actually render and export — and
|
|
140
|
+
// surface a note when some could not be auto-placed (#309).
|
|
141
|
+
const overlayNormalization = useMemo(() => normalizeOverlays(cut.overlays), [cut.overlays]);
|
|
142
|
+
const invalidOverlayCount = overlayNormalization.invalid.length;
|
|
143
|
+
// Overlays that could not be placed (no geometry, no recognizable position)
|
|
144
|
+
// are NOT exported. Exporting silently would produce a final missing that
|
|
145
|
+
// bubble/text, so block export until the writer explicitly discards them (#309).
|
|
146
|
+
const [acknowledgedInvalid, setAcknowledgedInvalid] = useState(false);
|
|
147
|
+
const autoPlacedOverlays =
|
|
148
|
+
invalidOverlayCount === 0 && overlayNormalization.changed && overlayNormalization.overlays.length > 0;
|
|
149
|
+
const [overlays, setOverlays] = useState<Overlay[]>(() => overlayNormalization.overlays as Overlay[]);
|
|
150
|
+
// Signature of the overlays that match the current export/upload (#336, re1).
|
|
151
|
+
// Captured (already normalized like the live `overlays`) when the cut opens so
|
|
152
|
+
// a load-time normalization isn't mistaken for a user edit, and advanced to the
|
|
153
|
+
// live overlays after a successful re-export so the stale flag clears without
|
|
154
|
+
// closing the editor. As state, so updating it recomputes staleExport.
|
|
155
|
+
const [exportBaselineSig, setExportBaselineSig] = useState(() =>
|
|
156
|
+
overlaysSignature(overlayNormalization.overlays as Overlay[]),
|
|
157
|
+
);
|
|
158
|
+
// Offscreen canvas to measure text exactly like the export canvas, so the
|
|
159
|
+
// preview wraps/sizes bubble text identically to the final image (#310).
|
|
160
|
+
const measureCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
161
|
+
const measureWidth = useCallback((fontFamily: string) => (text: string, fontSize: number, fontWeight: 400 | 700 = 400): number => {
|
|
162
|
+
if (!measureCanvasRef.current && typeof document !== "undefined") {
|
|
163
|
+
measureCanvasRef.current = document.createElement("canvas");
|
|
164
|
+
}
|
|
165
|
+
const mctx = measureCanvasRef.current?.getContext("2d");
|
|
166
|
+
if (!mctx) return text.length * fontSize * 0.5; // jsdom fallback
|
|
167
|
+
mctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
|
|
168
|
+
return mctx.measureText(text).width;
|
|
169
|
+
}, []);
|
|
170
|
+
// Gate the exact (canvas-measured) preview layout on the SAME font-readiness
|
|
171
|
+
// signal export uses (ensureFontsReady), so the preview does not freeze line
|
|
172
|
+
// breaks computed from fallback-font metrics that would diverge from the
|
|
173
|
+
// exported image (#310, re1). Recomputes once the web fonts are loaded.
|
|
174
|
+
const [fontsReady, setFontsReady] = useState(false);
|
|
175
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
176
|
+
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
177
|
+
const [exporting, setExporting] = useState(false);
|
|
178
|
+
const [exportError, setExportError] = useState<string | null>(null);
|
|
179
|
+
const [imageBounds, setImageBounds] = useState({ x: 0, y: 0, width: 0, height: 0 });
|
|
180
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
181
|
+
const imgRef = useRef<HTMLImageElement>(null);
|
|
182
|
+
const dragRef = useRef<{ id: string; mode: "move" | "resize"; startX: number; startY: number; origX: number; origY: number; origW: number; origH: number } | null>(null);
|
|
183
|
+
|
|
184
|
+
const updateImageBounds = useCallback(() => {
|
|
185
|
+
const container = containerRef.current;
|
|
186
|
+
if (!container) return;
|
|
187
|
+
const cw = container.clientWidth;
|
|
188
|
+
const ch = container.clientHeight;
|
|
189
|
+
let iw: number;
|
|
190
|
+
let ih: number;
|
|
191
|
+
if (cut.kind === "text") {
|
|
192
|
+
// A text panel has no image — size the editor canvas from the SAME aspect
|
|
193
|
+
// ratio the export uses, so lettering and the exported final agree (#351).
|
|
194
|
+
const dims = textPanelDimensions(cut.aspectRatio) ?? { width: 800, height: 600 };
|
|
195
|
+
iw = dims.width;
|
|
196
|
+
ih = dims.height;
|
|
197
|
+
} else {
|
|
198
|
+
const img = imgRef.current;
|
|
199
|
+
if (!img || !img.naturalWidth) return;
|
|
200
|
+
iw = img.naturalWidth;
|
|
201
|
+
ih = img.naturalHeight;
|
|
202
|
+
}
|
|
203
|
+
if (!cw || !ch) return;
|
|
204
|
+
const scale = Math.min(cw / iw, ch / ih);
|
|
205
|
+
const rw = iw * scale;
|
|
206
|
+
const rh = ih * scale;
|
|
207
|
+
setImageBounds({ x: (cw - rw) / 2, y: (ch - rh) / 2, width: rw, height: rh });
|
|
208
|
+
}, [cut.kind, cut.aspectRatio]);
|
|
209
|
+
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
const el = containerRef.current;
|
|
212
|
+
if (!el) return;
|
|
213
|
+
const observer = new ResizeObserver(() => updateImageBounds());
|
|
214
|
+
observer.observe(el);
|
|
215
|
+
return () => observer.disconnect();
|
|
216
|
+
}, [updateImageBounds]);
|
|
217
|
+
|
|
218
|
+
// Size an overlay so ordinary narration/dialogue lines don't overflow the box
|
|
219
|
+
// the instant they're added (#452). With the loaded fonts + image metrics it
|
|
220
|
+
// grows the height (at a comfortable on-image width) until the text fits at the
|
|
221
|
+
// default font; without measurement it falls back to a generous default that
|
|
222
|
+
// fits ordinary lines, instead of the tiny create-default. The writer can still
|
|
223
|
+
// resize freely afterward, and the overflow warning stays useful if they shrink it.
|
|
224
|
+
const fittedSize = useCallback((o: Overlay): { width: number; height: number } => {
|
|
225
|
+
const comfortable = comfortableOverlaySize(o.type, o.x, o.y);
|
|
226
|
+
const width = comfortable.width;
|
|
227
|
+
const maxH = Math.max(0.08, 1 - o.y);
|
|
228
|
+
if (!o.text || !fontsReady || imageBounds.width <= 0) {
|
|
229
|
+
return comfortable;
|
|
230
|
+
}
|
|
231
|
+
const fontFamily = o.type === "sfx" ? displayFontFamily : bodyFontFamily;
|
|
232
|
+
const wPx = toPixel(width, imageBounds.width);
|
|
233
|
+
let height = o.type === "sfx" ? 0.08 : 0.12;
|
|
234
|
+
for (let i = 0; i < 24; i++) {
|
|
235
|
+
const h = Math.min(height, maxH);
|
|
236
|
+
const hPx = toPixel(h, imageBounds.height);
|
|
237
|
+
const layout = layoutBubbleText(
|
|
238
|
+
measureWidth(fontFamily), o.text, wPx, hPx,
|
|
239
|
+
bubbleLayoutOptionsForOverlay({ ...o, width, height: h }, imageBounds.height || 300, wPx, hPx),
|
|
240
|
+
);
|
|
241
|
+
if (!layout.overflow || h >= maxH) return { width, height: h };
|
|
242
|
+
height += 0.03;
|
|
243
|
+
}
|
|
244
|
+
return { width, height: Math.min(height, maxH) };
|
|
245
|
+
}, [fontsReady, imageBounds, measureWidth, bodyFontFamily, displayFontFamily]);
|
|
246
|
+
|
|
247
|
+
const addOverlay = useCallback((type: OverlayType) => {
|
|
248
|
+
const o = createOverlay(type, 0.1 + Math.random() * 0.3, 0.1 + Math.random() * 0.3);
|
|
249
|
+
const sized: Overlay = { ...o, ...fittedSize(o) };
|
|
250
|
+
setOverlays((prev) => [...prev, sized]);
|
|
251
|
+
setSelectedId(sized.id);
|
|
252
|
+
}, [fittedSize]);
|
|
253
|
+
|
|
254
|
+
// Insert a line from the cut's cuts.json script (#336) as a prefilled overlay,
|
|
255
|
+
// so the writer never has to hand-copy dialogue/narration/SFX out of the JSON.
|
|
256
|
+
const addScriptLine = useCallback((line: ScriptLine) => {
|
|
257
|
+
const o = createOverlay(line.type, 0.1 + Math.random() * 0.3, 0.1 + Math.random() * 0.3);
|
|
258
|
+
const filled: Overlay = {
|
|
259
|
+
...o,
|
|
260
|
+
text: line.text,
|
|
261
|
+
...(line.type === "speech" && line.speaker ? { speaker: line.speaker } : {}),
|
|
262
|
+
};
|
|
263
|
+
const sized: Overlay = { ...filled, ...fittedSize(filled) };
|
|
264
|
+
setOverlays((prev) => [...prev, sized]);
|
|
265
|
+
setSelectedId(sized.id);
|
|
266
|
+
}, [fittedSize]);
|
|
267
|
+
|
|
268
|
+
const updateOverlay = useCallback((id: string, changes: Partial<Overlay>) => {
|
|
269
|
+
setOverlays((prev) => prev.map((o) => o.id === id ? { ...o, ...changes } : o));
|
|
270
|
+
}, []);
|
|
271
|
+
|
|
272
|
+
const enableManualTypography = useCallback((overlay: Overlay) => {
|
|
273
|
+
const renderHeight = imageBounds.height || 300;
|
|
274
|
+
const width = imageBounds.width > 0 ? toPixel(overlay.width, imageBounds.width) : 200;
|
|
275
|
+
const height = imageBounds.height > 0 ? toPixel(overlay.height, imageBounds.height) : 100;
|
|
276
|
+
const fontFamily = overlay.type === "sfx" ? displayFontFamily : bodyFontFamily;
|
|
277
|
+
const autoLayout = layoutBubbleText(
|
|
278
|
+
measureWidth(fontFamily),
|
|
279
|
+
overlay.text,
|
|
280
|
+
width,
|
|
281
|
+
height,
|
|
282
|
+
bubbleLayoutOptionsForOverlay({ ...overlay, textStyle: undefined }, renderHeight, width, height),
|
|
283
|
+
);
|
|
284
|
+
updateOverlay(overlay.id, {
|
|
285
|
+
textStyle: {
|
|
286
|
+
mode: "manual",
|
|
287
|
+
fontScale: autoLayout.fontSize / Math.max(1, renderHeight),
|
|
288
|
+
fontWeight: overlay.textStyle?.fontWeight ?? 400,
|
|
289
|
+
lineHeightFactor: autoLayout.fontSize > 0 ? autoLayout.lineHeight / autoLayout.fontSize : 1.2,
|
|
290
|
+
speakerScale: autoLayout.fontSize > 0 && autoLayout.speakerFontSize > 0 ? autoLayout.speakerFontSize / autoLayout.fontSize : 0.8,
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
}, [imageBounds, displayFontFamily, bodyFontFamily, measureWidth, updateOverlay]);
|
|
294
|
+
|
|
295
|
+
const deleteOverlay = useCallback((id: string) => {
|
|
296
|
+
setOverlays((prev) => prev.filter((o) => o.id !== id));
|
|
297
|
+
setSelectedId(null);
|
|
298
|
+
setConfirmDelete(false);
|
|
299
|
+
}, []);
|
|
300
|
+
|
|
301
|
+
const handleBackgroundClick = useCallback(() => {
|
|
302
|
+
setSelectedId(null);
|
|
303
|
+
setConfirmDelete(false);
|
|
304
|
+
}, []);
|
|
305
|
+
|
|
306
|
+
const handleOverlayClick = useCallback((e: React.MouseEvent, id: string) => {
|
|
307
|
+
e.stopPropagation();
|
|
308
|
+
setSelectedId(id);
|
|
309
|
+
setConfirmDelete(false);
|
|
310
|
+
}, []);
|
|
311
|
+
|
|
312
|
+
const handleMouseDown = useCallback((e: React.MouseEvent, id: string, mode: "move" | "resize") => {
|
|
313
|
+
e.stopPropagation();
|
|
314
|
+
e.preventDefault();
|
|
315
|
+
const overlay = overlays.find((o) => o.id === id);
|
|
316
|
+
if (!overlay) return;
|
|
317
|
+
setSelectedId(id);
|
|
318
|
+
dragRef.current = {
|
|
319
|
+
id,
|
|
320
|
+
mode,
|
|
321
|
+
startX: e.clientX,
|
|
322
|
+
startY: e.clientY,
|
|
323
|
+
origX: overlay.x,
|
|
324
|
+
origY: overlay.y,
|
|
325
|
+
origW: overlay.width,
|
|
326
|
+
origH: overlay.height,
|
|
327
|
+
};
|
|
328
|
+
}, [overlays]);
|
|
329
|
+
|
|
330
|
+
useEffect(() => {
|
|
331
|
+
const onMouseMove = (e: MouseEvent) => {
|
|
332
|
+
const drag = dragRef.current;
|
|
333
|
+
if (!drag || imageBounds.width === 0) return;
|
|
334
|
+
const dx = toNorm(e.clientX - drag.startX, imageBounds.width);
|
|
335
|
+
const dy = toNorm(e.clientY - drag.startY, imageBounds.height);
|
|
336
|
+
|
|
337
|
+
if (drag.mode === "move") {
|
|
338
|
+
const newX = clamp(drag.origX + dx, 0, 1 - drag.origW);
|
|
339
|
+
const newY = clamp(drag.origY + dy, 0, 1 - drag.origH);
|
|
340
|
+
updateOverlay(drag.id, { x: newX, y: newY });
|
|
341
|
+
} else {
|
|
342
|
+
const newW = clamp(drag.origW + dx, MIN_SIZE, 1 - drag.origX);
|
|
343
|
+
const newH = clamp(drag.origH + dy, MIN_SIZE, 1 - drag.origY);
|
|
344
|
+
updateOverlay(drag.id, { width: newW, height: newH });
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const onMouseUp = () => { dragRef.current = null; };
|
|
349
|
+
|
|
350
|
+
window.addEventListener("mousemove", onMouseMove);
|
|
351
|
+
window.addEventListener("mouseup", onMouseUp);
|
|
352
|
+
return () => {
|
|
353
|
+
window.removeEventListener("mousemove", onMouseMove);
|
|
354
|
+
window.removeEventListener("mouseup", onMouseUp);
|
|
355
|
+
};
|
|
356
|
+
}, [imageBounds, updateOverlay]);
|
|
357
|
+
|
|
358
|
+
const handleSave = useCallback(() => {
|
|
359
|
+
onSave(overlays);
|
|
360
|
+
}, [overlays, onSave]);
|
|
361
|
+
|
|
362
|
+
const handleExport = useCallback(async () => {
|
|
363
|
+
// Block export when the cut plan contained overlays that could not be placed
|
|
364
|
+
// (no numeric geometry, no recognizable position). Dropping them silently
|
|
365
|
+
// would export an image missing the intended bubble/text (#309, re1).
|
|
366
|
+
// Require an explicit discard first — do not save or export the reduced set.
|
|
367
|
+
if (invalidOverlayCount > 0 && !acknowledgedInvalid) {
|
|
368
|
+
const c = invalidOverlayCount;
|
|
369
|
+
setExportError(
|
|
370
|
+
`${c} overlay${c === 1 ? "" : "s"} from the cut plan ${c === 1 ? "has" : "have"} no usable position and cannot be exported — re-place ${c === 1 ? "it" : "them"} or discard ${c === 1 ? "it" : "them"} first.`,
|
|
371
|
+
);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
setExporting(true);
|
|
375
|
+
setExportError(null);
|
|
376
|
+
try {
|
|
377
|
+
await onSave(overlays);
|
|
378
|
+
|
|
379
|
+
const { exportCut, ensureFontsReady } = await import("./export-cut");
|
|
380
|
+
|
|
381
|
+
const usesSfx = overlays.some((o) => o.type === "sfx");
|
|
382
|
+
const fontsToCheck = [bodyFont.family, ...(usesSfx ? [displayFont.family] : [])];
|
|
383
|
+
const { ready, missing } = await ensureFontsReady(fontsToCheck);
|
|
384
|
+
if (!ready) {
|
|
385
|
+
setExportError(`Fonts not loaded: ${missing.join(", ")}. Check your connection and retry.`);
|
|
386
|
+
setExporting(false);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Export the actual loaded clean image, never a blank canvas standing in
|
|
391
|
+
// for an image that simply failed to load.
|
|
392
|
+
if (cut.cleanImagePath && !cleanAsset.url) {
|
|
393
|
+
setExportError(
|
|
394
|
+
cleanAsset.error
|
|
395
|
+
? "Clean image failed to load — cannot export. Retry once it renders."
|
|
396
|
+
: "Clean image still loading — wait for it to render, then export.",
|
|
397
|
+
);
|
|
398
|
+
setExporting(false);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const imgUrl = cleanAsset.url;
|
|
402
|
+
const blob = await exportCut(
|
|
403
|
+
imgUrl,
|
|
404
|
+
overlays,
|
|
405
|
+
bodyFontFamily,
|
|
406
|
+
displayFontFamily,
|
|
407
|
+
{ narration: cut.narration, dialogue: cut.dialogue },
|
|
408
|
+
// Text panels have no clean image — render the final on a styled
|
|
409
|
+
// background canvas sized by the panel's aspect ratio (#351).
|
|
410
|
+
cut.kind === "text" ? { background: cut.background, aspectRatio: cut.aspectRatio } : undefined,
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
const fd = new FormData();
|
|
414
|
+
const ext = blob.type === "image/webp" ? "webp" : "jpg";
|
|
415
|
+
fd.append("file", blob, `cut-${cut.id}.${ext}`);
|
|
416
|
+
|
|
417
|
+
const res = await authFetch(
|
|
418
|
+
`/api/stories/${storyName}/cuts/${plotFile}/export-final/${cut.id}`,
|
|
419
|
+
{ method: "POST", body: fd },
|
|
420
|
+
);
|
|
421
|
+
if (!res.ok) {
|
|
422
|
+
const data = await res.json();
|
|
423
|
+
setExportError(data.error || "Export failed");
|
|
424
|
+
} else {
|
|
425
|
+
// The just-exported overlays are now the export baseline, so the stale
|
|
426
|
+
// warning/checklist clear immediately without closing the editor (re1).
|
|
427
|
+
setExportBaselineSig(overlaysSignature(overlays));
|
|
428
|
+
onExported?.();
|
|
429
|
+
}
|
|
430
|
+
} catch (err) {
|
|
431
|
+
setExportError(err instanceof Error ? err.message : "Export failed");
|
|
432
|
+
} finally {
|
|
433
|
+
setExporting(false);
|
|
434
|
+
}
|
|
435
|
+
}, [cut, cleanAsset, overlays, storyName, plotFile, bodyFont, displayFont, bodyFontFamily, displayFontFamily, authFetch, onSave, onExported, invalidOverlayCount, acknowledgedInvalid]);
|
|
436
|
+
|
|
437
|
+
const selectedOverlay = overlays.find((o) => o.id === selectedId);
|
|
438
|
+
|
|
439
|
+
// Flag bubbles whose filled bodies overlap enough to hide each other's text so
|
|
440
|
+
// the writer gets a readability warning before export/publish (#318). Computed
|
|
441
|
+
// from the live overlay positions, so it clears as soon as bubbles are moved
|
|
442
|
+
// apart. Non-blocking: overlap can be intentional, so it never blocks export.
|
|
443
|
+
const overlapPairs = useMemo(() => detectOverlappingOverlays(overlays), [overlays]);
|
|
444
|
+
|
|
445
|
+
// Re-baseline when a different cut opens without a remount (rare — the parent
|
|
446
|
+
// normally unmounts the editor between cuts).
|
|
447
|
+
const baselineCutIdRef = useRef(cut.id);
|
|
448
|
+
useEffect(() => {
|
|
449
|
+
if (baselineCutIdRef.current !== cut.id) {
|
|
450
|
+
baselineCutIdRef.current = cut.id;
|
|
451
|
+
setExportBaselineSig(overlaysSignature(overlayNormalization.overlays as Overlay[]));
|
|
452
|
+
}
|
|
453
|
+
}, [cut.id, overlayNormalization.overlays]);
|
|
454
|
+
|
|
455
|
+
// The recorded export/upload is stale once the writer edits bubbles since it
|
|
456
|
+
// was produced — the final image/uploaded URL no longer match the screen, so
|
|
457
|
+
// export & upload must be redone before they count again (#336, re1).
|
|
458
|
+
const staleExport = isExportStale({
|
|
459
|
+
exported: !!cut.finalImagePath || !!cut.exportedAt,
|
|
460
|
+
uploaded: !!cut.uploadedUrl || !!cut.uploadedCid,
|
|
461
|
+
baselineSig: exportBaselineSig,
|
|
462
|
+
current: overlays,
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// Per-cut lettering checklist + insertable script lines (#336). The checklist
|
|
466
|
+
// shows progress (clean image → script text → bubbles placed → exported →
|
|
467
|
+
// uploaded) right in the editor; the script lines power one-click insertion.
|
|
468
|
+
// A stale export marks the exported/uploaded steps incomplete until re-export.
|
|
469
|
+
const checklist = useMemo(
|
|
470
|
+
() => cutLetteringChecklist({ ...cut, overlays }, { staleExport }),
|
|
471
|
+
[cut, overlays, staleExport],
|
|
472
|
+
);
|
|
473
|
+
const scriptLines = useMemo(() => cutScriptLines(cut), [cut]);
|
|
474
|
+
|
|
475
|
+
// Likely export problems per overlay (#336): the body rect clipped by the
|
|
476
|
+
// image bounds, or text that overflows even at the smallest font. Out-of-bounds
|
|
477
|
+
// is pure geometry; overflow needs the loaded-font metrics, so it only computes
|
|
478
|
+
// once fonts are ready (same gate as the exact preview layout).
|
|
479
|
+
const overlayWarnings = useMemo(() => {
|
|
480
|
+
const out: Record<string, { outOfBounds: boolean; overflow: boolean }> = {};
|
|
481
|
+
for (const o of overlays) {
|
|
482
|
+
const outOfBounds = isOverlayOutOfBounds(o);
|
|
483
|
+
let overflow = false;
|
|
484
|
+
if (fontsReady && imageBounds.width > 0 && o.text) {
|
|
485
|
+
const fontFamily = o.type === "sfx" ? displayFontFamily : bodyFontFamily;
|
|
486
|
+
const w = toPixel(o.width, imageBounds.width);
|
|
487
|
+
const h = toPixel(o.height, imageBounds.height);
|
|
488
|
+
const layout = layoutBubbleText(
|
|
489
|
+
measureWidth(fontFamily),
|
|
490
|
+
o.text,
|
|
491
|
+
w,
|
|
492
|
+
h,
|
|
493
|
+
bubbleLayoutOptionsForOverlay(o, imageBounds.height || 300, w, h),
|
|
494
|
+
);
|
|
495
|
+
overflow = layout.overflow;
|
|
496
|
+
}
|
|
497
|
+
if (outOfBounds || overflow) out[o.id] = { outOfBounds, overflow };
|
|
498
|
+
}
|
|
499
|
+
return out;
|
|
500
|
+
}, [overlays, fontsReady, imageBounds, measureWidth, bodyFontFamily, displayFontFamily]);
|
|
501
|
+
const warningCount = Object.keys(overlayWarnings).length;
|
|
502
|
+
|
|
503
|
+
const isTextPanel = cut.kind === "text";
|
|
504
|
+
const isNarrationCut = !cut.cleanImagePath;
|
|
505
|
+
|
|
506
|
+
// A text/interstitial panel (#351) is editable on a styled background canvas
|
|
507
|
+
// even when empty, so it skips the "no clean image" guard that applies to a
|
|
508
|
+
// would-be image cut with nothing placed yet.
|
|
509
|
+
if (!isTextPanel && isNarrationCut && overlays.length === 0 && !cut.narration && !cut.dialogue?.length) {
|
|
510
|
+
return (
|
|
511
|
+
<div className="h-full flex items-center justify-center text-sm text-muted">
|
|
512
|
+
No clean image — upload one first, or add overlays for a narration cut.
|
|
513
|
+
</div>
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return (
|
|
518
|
+
<div className="h-full flex flex-col">
|
|
519
|
+
{/* Toolbar */}
|
|
520
|
+
<div className="px-3 py-1.5 border-b border-border flex items-center justify-between">
|
|
521
|
+
<div className="flex items-center gap-2">
|
|
522
|
+
<span className="text-xs font-mono text-muted">Cut #{cut.id}</span>
|
|
523
|
+
<span className="text-[10px] text-muted" data-testid="overlay-count">{overlays.length} overlays</span>
|
|
524
|
+
<div className="flex items-center gap-1 ml-2">
|
|
525
|
+
<button onClick={() => addOverlay("speech")} className="px-2 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5" data-testid="add-speech">Speech</button>
|
|
526
|
+
<button onClick={() => addOverlay("narration")} className="px-2 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5" data-testid="add-narration">Narration</button>
|
|
527
|
+
<button onClick={() => addOverlay("sfx")} className="px-2 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5" data-testid="add-sfx">SFX</button>
|
|
528
|
+
</div>
|
|
529
|
+
</div>
|
|
530
|
+
<div className="flex items-center gap-2">
|
|
531
|
+
{exportError && <span className="text-[10px] text-error">{exportError}</span>}
|
|
532
|
+
<button onClick={handleExport} disabled={exporting} className="px-3 py-1 text-xs border border-accent text-accent rounded hover:bg-accent/5 disabled:opacity-50" data-testid="export-btn">
|
|
533
|
+
{exporting ? "Exporting..." : "Export"}
|
|
534
|
+
</button>
|
|
535
|
+
<button onClick={handleSave} className="px-3 py-1 text-xs bg-accent text-white rounded hover:bg-accent-dim">Save</button>
|
|
536
|
+
<button onClick={onClose} className="px-3 py-1 text-xs text-muted hover:text-foreground border border-border rounded">Close</button>
|
|
537
|
+
</div>
|
|
538
|
+
</div>
|
|
539
|
+
|
|
540
|
+
{invalidOverlayCount > 0 && !acknowledgedInvalid ? (
|
|
541
|
+
<div className="px-3 py-1 border-b border-border bg-error/10 text-[10px] text-error flex items-center gap-2 flex-wrap" data-testid="overlay-repair-note">
|
|
542
|
+
<span>
|
|
543
|
+
{invalidOverlayCount} overlay{invalidOverlayCount === 1 ? "" : "s"} from the cut plan {invalidOverlayCount === 1 ? "has" : "have"} no usable position and cannot be exported. Re-place {invalidOverlayCount === 1 ? "it" : "them"}, or
|
|
544
|
+
</span>
|
|
545
|
+
<button
|
|
546
|
+
onClick={() => setAcknowledgedInvalid(true)}
|
|
547
|
+
data-testid="discard-invalid-overlays"
|
|
548
|
+
className="px-1.5 py-0.5 border border-error/40 rounded hover:bg-error/10"
|
|
549
|
+
>
|
|
550
|
+
discard {invalidOverlayCount} unplaceable overlay{invalidOverlayCount === 1 ? "" : "s"}
|
|
551
|
+
</button>
|
|
552
|
+
</div>
|
|
553
|
+
) : invalidOverlayCount > 0 ? (
|
|
554
|
+
<div className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700" data-testid="overlay-repair-note">
|
|
555
|
+
Discarded {invalidOverlayCount} unplaceable overlay{invalidOverlayCount === 1 ? "" : "s"} — the export will not include {invalidOverlayCount === 1 ? "it" : "them"}.
|
|
556
|
+
</div>
|
|
557
|
+
) : autoPlacedOverlays ? (
|
|
558
|
+
<div className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700" data-testid="overlay-repair-note">
|
|
559
|
+
Auto-placed overlays from the cut plan — review their positions before exporting.
|
|
560
|
+
</div>
|
|
561
|
+
) : null}
|
|
562
|
+
|
|
563
|
+
{overlapPairs.length > 0 && (
|
|
564
|
+
<div
|
|
565
|
+
className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700"
|
|
566
|
+
data-testid="overlay-overlap-warning"
|
|
567
|
+
>
|
|
568
|
+
Cut #{cut.id}: {overlapPairs.length} bubble {overlapPairs.length === 1 ? "pair overlaps" : "pairs overlap"} and may be hard to read —{" "}
|
|
569
|
+
{overlapPairs
|
|
570
|
+
.map((p) => `#${p.indexA + 1} ${overlapLabel(overlays[p.indexA])} ↔ #${p.indexB + 1} ${overlapLabel(overlays[p.indexB])}`)
|
|
571
|
+
.join("; ")}
|
|
572
|
+
. Move them apart, or export as-is if the overlap is intended.
|
|
573
|
+
</div>
|
|
574
|
+
)}
|
|
575
|
+
|
|
576
|
+
{/* Per-cut lettering checklist (#336): shows how far this cut has come so
|
|
577
|
+
the writer can finish it from the editor without inspecting cuts.json. */}
|
|
578
|
+
<div
|
|
579
|
+
className="px-3 py-1 border-b border-border flex items-center gap-3 flex-wrap text-[10px] text-muted"
|
|
580
|
+
data-testid="lettering-checklist"
|
|
581
|
+
>
|
|
582
|
+
{([
|
|
583
|
+
["clean-image", "Clean image", checklist.hasCleanImage],
|
|
584
|
+
["script-text", "Script text", checklist.hasScriptText],
|
|
585
|
+
["bubbles", `Bubbles placed${checklist.bubblesPlaced ? ` (${checklist.bubblesPlaced})` : ""}`, checklist.bubblesPlaced > 0],
|
|
586
|
+
["exported", "Final exported", checklist.exported],
|
|
587
|
+
["uploaded", "Uploaded", checklist.uploaded],
|
|
588
|
+
] as [string, string, boolean][]).map(([key, label, done]) => (
|
|
589
|
+
<span
|
|
590
|
+
key={key}
|
|
591
|
+
data-testid={`lettering-check-${key}`}
|
|
592
|
+
data-done={done ? "true" : "false"}
|
|
593
|
+
className={`flex items-center gap-1 ${done ? "text-green-700" : "text-muted/70"}`}
|
|
594
|
+
>
|
|
595
|
+
<span aria-hidden>{done ? "✓" : "○"}</span>
|
|
596
|
+
{label}
|
|
597
|
+
</span>
|
|
598
|
+
))}
|
|
599
|
+
</div>
|
|
600
|
+
|
|
601
|
+
{/* Stale-export warning (#336, re1): bubbles changed since the recorded
|
|
602
|
+
export/upload, so the final image/uploaded URL are out of date. The
|
|
603
|
+
checklist already marks export/upload incomplete; this says why. */}
|
|
604
|
+
{staleExport && (
|
|
605
|
+
<div
|
|
606
|
+
className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700"
|
|
607
|
+
data-testid="lettering-stale-export-warning"
|
|
608
|
+
>
|
|
609
|
+
Bubbles changed since the last export — re-export this cut and upload the new final image before publishing.
|
|
610
|
+
</div>
|
|
611
|
+
)}
|
|
612
|
+
|
|
613
|
+
{/* Likely export problems (#336): clipped/out-of-bounds bubbles or text that
|
|
614
|
+
overflows even at the smallest font. Non-blocking guidance. */}
|
|
615
|
+
{warningCount > 0 && (
|
|
616
|
+
<div
|
|
617
|
+
className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700"
|
|
618
|
+
data-testid="lettering-export-warning"
|
|
619
|
+
>
|
|
620
|
+
{warningCount} bubble{warningCount === 1 ? "" : "s"} may not export cleanly:{" "}
|
|
621
|
+
{Object.entries(overlayWarnings)
|
|
622
|
+
.map(([id, w]) => {
|
|
623
|
+
const idx = overlays.findIndex((o) => o.id === id);
|
|
624
|
+
const problems = [w.outOfBounds ? "outside image" : null, w.overflow ? "text overflow" : null]
|
|
625
|
+
.filter(Boolean)
|
|
626
|
+
.join(", ");
|
|
627
|
+
return `#${idx + 1} ${overlapLabel(overlays[idx])} (${problems})`;
|
|
628
|
+
})
|
|
629
|
+
.join("; ")}
|
|
630
|
+
. Resize or reposition before exporting.
|
|
631
|
+
</div>
|
|
632
|
+
)}
|
|
633
|
+
|
|
634
|
+
{/* Editor surface */}
|
|
635
|
+
<div className="flex-1 min-h-0 flex">
|
|
636
|
+
<div
|
|
637
|
+
ref={containerRef}
|
|
638
|
+
className="flex-1 min-w-0 relative overflow-hidden"
|
|
639
|
+
onClick={handleBackgroundClick}
|
|
640
|
+
data-testid="editor-surface"
|
|
641
|
+
>
|
|
642
|
+
{cut.cleanImagePath && cleanAsset.error ? (
|
|
643
|
+
<div className="w-full h-full flex items-center justify-center text-muted text-xs" data-testid="clean-image-error">
|
|
644
|
+
Clean image not available
|
|
645
|
+
</div>
|
|
646
|
+
) : cut.cleanImagePath && !cleanAsset.url ? (
|
|
647
|
+
<div className="w-full h-full flex items-center justify-center text-muted text-xs" data-testid="clean-image-loading">
|
|
648
|
+
Loading clean image…
|
|
649
|
+
</div>
|
|
650
|
+
) : cut.cleanImagePath ? (
|
|
651
|
+
<img
|
|
652
|
+
ref={imgRef}
|
|
653
|
+
src={cleanAsset.url!}
|
|
654
|
+
alt={`Cut ${cut.id} clean`}
|
|
655
|
+
className="w-full h-full object-contain"
|
|
656
|
+
draggable={false}
|
|
657
|
+
onLoad={updateImageBounds}
|
|
658
|
+
/>
|
|
659
|
+
) : isTextPanel ? (
|
|
660
|
+
// Text panel: an aspect-ratio-contained canvas (imageBounds), so the
|
|
661
|
+
// lettering surface matches the exported final's shape (#351, re1).
|
|
662
|
+
imageBounds.width > 0 && (
|
|
663
|
+
<div
|
|
664
|
+
className="absolute flex items-center justify-center text-muted text-xs"
|
|
665
|
+
style={{
|
|
666
|
+
left: imageBounds.x,
|
|
667
|
+
top: imageBounds.y,
|
|
668
|
+
width: imageBounds.width,
|
|
669
|
+
height: imageBounds.height,
|
|
670
|
+
background: cut.background || "#ffffff",
|
|
671
|
+
}}
|
|
672
|
+
data-testid="text-panel-canvas"
|
|
673
|
+
>
|
|
674
|
+
Text panel
|
|
675
|
+
</div>
|
|
676
|
+
)
|
|
677
|
+
) : (
|
|
678
|
+
<div
|
|
679
|
+
className="w-full h-full bg-white flex items-center justify-center text-muted text-xs"
|
|
680
|
+
ref={(el) => {
|
|
681
|
+
if (el && imageBounds.width === 0) {
|
|
682
|
+
const rect = el.getBoundingClientRect();
|
|
683
|
+
if (rect.width > 0) {
|
|
684
|
+
setImageBounds({ x: 0, y: 0, width: rect.width, height: rect.height });
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}}
|
|
688
|
+
>
|
|
689
|
+
Narration cut
|
|
690
|
+
</div>
|
|
691
|
+
)}
|
|
692
|
+
|
|
693
|
+
{/* Speech balloons, drawn under the overlay boxes (which carry the
|
|
694
|
+
text + drag/resize handles) so the box sits on top of the fill.
|
|
695
|
+
Body + tail are ONE integrated <path> per bubble (#327), mirroring
|
|
696
|
+
the export's traceBalloonPath (#317): one fill, one stroke, so the
|
|
697
|
+
tail reads as part of the balloon outline with no internal seam.
|
|
698
|
+
Tailless speech (no tailAnchor, or a tip inside the bubble) traces
|
|
699
|
+
a plain rounded rectangle. Tail-anchor edits update the path live. */}
|
|
700
|
+
{imageBounds.width > 0 && (
|
|
701
|
+
<svg className="absolute inset-0 w-full h-full pointer-events-none" data-testid="balloon-layer">
|
|
702
|
+
{overlays.map((overlay) => {
|
|
703
|
+
if (overlay.type !== "speech") return null;
|
|
704
|
+
const ox = imageBounds.x + toPixel(overlay.x, imageBounds.width);
|
|
705
|
+
const oy = imageBounds.y + toPixel(overlay.y, imageBounds.height);
|
|
706
|
+
const ow = toPixel(overlay.width, imageBounds.width);
|
|
707
|
+
const oh = toPixel(overlay.height, imageBounds.height);
|
|
708
|
+
const radius = balloonRadiusForOverlay(overlay, ow, oh);
|
|
709
|
+
const tail = overlay.tailAnchor ? speechTailPoints(ox, oy, ow, oh, overlay.tailAnchor, radius) : null;
|
|
710
|
+
// Strong, clean near-black outline scaled to the preview size so
|
|
711
|
+
// the bubble reads as a webtoon balloon (matching the export's
|
|
712
|
+
// proportional stroke), not a faint UI box (#363).
|
|
713
|
+
const strokeW = Math.max(1.5, imageBounds.height * 0.004);
|
|
714
|
+
const selected = overlay.id === selectedId;
|
|
715
|
+
return (
|
|
716
|
+
<path
|
|
717
|
+
key={overlay.id}
|
|
718
|
+
data-testid={`balloon-${overlay.id}`}
|
|
719
|
+
d={balloonPathD(ox, oy, ow, oh, tail, radius)}
|
|
720
|
+
className={`fill-white/95 ${selected ? "stroke-accent" : "stroke-[#1a1a1a]"}`}
|
|
721
|
+
strokeWidth={selected ? strokeW + 0.5 : strokeW}
|
|
722
|
+
strokeLinejoin="round"
|
|
723
|
+
/>
|
|
724
|
+
);
|
|
725
|
+
})}
|
|
726
|
+
</svg>
|
|
727
|
+
)}
|
|
728
|
+
|
|
729
|
+
{imageBounds.width > 0 && overlays.map((overlay) => {
|
|
730
|
+
const left = imageBounds.x + toPixel(overlay.x, imageBounds.width);
|
|
731
|
+
const top = imageBounds.y + toPixel(overlay.y, imageBounds.height);
|
|
732
|
+
const width = toPixel(overlay.width, imageBounds.width);
|
|
733
|
+
const height = toPixel(overlay.height, imageBounds.height);
|
|
734
|
+
const isSelected = overlay.id === selectedId;
|
|
735
|
+
// Speech bubbles draw no body border here — the integrated balloon
|
|
736
|
+
// <path> in the layer below is their outline, so a box border would
|
|
737
|
+
// re-introduce the body/tail seam (#327). Their selection cue is the
|
|
738
|
+
// path's accent stroke (plus the resize handle). Narration/SFX keep
|
|
739
|
+
// their bordered box + selection ring as before.
|
|
740
|
+
const isSpeech = overlay.type === "speech";
|
|
741
|
+
// Narration reads as an intentional parchment caption card (rounded,
|
|
742
|
+
// filled), mirroring the export, instead of an empty bordered box (#363).
|
|
743
|
+
const isNarration = overlay.type === "narration";
|
|
744
|
+
const warned = !!overlayWarnings[overlay.id];
|
|
745
|
+
|
|
746
|
+
return (
|
|
747
|
+
<div
|
|
748
|
+
key={overlay.id}
|
|
749
|
+
data-testid={`overlay-${overlay.id}`}
|
|
750
|
+
data-warning={warned ? "true" : "false"}
|
|
751
|
+
onClick={(e) => handleOverlayClick(e, overlay.id)}
|
|
752
|
+
onMouseDown={(e) => handleMouseDown(e, overlay.id, "move")}
|
|
753
|
+
className={`absolute rounded cursor-move select-none ${
|
|
754
|
+
isSpeech ? "" : `border-2 ${TYPE_BORDER[overlay.type]}`
|
|
755
|
+
} ${isNarration ? "bg-[#f4efe6]/85 rounded-md" : ""} ${
|
|
756
|
+
isSelected && !isSpeech ? "ring-2 ring-accent" : ""
|
|
757
|
+
} ${warned ? "ring-2 ring-amber-500" : ""}`}
|
|
758
|
+
style={{ left, top, width, height }}
|
|
759
|
+
>
|
|
760
|
+
{(() => {
|
|
761
|
+
const fontFamily = overlay.type === "sfx" ? displayFontFamily : bodyFontFamily;
|
|
762
|
+
if (!overlay.text) {
|
|
763
|
+
return (
|
|
764
|
+
<span className="text-[9px] px-1 text-muted truncate block pointer-events-none" style={{ fontFamily }}>
|
|
765
|
+
{TYPE_LABEL[overlay.type]}
|
|
766
|
+
</span>
|
|
767
|
+
);
|
|
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
|
+
return (
|
|
776
|
+
<div
|
|
777
|
+
className="absolute inset-0 flex items-center justify-center px-1 overflow-hidden pointer-events-none text-center break-words"
|
|
778
|
+
style={{
|
|
779
|
+
fontFamily,
|
|
780
|
+
fontSize: Math.max(9, Math.min(height * 0.05, 16)),
|
|
781
|
+
fontWeight: overlay.textStyle?.fontWeight ?? 400,
|
|
782
|
+
}}
|
|
783
|
+
data-testid={`overlay-text-${overlay.id}`}
|
|
784
|
+
data-fonts-ready="false"
|
|
785
|
+
>
|
|
786
|
+
{hasSpeaker ? `${overlay.speaker}: ${overlay.text}` : overlay.text}
|
|
787
|
+
</div>
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
const layout = layoutBubbleText(
|
|
791
|
+
measureWidth(fontFamily),
|
|
792
|
+
overlay.text,
|
|
793
|
+
width,
|
|
794
|
+
height,
|
|
795
|
+
bubbleLayoutOptionsForOverlay(overlay, imageBounds.height, width, height),
|
|
796
|
+
);
|
|
797
|
+
return (
|
|
798
|
+
<div
|
|
799
|
+
className="absolute inset-0 flex flex-col items-center justify-center px-1 overflow-hidden pointer-events-none text-center"
|
|
800
|
+
style={{ fontFamily }}
|
|
801
|
+
data-testid={`overlay-text-${overlay.id}`}
|
|
802
|
+
data-fonts-ready="true"
|
|
803
|
+
>
|
|
804
|
+
{hasSpeaker && (
|
|
805
|
+
<span className="font-bold text-[#3a3a3a] block" style={{ fontSize: layout.speakerFontSize, lineHeight: 1.2 }}>
|
|
806
|
+
{overlay.speaker}
|
|
807
|
+
</span>
|
|
808
|
+
)}
|
|
809
|
+
<span
|
|
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
|
+
})}
|
|
830
|
+
</div>
|
|
831
|
+
|
|
832
|
+
{/* Inspector panel */}
|
|
833
|
+
<div className="w-52 border-l border-border p-3 overflow-y-auto flex-shrink-0">
|
|
834
|
+
{/* Insert-from-script (#336): drop the cut's planned dialogue/narration/
|
|
835
|
+
SFX straight into a prefilled overlay — no copy/paste out of JSON. */}
|
|
836
|
+
{scriptLines.length > 0 && (
|
|
837
|
+
<div className="mb-3 space-y-1.5" data-testid="script-insert-panel">
|
|
838
|
+
<span className="text-[10px] font-medium text-muted">From script</span>
|
|
839
|
+
<div className="flex flex-col gap-1">
|
|
840
|
+
{scriptLines.map((line) => (
|
|
841
|
+
<button
|
|
842
|
+
key={line.key}
|
|
843
|
+
onClick={() => addScriptLine(line)}
|
|
844
|
+
data-testid={`script-insert-${line.key}`}
|
|
845
|
+
title={`Add ${line.type} overlay with this text`}
|
|
846
|
+
className="text-left px-2 py-1 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
|
|
847
|
+
>
|
|
848
|
+
<span className="font-medium text-accent">+ {TYPE_LABEL[line.type]}</span>{" "}
|
|
849
|
+
<span className="text-muted">
|
|
850
|
+
{line.speaker ? `${line.speaker}: ` : ""}
|
|
851
|
+
{line.text.length > 32 ? `${line.text.slice(0, 32)}…` : line.text}
|
|
852
|
+
</span>
|
|
853
|
+
</button>
|
|
854
|
+
))}
|
|
855
|
+
</div>
|
|
856
|
+
</div>
|
|
857
|
+
)}
|
|
858
|
+
{selectedOverlay ? (
|
|
859
|
+
<div className="space-y-3">
|
|
860
|
+
<p className="text-xs font-medium text-foreground">{TYPE_LABEL[selectedOverlay.type]}</p>
|
|
861
|
+
|
|
862
|
+
{selectedOverlay.speaker !== undefined && (
|
|
863
|
+
<label className="block space-y-1">
|
|
864
|
+
<span className="text-[10px] font-medium text-muted">Speaker</span>
|
|
865
|
+
<input
|
|
866
|
+
value={selectedOverlay.speaker || ""}
|
|
867
|
+
onChange={(e) => updateOverlay(selectedOverlay.id, { speaker: e.target.value })}
|
|
868
|
+
className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
869
|
+
placeholder="Character name"
|
|
870
|
+
data-testid="inspector-speaker"
|
|
871
|
+
/>
|
|
872
|
+
</label>
|
|
873
|
+
)}
|
|
874
|
+
|
|
875
|
+
<label className="block space-y-1">
|
|
876
|
+
<span className="text-[10px] font-medium text-muted">Text</span>
|
|
877
|
+
<textarea
|
|
878
|
+
value={selectedOverlay.text}
|
|
879
|
+
onChange={(e) => updateOverlay(selectedOverlay.id, { text: e.target.value })}
|
|
880
|
+
rows={3}
|
|
881
|
+
className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent resize-none focus:border-accent focus:outline-none"
|
|
882
|
+
placeholder="Overlay text"
|
|
883
|
+
data-testid="inspector-text"
|
|
884
|
+
/>
|
|
885
|
+
</label>
|
|
886
|
+
|
|
887
|
+
{/* One-click resize so a long line that overflows can be fitted
|
|
888
|
+
without hand-dragging the box (#452). */}
|
|
889
|
+
<button
|
|
890
|
+
onClick={() => updateOverlay(selectedOverlay.id, fittedSize(selectedOverlay))}
|
|
891
|
+
data-testid="inspector-fit-text"
|
|
892
|
+
className="w-full px-2 py-1 text-[11px] border border-border rounded hover:border-accent hover:text-accent"
|
|
893
|
+
title="Resize this overlay so its text fits without overflowing"
|
|
894
|
+
>
|
|
895
|
+
Fit box to text
|
|
896
|
+
</button>
|
|
897
|
+
|
|
898
|
+
<div className="space-y-1.5 rounded border border-border/70 p-2" data-testid="inspector-typography">
|
|
899
|
+
<div className="flex items-center justify-between gap-2">
|
|
900
|
+
<span className="text-[10px] font-medium text-muted">Typography</span>
|
|
901
|
+
{selectedOverlay.textStyle?.mode === "manual" ? (
|
|
902
|
+
<button
|
|
903
|
+
type="button"
|
|
904
|
+
onClick={() => updateOverlay(selectedOverlay.id, { textStyle: undefined })}
|
|
905
|
+
className="px-1.5 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
|
|
906
|
+
data-testid="inspector-text-auto"
|
|
907
|
+
>
|
|
908
|
+
Auto-fit
|
|
909
|
+
</button>
|
|
910
|
+
) : (
|
|
911
|
+
<button
|
|
912
|
+
type="button"
|
|
913
|
+
onClick={() => enableManualTypography(selectedOverlay)}
|
|
914
|
+
className="px-1.5 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
|
|
915
|
+
data-testid="inspector-text-manual"
|
|
916
|
+
>
|
|
917
|
+
Manual
|
|
918
|
+
</button>
|
|
919
|
+
)}
|
|
920
|
+
</div>
|
|
921
|
+
{selectedOverlay.textStyle?.mode === "manual" ? (
|
|
922
|
+
<div className="space-y-1.5">
|
|
923
|
+
<label className="block space-y-1">
|
|
924
|
+
<span className="text-[10px] text-muted">Font size (% panel height)</span>
|
|
925
|
+
<input
|
|
926
|
+
type="number"
|
|
927
|
+
step="0.1"
|
|
928
|
+
min="1.5"
|
|
929
|
+
max="12"
|
|
930
|
+
value={(((selectedOverlay.textStyle.fontScale ?? 0.032) * 100)).toFixed(1)}
|
|
931
|
+
onChange={(e) => updateOverlay(selectedOverlay.id, {
|
|
932
|
+
textStyle: {
|
|
933
|
+
...selectedOverlay.textStyle,
|
|
934
|
+
mode: "manual",
|
|
935
|
+
fontScale: Math.max(0.015, Math.min(0.12, (parseFloat(e.target.value) || 3.2) / 100)),
|
|
936
|
+
},
|
|
937
|
+
})}
|
|
938
|
+
className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
939
|
+
data-testid="inspector-font-scale"
|
|
940
|
+
/>
|
|
941
|
+
</label>
|
|
942
|
+
<label className="block space-y-1">
|
|
943
|
+
<span className="text-[10px] text-muted">Weight</span>
|
|
944
|
+
<select
|
|
945
|
+
value={String(selectedOverlay.textStyle.fontWeight ?? 400)}
|
|
946
|
+
onChange={(e) => updateOverlay(selectedOverlay.id, {
|
|
947
|
+
textStyle: {
|
|
948
|
+
...selectedOverlay.textStyle,
|
|
949
|
+
mode: "manual",
|
|
950
|
+
fontWeight: e.target.value === "700" ? 700 : 400,
|
|
951
|
+
},
|
|
952
|
+
})}
|
|
953
|
+
className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
954
|
+
data-testid="inspector-font-weight"
|
|
955
|
+
>
|
|
956
|
+
<option value="400">Regular</option>
|
|
957
|
+
<option value="700">Bold</option>
|
|
958
|
+
</select>
|
|
959
|
+
</label>
|
|
960
|
+
<label className="block space-y-1">
|
|
961
|
+
<span className="text-[10px] text-muted">Line height</span>
|
|
962
|
+
<input
|
|
963
|
+
type="number"
|
|
964
|
+
step="0.05"
|
|
965
|
+
min="0.9"
|
|
966
|
+
max="2"
|
|
967
|
+
value={(selectedOverlay.textStyle.lineHeightFactor ?? 1.2).toFixed(2)}
|
|
968
|
+
onChange={(e) => updateOverlay(selectedOverlay.id, {
|
|
969
|
+
textStyle: {
|
|
970
|
+
...selectedOverlay.textStyle,
|
|
971
|
+
mode: "manual",
|
|
972
|
+
lineHeightFactor: Math.max(0.9, Math.min(2, parseFloat(e.target.value) || 1.2)),
|
|
973
|
+
},
|
|
974
|
+
})}
|
|
975
|
+
className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
976
|
+
data-testid="inspector-line-height"
|
|
977
|
+
/>
|
|
978
|
+
</label>
|
|
979
|
+
{selectedOverlay.type !== "sfx" && (
|
|
980
|
+
<label className="block space-y-1">
|
|
981
|
+
<span className="text-[10px] text-muted">Speaker scale</span>
|
|
982
|
+
<input
|
|
983
|
+
type="number"
|
|
984
|
+
step="0.05"
|
|
985
|
+
min="0.5"
|
|
986
|
+
max="1.5"
|
|
987
|
+
value={(selectedOverlay.textStyle.speakerScale ?? 0.8).toFixed(2)}
|
|
988
|
+
onChange={(e) => updateOverlay(selectedOverlay.id, {
|
|
989
|
+
textStyle: {
|
|
990
|
+
...selectedOverlay.textStyle,
|
|
991
|
+
mode: "manual",
|
|
992
|
+
speakerScale: Math.max(0.5, Math.min(1.5, parseFloat(e.target.value) || 0.8)),
|
|
993
|
+
},
|
|
994
|
+
})}
|
|
995
|
+
className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
996
|
+
data-testid="inspector-speaker-scale"
|
|
997
|
+
/>
|
|
998
|
+
</label>
|
|
999
|
+
)}
|
|
1000
|
+
</div>
|
|
1001
|
+
) : (
|
|
1002
|
+
<p className="text-[10px] text-muted">Auto-fit stays on by default and resizes text to the box.</p>
|
|
1003
|
+
)}
|
|
1004
|
+
</div>
|
|
1005
|
+
|
|
1006
|
+
{selectedOverlay.type === "speech" && (() => {
|
|
1007
|
+
const tail = selectedOverlay.tailAnchor || { x: 0.5, y: 1.2 };
|
|
1008
|
+
return (
|
|
1009
|
+
<div className="space-y-1">
|
|
1010
|
+
<span className="text-[10px] font-medium text-muted">Tail anchor</span>
|
|
1011
|
+
<div className="flex flex-wrap gap-1" data-testid="inspector-tail-presets">
|
|
1012
|
+
{TAIL_PRESETS.map((preset) => (
|
|
1013
|
+
<button
|
|
1014
|
+
key={preset.key}
|
|
1015
|
+
type="button"
|
|
1016
|
+
onClick={() => updateOverlay(selectedOverlay.id, { tailAnchor: preset.anchor })}
|
|
1017
|
+
className="px-1.5 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
|
|
1018
|
+
data-testid={`inspector-tail-${preset.key}`}
|
|
1019
|
+
>
|
|
1020
|
+
{preset.label}
|
|
1021
|
+
</button>
|
|
1022
|
+
))}
|
|
1023
|
+
</div>
|
|
1024
|
+
<div className="flex gap-2">
|
|
1025
|
+
<label className="flex items-center gap-1 text-[10px] font-mono text-muted">
|
|
1026
|
+
x
|
|
1027
|
+
<input
|
|
1028
|
+
type="number"
|
|
1029
|
+
step="0.1"
|
|
1030
|
+
value={tail.x}
|
|
1031
|
+
onChange={(e) => updateOverlay(selectedOverlay.id, { tailAnchor: { ...tail, x: parseFloat(e.target.value) || 0 } })}
|
|
1032
|
+
className="w-14 px-1 py-0.5 text-[10px] border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
1033
|
+
data-testid="inspector-tail-x"
|
|
1034
|
+
/>
|
|
1035
|
+
</label>
|
|
1036
|
+
<label className="flex items-center gap-1 text-[10px] font-mono text-muted">
|
|
1037
|
+
y
|
|
1038
|
+
<input
|
|
1039
|
+
type="number"
|
|
1040
|
+
step="0.1"
|
|
1041
|
+
value={tail.y}
|
|
1042
|
+
onChange={(e) => updateOverlay(selectedOverlay.id, { tailAnchor: { ...tail, y: parseFloat(e.target.value) || 0 } })}
|
|
1043
|
+
className="w-14 px-1 py-0.5 text-[10px] border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
1044
|
+
data-testid="inspector-tail-y"
|
|
1045
|
+
/>
|
|
1046
|
+
</label>
|
|
1047
|
+
</div>
|
|
1048
|
+
</div>
|
|
1049
|
+
);
|
|
1050
|
+
})()}
|
|
1051
|
+
|
|
1052
|
+
{selectedOverlay.type !== "sfx" && (
|
|
1053
|
+
<div className="space-y-1.5 rounded border border-border/70 p-2" data-testid="inspector-bubble-style">
|
|
1054
|
+
<span className="text-[10px] font-medium text-muted">Bubble controls</span>
|
|
1055
|
+
<label className="block space-y-1">
|
|
1056
|
+
<span className="text-[10px] text-muted">Padding X (% width)</span>
|
|
1057
|
+
<input
|
|
1058
|
+
type="number"
|
|
1059
|
+
step="1"
|
|
1060
|
+
min="0"
|
|
1061
|
+
max="25"
|
|
1062
|
+
value={(((selectedOverlay.bubbleStyle?.paddingX ?? 0.06) * 100)).toFixed(0)}
|
|
1063
|
+
onChange={(e) => updateOverlay(selectedOverlay.id, {
|
|
1064
|
+
bubbleStyle: {
|
|
1065
|
+
...selectedOverlay.bubbleStyle,
|
|
1066
|
+
paddingX: Math.max(0, Math.min(0.25, (parseFloat(e.target.value) || 6) / 100)),
|
|
1067
|
+
},
|
|
1068
|
+
})}
|
|
1069
|
+
className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
1070
|
+
data-testid="inspector-padding-x"
|
|
1071
|
+
/>
|
|
1072
|
+
</label>
|
|
1073
|
+
<label className="block space-y-1">
|
|
1074
|
+
<span className="text-[10px] text-muted">Padding Y (% height)</span>
|
|
1075
|
+
<input
|
|
1076
|
+
type="number"
|
|
1077
|
+
step="1"
|
|
1078
|
+
min="0"
|
|
1079
|
+
max="25"
|
|
1080
|
+
value={(((selectedOverlay.bubbleStyle?.paddingY ?? 0.08) * 100)).toFixed(0)}
|
|
1081
|
+
onChange={(e) => updateOverlay(selectedOverlay.id, {
|
|
1082
|
+
bubbleStyle: {
|
|
1083
|
+
...selectedOverlay.bubbleStyle,
|
|
1084
|
+
paddingY: Math.max(0, Math.min(0.25, (parseFloat(e.target.value) || 8) / 100)),
|
|
1085
|
+
},
|
|
1086
|
+
})}
|
|
1087
|
+
className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
1088
|
+
data-testid="inspector-padding-y"
|
|
1089
|
+
/>
|
|
1090
|
+
</label>
|
|
1091
|
+
<label className="block space-y-1">
|
|
1092
|
+
<span className="text-[10px] text-muted">Corner roundness (% short side)</span>
|
|
1093
|
+
<input
|
|
1094
|
+
type="number"
|
|
1095
|
+
step="1"
|
|
1096
|
+
min="0"
|
|
1097
|
+
max="49"
|
|
1098
|
+
value={(((selectedOverlay.bubbleStyle?.cornerRadius ?? 0.4) * 100)).toFixed(0)}
|
|
1099
|
+
onChange={(e) => updateOverlay(selectedOverlay.id, {
|
|
1100
|
+
bubbleStyle: {
|
|
1101
|
+
...selectedOverlay.bubbleStyle,
|
|
1102
|
+
cornerRadius: Math.max(0, Math.min(0.49, (parseFloat(e.target.value) || 40) / 100)),
|
|
1103
|
+
},
|
|
1104
|
+
})}
|
|
1105
|
+
className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
|
|
1106
|
+
data-testid="inspector-corner-radius"
|
|
1107
|
+
/>
|
|
1108
|
+
</label>
|
|
1109
|
+
</div>
|
|
1110
|
+
)}
|
|
1111
|
+
|
|
1112
|
+
<div className="text-[10px] text-muted" data-testid="inspector-font">
|
|
1113
|
+
Font: {selectedOverlay.type === "sfx" ? displayFont.family : bodyFont.family}
|
|
1114
|
+
</div>
|
|
1115
|
+
|
|
1116
|
+
<div className="text-[10px] font-mono text-muted space-y-0.5">
|
|
1117
|
+
<p>x: {selectedOverlay.x.toFixed(3)}, y: {selectedOverlay.y.toFixed(3)}</p>
|
|
1118
|
+
<p>w: {selectedOverlay.width.toFixed(3)}, h: {selectedOverlay.height.toFixed(3)}</p>
|
|
1119
|
+
</div>
|
|
1120
|
+
|
|
1121
|
+
<button
|
|
1122
|
+
onClick={() => {
|
|
1123
|
+
if (confirmDelete) deleteOverlay(selectedOverlay.id);
|
|
1124
|
+
else setConfirmDelete(true);
|
|
1125
|
+
}}
|
|
1126
|
+
className="w-full px-2 py-1 text-xs text-error border border-error/30 rounded hover:bg-error/5"
|
|
1127
|
+
data-testid="delete-overlay"
|
|
1128
|
+
>
|
|
1129
|
+
{confirmDelete ? "Click again to delete" : "Delete"}
|
|
1130
|
+
</button>
|
|
1131
|
+
</div>
|
|
1132
|
+
) : (
|
|
1133
|
+
<p className="text-xs text-muted" data-testid="inspector-empty">
|
|
1134
|
+
Select an overlay to inspect.
|
|
1135
|
+
</p>
|
|
1136
|
+
)}
|
|
1137
|
+
</div>
|
|
1138
|
+
</div>
|
|
1139
|
+
</div>
|
|
1140
|
+
);
|
|
1141
|
+
}
|