plotlink-ows 1.2.96 → 1.2.98

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.
@@ -90,6 +90,11 @@ interface LetteringEditorProps {
90
90
  workspaceVisible?: boolean;
91
91
  /** Toggle the surrounding app work area while staying in the editor. */
92
92
  onToggleWorkspaceVisible?: () => void;
93
+ /** Move to adjacent cuts while staying in the focused editor. */
94
+ onPreviousCut?: () => void;
95
+ onNextCut?: () => void;
96
+ hasPreviousCut?: boolean;
97
+ hasNextCut?: boolean;
93
98
  }
94
99
 
95
100
  const TYPE_LABEL: Record<OverlayType, string> = {
@@ -138,6 +143,10 @@ export function LetteringEditor({
138
143
  returnOnSave = false,
139
144
  workspaceVisible = false,
140
145
  onToggleWorkspaceVisible,
146
+ onPreviousCut,
147
+ onNextCut,
148
+ hasPreviousCut = false,
149
+ hasNextCut = false,
141
150
  }: LetteringEditorProps) {
142
151
  const bodyFont = getDefaultFont(language);
143
152
  const displayFont = getDisplayFont();
@@ -224,6 +233,7 @@ export function LetteringEditor({
224
233
  const [exporting, setExporting] = useState(false);
225
234
  const [exportError, setExportError] = useState<string | null>(null);
226
235
  const [saveError, setSaveError] = useState<string | null>(null);
236
+ const [showHelp, setShowHelp] = useState(false);
227
237
  const [imageBounds, setImageBounds] = useState({
228
238
  x: 0,
229
239
  y: 0,
@@ -243,6 +253,17 @@ export function LetteringEditor({
243
253
  origH: number;
244
254
  } | null>(null);
245
255
 
256
+ useEffect(() => {
257
+ const nextOverlays = overlayNormalization.overlays as Overlay[];
258
+ setOverlays(nextOverlays);
259
+ setSelectedId(null);
260
+ setAcknowledgedInvalid(false);
261
+ setConfirmDelete(false);
262
+ setExportError(null);
263
+ setSaveError(null);
264
+ setExportBaselineSig(overlaysSignature(nextOverlays));
265
+ }, [cut.id, overlayNormalization]);
266
+
246
267
  const updateImageBounds = useCallback(() => {
247
268
  const container = containerRef.current;
248
269
  if (!container) return;
@@ -681,6 +702,23 @@ export function LetteringEditor({
681
702
  displayFontFamily,
682
703
  ]);
683
704
  const warningCount = Object.keys(overlayWarnings).length;
705
+ const checklistChips: Array<{
706
+ key: string;
707
+ label: string;
708
+ done: boolean;
709
+ }> = [
710
+ { key: "clean-image", label: "Clean", done: checklist.hasCleanImage },
711
+ { key: "script-text", label: "Script", done: checklist.hasScriptText },
712
+ {
713
+ key: "bubbles",
714
+ label: checklist.bubblesPlaced
715
+ ? `Bubbles ${checklist.bubblesPlaced}`
716
+ : "Bubbles",
717
+ done: checklist.bubblesPlaced > 0,
718
+ },
719
+ { key: "exported", label: "Exported", done: checklist.exported },
720
+ { key: "uploaded", label: "Uploaded", done: checklist.uploaded },
721
+ ];
684
722
 
685
723
  const isTextPanel = cut.kind === "text";
686
724
  const isNarrationCut = !cut.cleanImagePath;
@@ -708,74 +746,114 @@ export function LetteringEditor({
708
746
  data-testid="focused-lettering-editor"
709
747
  >
710
748
  {/* Toolbar */}
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>
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">
749
+ <div
750
+ className="px-3 py-1.5 border-b border-border bg-surface/55 grid grid-cols-[minmax(14rem,1fr)_auto_minmax(12rem,1fr)] items-center gap-2"
751
+ data-testid="lettering-toolbar"
752
+ >
753
+ <div className="flex items-center gap-1.5 min-w-0 overflow-hidden">
754
+ <button
755
+ onClick={onClose}
756
+ className="px-2.5 py-1 text-[11px] border border-border rounded text-muted hover:text-foreground"
757
+ data-testid="return-to-cut-review-btn"
758
+ >
759
+ Cut review
760
+ </button>
761
+ <span className="rounded-full border border-accent/30 bg-accent/10 px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.14em] text-accent whitespace-nowrap">
762
+ Lettering
763
+ </span>
764
+ <span className="text-[11px] font-mono text-muted">
765
+ {targetLabel ?? `Cut #${cut.id}`}
766
+ </span>
767
+ <span
768
+ className="rounded-full border border-border bg-background px-2 py-0.5 text-[10px] text-muted"
769
+ data-testid="overlay-count"
770
+ >
726
771
  {overlays.length} overlays
727
772
  </span>
773
+ {checklistChips.map((chip) => (
774
+ <span
775
+ key={chip.key}
776
+ data-testid={`lettering-check-${chip.key}`}
777
+ data-done={chip.done ? "true" : "false"}
778
+ className={`rounded-full border px-2 py-0.5 text-[10px] ${
779
+ chip.key === "exported" || chip.key === "uploaded"
780
+ ? "hidden xl:inline-flex"
781
+ : ""
782
+ } ${
783
+ chip.done
784
+ ? "border-green-700/30 bg-green-700/10 text-green-700"
785
+ : "border-border bg-background text-muted"
786
+ }`}
787
+ >
788
+ {chip.done ? "✓ " : "○ "}
789
+ {chip.label}
790
+ </span>
791
+ ))}
792
+ <span className="sr-only">Focused lettering editor</span>
793
+ {cut.aiDraft?.status === "generated" && (
794
+ <span
795
+ className="rounded-full border border-accent/30 bg-accent/10 px-2 py-0.5 text-[10px] text-accent"
796
+ data-testid="ai-draft-current-target"
797
+ >
798
+ AI draft ready
799
+ </span>
800
+ )}
728
801
  </div>
729
- <div className="flex items-center gap-2 flex-wrap justify-end">
802
+ <div className="flex items-center justify-center gap-1 rounded border border-border bg-background px-1 py-0.5">
730
803
  <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"
804
+ onClick={() => addOverlay("speech")}
805
+ className="px-2.5 py-1 text-[11px] rounded hover:bg-accent/10 hover:text-accent"
806
+ data-testid="add-speech"
807
+ >
808
+ Speech
809
+ </button>
810
+ <button
811
+ onClick={() => addOverlay("narration")}
812
+ className="px-2.5 py-1 text-[11px] rounded hover:bg-accent/10 hover:text-accent"
813
+ data-testid="add-narration"
814
+ >
815
+ Narration
816
+ </button>
817
+ <button
818
+ onClick={() => addOverlay("sfx")}
819
+ className="px-2.5 py-1 text-[11px] rounded hover:bg-accent/10 hover:text-accent"
820
+ data-testid="add-sfx"
734
821
  >
735
- Back to cut review
822
+ SFX
736
823
  </button>
824
+ </div>
825
+ <div className="flex items-center gap-1.5 justify-end min-w-0">
737
826
  {onToggleWorkspaceVisible && (
738
827
  <button
739
828
  onClick={onToggleWorkspaceVisible}
740
- className="px-3 py-1 text-xs border border-border rounded text-muted hover:border-accent hover:text-accent"
829
+ className="px-2.5 py-1 text-[11px] border border-border rounded text-muted hover:border-accent hover:text-accent"
741
830
  data-testid="toggle-work-area-btn"
742
831
  >
743
832
  {workspaceVisible ? "Hide work area" : "Show work area"}
744
833
  </button>
745
834
  )}
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>
835
+ <button
836
+ type="button"
837
+ onClick={() => setShowHelp((prev) => !prev)}
838
+ className="px-2.5 py-1 text-[11px] border border-border rounded text-muted hover:border-accent hover:text-accent"
839
+ data-testid="lettering-help-toggle"
840
+ >
841
+ {showHelp ? "Hide help" : "Help"}
842
+ </button>
769
843
  {exportError && (
770
- <span className="text-[10px] text-error">{exportError}</span>
844
+ <span className="text-[10px] text-error max-w-[18rem]">
845
+ {exportError}
846
+ </span>
771
847
  )}
772
848
  {saveError && (
773
- <span className="text-[10px] text-error">{saveError}</span>
849
+ <span className="text-[10px] text-error max-w-[18rem]">
850
+ {saveError}
851
+ </span>
774
852
  )}
775
853
  <button
776
854
  onClick={handleExport}
777
855
  disabled={exporting}
778
- className="px-3 py-1 text-xs border border-accent text-accent rounded hover:bg-accent/5 disabled:opacity-50"
856
+ className="px-2.5 py-1 text-[11px] border border-accent text-accent rounded hover:bg-accent/5 disabled:opacity-50"
779
857
  data-testid="export-btn"
780
858
  >
781
859
  {exporting ? "Exporting..." : "Export"}
@@ -784,14 +862,14 @@ export function LetteringEditor({
784
862
  onClick={() => {
785
863
  void handleSave();
786
864
  }}
787
- className="px-3 py-1 text-xs bg-accent text-white rounded hover:bg-accent-dim"
865
+ className="px-2.5 py-1 text-[11px] bg-accent text-white rounded hover:bg-accent-dim"
788
866
  data-testid="save-lettering-btn"
789
867
  >
790
868
  Save
791
869
  </button>
792
870
  <button
793
871
  onClick={onClose}
794
- className="px-3 py-1 text-xs text-muted hover:text-foreground border border-border rounded"
872
+ className="px-2.5 py-1 text-[11px] text-muted hover:text-foreground border border-border rounded"
795
873
  data-testid="cancel-lettering-btn"
796
874
  >
797
875
  Cancel
@@ -799,6 +877,17 @@ export function LetteringEditor({
799
877
  </div>
800
878
  </div>
801
879
 
880
+ {showHelp && (
881
+ <div
882
+ className="px-3 py-1.5 border-b border-border bg-background text-[10px] text-muted"
883
+ data-testid="lettering-help-panel"
884
+ >
885
+ Add or select a bubble, edit it in the inspector, then Save to return
886
+ to cut review. Use Export after the overlay layout is ready. Text cards
887
+ use narration overlays on the canvas.
888
+ </div>
889
+ )}
890
+
802
891
  {invalidOverlayCount > 0 && !acknowledgedInvalid ? (
803
892
  <div
804
893
  className="px-3 py-1 border-b border-border bg-error/10 text-[10px] text-error flex items-center gap-2 flex-wrap"
@@ -856,40 +945,9 @@ export function LetteringEditor({
856
945
  </div>
857
946
  )}
858
947
 
859
- {/* Per-cut lettering checklist (#336): shows how far this cut has come so
860
- the writer can finish it from the editor without inspecting cuts.json. */}
861
- <div
862
- className="px-3 py-1 border-b border-border flex items-center gap-3 flex-wrap text-[10px] text-muted"
863
- data-testid="lettering-checklist"
864
- >
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]) => (
878
- <span
879
- key={key}
880
- data-testid={`lettering-check-${key}`}
881
- data-done={done ? "true" : "false"}
882
- className={`flex items-center gap-1 ${done ? "text-green-700" : "text-muted/70"}`}
883
- >
884
- <span aria-hidden>{done ? "✓" : "○"}</span>
885
- {label}
886
- </span>
887
- ))}
888
- </div>
889
-
890
948
  {/* Stale-export warning (#336, re1): bubbles changed since the recorded
891
949
  export/upload, so the final image/uploaded URL are out of date. The
892
- checklist already marks export/upload incomplete; this says why. */}
950
+ compact toolbar chips already mark export/upload incomplete; this says why. */}
893
951
  {staleExport && (
894
952
  <div
895
953
  className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700"
@@ -995,6 +1053,33 @@ export function LetteringEditor({
995
1053
  </div>
996
1054
  )}
997
1055
 
1056
+ {(onPreviousCut || onNextCut) && (
1057
+ <>
1058
+ <button
1059
+ type="button"
1060
+ onClick={onPreviousCut}
1061
+ disabled={!hasPreviousCut}
1062
+ className="absolute left-3 top-1/2 z-20 flex h-12 w-8 -translate-y-1/2 items-center justify-center rounded border border-border bg-background/85 text-2xl text-accent shadow-sm hover:bg-background disabled:opacity-30 disabled:hover:bg-background/85"
1063
+ data-testid="previous-cut-btn"
1064
+ aria-label="Previous cut"
1065
+ >
1066
+ <span aria-hidden>‹</span>
1067
+ <span className="sr-only">Previous cut</span>
1068
+ </button>
1069
+ <button
1070
+ type="button"
1071
+ onClick={onNextCut}
1072
+ disabled={!hasNextCut}
1073
+ className="absolute right-3 top-1/2 z-20 flex h-12 w-8 -translate-y-1/2 items-center justify-center rounded border border-border bg-background/85 text-2xl text-accent shadow-sm hover:bg-background disabled:opacity-30 disabled:hover:bg-background/85"
1074
+ data-testid="next-cut-btn"
1075
+ aria-label="Next cut"
1076
+ >
1077
+ <span aria-hidden>›</span>
1078
+ <span className="sr-only">Next cut</span>
1079
+ </button>
1080
+ </>
1081
+ )}
1082
+
998
1083
  {/* Speech balloons, drawn under the overlay boxes (which carry the
999
1084
  text + drag/resize handles) so the box sits on top of the fill.
1000
1085
  Body + tail are ONE integrated <path> per bubble (#327), mirroring
@@ -1175,55 +1260,16 @@ export function LetteringEditor({
1175
1260
 
1176
1261
  {/* Inspector panel */}
1177
1262
  <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
- )}
1192
- {/* Insert-from-script (#336): drop the cut's planned dialogue/narration/
1193
- SFX straight into a prefilled overlay — no copy/paste out of JSON. */}
1194
- {scriptLines.length > 0 && (
1195
- <div className="mb-3 space-y-1.5" data-testid="script-insert-panel">
1196
- <span className="text-[10px] font-medium text-muted">
1197
- From script
1198
- </span>
1199
- <div className="flex flex-col gap-1">
1200
- {scriptLines.map((line) => (
1201
- <button
1202
- key={line.key}
1203
- onClick={() => addScriptLine(line)}
1204
- data-testid={`script-insert-${line.key}`}
1205
- title={`Add ${line.type} overlay with this text`}
1206
- className="text-left px-2 py-1 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
1207
- >
1208
- <span className="font-medium text-accent">
1209
- + {TYPE_LABEL[line.type]}
1210
- </span>{" "}
1211
- <span className="text-muted">
1212
- {line.speaker ? `${line.speaker}: ` : ""}
1213
- {line.text.length > 32
1214
- ? `${line.text.slice(0, 32)}…`
1215
- : line.text}
1216
- </span>
1217
- </button>
1218
- ))}
1219
- </div>
1220
- </div>
1221
- )}
1222
1263
  {selectedOverlay ? (
1223
1264
  <div className="space-y-3">
1224
- <p className="text-xs font-medium text-foreground">
1225
- {TYPE_LABEL[selectedOverlay.type]}
1226
- </p>
1265
+ <div className="flex items-center justify-between gap-2">
1266
+ <p className="text-xs font-medium text-foreground">
1267
+ {TYPE_LABEL[selectedOverlay.type]}
1268
+ </p>
1269
+ <span className="text-[10px] text-muted">
1270
+ #{overlays.findIndex((o) => o.id === selectedOverlay.id) + 1}
1271
+ </span>
1272
+ </div>
1227
1273
 
1228
1274
  {selectedOverlay.speaker !== undefined && (
1229
1275
  <label className="block space-y-1">
@@ -1629,11 +1675,80 @@ export function LetteringEditor({
1629
1675
  >
1630
1676
  {confirmDelete ? "Click again to delete" : "Delete"}
1631
1677
  </button>
1678
+
1679
+ {scriptLines.length > 0 && (
1680
+ <div
1681
+ className="space-y-1.5 border-t border-border pt-3"
1682
+ data-testid="script-insert-panel"
1683
+ >
1684
+ <span className="text-[10px] font-medium text-muted">
1685
+ Add from script
1686
+ </span>
1687
+ <div className="flex flex-col gap-1">
1688
+ {scriptLines.map((line) => (
1689
+ <button
1690
+ key={line.key}
1691
+ onClick={() => addScriptLine(line)}
1692
+ data-testid={`script-insert-${line.key}`}
1693
+ title={`Add ${line.type} overlay with this text`}
1694
+ className="text-left px-2 py-1 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
1695
+ >
1696
+ <span className="font-medium text-accent">
1697
+ + {TYPE_LABEL[line.type]}
1698
+ </span>{" "}
1699
+ <span className="text-muted">
1700
+ {line.speaker ? `${line.speaker}: ` : ""}
1701
+ {line.text.length > 32
1702
+ ? `${line.text.slice(0, 32)}…`
1703
+ : line.text}
1704
+ </span>
1705
+ </button>
1706
+ ))}
1707
+ </div>
1708
+ </div>
1709
+ )}
1632
1710
  </div>
1633
1711
  ) : (
1634
- <p className="text-xs text-muted" data-testid="inspector-empty">
1635
- Select an overlay to inspect.
1636
- </p>
1712
+ <div className="space-y-3">
1713
+ <p className="text-xs text-muted" data-testid="inspector-empty">
1714
+ Select or add an overlay to inspect it.
1715
+ </p>
1716
+ {cut.aiDraft?.status === "generated" && (
1717
+ <div className="rounded border border-accent/30 bg-accent/5 p-2 text-[10px] text-muted">
1718
+ AI drafted overlays are editable here before export.
1719
+ </div>
1720
+ )}
1721
+ {/* Insert-from-script (#336): drop the cut's planned dialogue/narration/
1722
+ SFX straight into a prefilled overlay — no copy/paste out of JSON. */}
1723
+ {scriptLines.length > 0 && (
1724
+ <div className="space-y-1.5" data-testid="script-insert-panel">
1725
+ <span className="text-[10px] font-medium text-muted">
1726
+ Add from script
1727
+ </span>
1728
+ <div className="flex flex-col gap-1">
1729
+ {scriptLines.map((line) => (
1730
+ <button
1731
+ key={line.key}
1732
+ onClick={() => addScriptLine(line)}
1733
+ data-testid={`script-insert-${line.key}`}
1734
+ title={`Add ${line.type} overlay with this text`}
1735
+ className="text-left px-2 py-1 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
1736
+ >
1737
+ <span className="font-medium text-accent">
1738
+ + {TYPE_LABEL[line.type]}
1739
+ </span>{" "}
1740
+ <span className="text-muted">
1741
+ {line.speaker ? `${line.speaker}: ` : ""}
1742
+ {line.text.length > 32
1743
+ ? `${line.text.slice(0, 32)}…`
1744
+ : line.text}
1745
+ </span>
1746
+ </button>
1747
+ ))}
1748
+ </div>
1749
+ </div>
1750
+ )}
1751
+ </div>
1637
1752
  )}
1638
1753
  </div>
1639
1754
  </div>