@bycrux/editor 1.2.2 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/ControlsInfoModal.tsx +4 -4
- package/src/__tests__/no-opacity-modifier-on-editor-vars.test.ts +84 -0
- package/src/__tests__/node-shims.d.ts +19 -0
- package/src/carousel/AddElementMenu.tsx +1 -1
- package/src/carousel/CarouselEditor.tsx +11 -11
- package/src/carousel/CarouselRenderModal.tsx +11 -11
- package/src/carousel/OverlayPicker.tsx +3 -3
- package/src/carousel/SlidePropertyPanel.tsx +6 -6
- package/src/carousel/__tests__/CarouselEditor.test.tsx +12 -0
- package/src/components/FilmstripScrubber.tsx +1 -1
- package/src/crop/VideoSourceCropModal.tsx +4 -4
- package/src/state/__tests__/use-project-sync.test.tsx +76 -0
- package/src/state/use-project-sync.ts +21 -1
- package/src/text/FontPicker.tsx +3 -3
- package/src/text/TextFormattingToolbar.tsx +3 -3
- package/src/types.ts +16 -0
- package/src/ui/Loader.tsx +1 -1
- package/src/ui/NumberField.tsx +3 -3
- package/src/ui/SwatchInput.tsx +1 -1
- package/src/ui/Tooltip.tsx +1 -1
- package/src/ui/input.tsx +1 -1
- package/src/ui/label.tsx +1 -1
- package/src/ui/textarea.tsx +1 -1
- package/src/video/AudioPolishModal.tsx +21 -21
- package/src/video/CaptionListPanel.test.tsx +8 -7
- package/src/video/CaptionListPanel.tsx +17 -17
- package/src/video/CaptionRegenModal.tsx +7 -7
- package/src/video/CaptionStyleGallery.tsx +1 -1
- package/src/video/CommandPalette.tsx +7 -7
- package/src/video/ImageToneMenu.tsx +7 -7
- package/src/video/OverlayInspector.tsx +7 -7
- package/src/video/RenderModal.tsx +52 -52
- package/src/video/VersionCompare.tsx +7 -7
- package/src/video/VersionPanel.tsx +2 -2
- package/src/video/VideoEditor.tsx +43 -23
- package/src/video/__tests__/VideoEditor.pendingSurface.test.tsx +233 -0
- package/src/video/__tests__/VideoEditor.saveError.test.tsx +105 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +4 -4
- package/src/video/panels/OverlayContentPanel.tsx +5 -5
- package/src/video/preview/OverlayItemsLayer.tsx +7 -2
- package/src/video/preview/SocialPreviewMenu.tsx +5 -5
- package/src/video/preview/__tests__/resolveOverlayPropPaths.test.ts +21 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +2 -2
- package/src/video/timeline/SpeedControl.tsx +3 -3
- package/src/video/timeline/Timeline.tsx +3 -3
- package/src/video/timeline/TrackGutter.tsx +6 -6
- package/src/video/timeline/TrackSettingsPopover.tsx +3 -3
- package/src/video/timeline/VolumeControl.tsx +3 -3
package/package.json
CHANGED
|
@@ -140,7 +140,7 @@ export default function ControlsInfoModal({ title, sections, onClose }: Controls
|
|
|
140
140
|
<div className="relative flex items-start justify-between gap-4">
|
|
141
141
|
<div className="flex items-center gap-3">
|
|
142
142
|
<span
|
|
143
|
-
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border bg-[var(--editor-text)]
|
|
143
|
+
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border bg-[color-mix(in_srgb,var(--editor-text)_6%,transparent)]"
|
|
144
144
|
style={{ borderColor: 'var(--editor-border)' }}
|
|
145
145
|
>
|
|
146
146
|
<Command size={17} style={{ color: 'var(--editor-accent)' }} />
|
|
@@ -156,7 +156,7 @@ export default function ControlsInfoModal({ title, sections, onClose }: Controls
|
|
|
156
156
|
type="button"
|
|
157
157
|
onClick={onClose}
|
|
158
158
|
aria-label="Close"
|
|
159
|
-
className="-mr-1 -mt-1 shrink-0 cursor-pointer rounded-md p-1.5 opacity-55 transition-all hover:bg-[var(--editor-text)]
|
|
159
|
+
className="-mr-1 -mt-1 shrink-0 cursor-pointer rounded-md p-1.5 opacity-55 transition-all hover:bg-[color-mix(in_srgb,var(--editor-text)_10%,transparent)] hover:opacity-100"
|
|
160
160
|
>
|
|
161
161
|
<X size={18} />
|
|
162
162
|
</button>
|
|
@@ -182,7 +182,7 @@ export default function ControlsInfoModal({ title, sections, onClose }: Controls
|
|
|
182
182
|
style={{ borderColor: 'var(--editor-border)' }}
|
|
183
183
|
>
|
|
184
184
|
<span
|
|
185
|
-
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-[var(--editor-text)]
|
|
185
|
+
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-[color-mix(in_srgb,var(--editor-text)_6%,transparent)]"
|
|
186
186
|
>
|
|
187
187
|
<Icon size={14} style={{ color: 'var(--editor-accent)' }} />
|
|
188
188
|
</span>
|
|
@@ -194,7 +194,7 @@ export default function ControlsInfoModal({ title, sections, onClose }: Controls
|
|
|
194
194
|
{section.entries.map((entry, i) => (
|
|
195
195
|
<li
|
|
196
196
|
key={i}
|
|
197
|
-
className="flex items-center justify-between gap-3 rounded-md px-2 py-2 text-sm transition-colors hover:bg-[var(--editor-text)]
|
|
197
|
+
className="flex items-center justify-between gap-3 rounded-md px-2 py-2 text-sm transition-colors hover:bg-[color-mix(in_srgb,var(--editor-text)_6%,transparent)]"
|
|
198
198
|
>
|
|
199
199
|
<span className="flex items-start gap-2.5 opacity-90">
|
|
200
200
|
{/* A toolbar row shows that button's real glyph;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
3
|
+
import { dirname, join, relative } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
// See ./node-shims.d.ts: this package has no @types/node, so the `node:*`
|
|
6
|
+
// specifiers above have no type declarations under this package's tsconfig.
|
|
7
|
+
// Vitest itself runs on Node and provides the real modules at runtime.
|
|
8
|
+
|
|
9
|
+
// Tailwind cannot generate a rule for a `/NN` opacity modifier on an
|
|
10
|
+
// arbitrary CSS-variable color — `text-[var(--editor-text)]/60` and friends
|
|
11
|
+
// — because it can't parse an opaque `var(...)` reference to inject an alpha
|
|
12
|
+
// channel. It silently emits NO rule (measured under a host app's Tailwind
|
|
13
|
+
// v3.4), so the class falls back to an inherited/default color, which is how
|
|
14
|
+
// toolbar and timeline icons went invisible against the editor's dark
|
|
15
|
+
// surface. The fix is `color-mix(in_srgb,var(--editor-X)_NN%,transparent)`
|
|
16
|
+
// baked directly into the arbitrary value, so Tailwind never has to parse
|
|
17
|
+
// the color itself (see CHANGELOG.md "Unreleased", and LeftPanelTabs.tsx for
|
|
18
|
+
// the original instance of this fix).
|
|
19
|
+
//
|
|
20
|
+
// This guard fails the build if that dead pattern comes back anywhere in
|
|
21
|
+
// this package's source, from a revert, a bad merge, or a new component
|
|
22
|
+
// copy-pasting an old snippet.
|
|
23
|
+
|
|
24
|
+
const SRC_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
25
|
+
|
|
26
|
+
// The one deliberate exception: this file quotes the OLD broken class with a
|
|
27
|
+
// real digit (`text-[var(--editor-text)]/40`) inside a comment, as a
|
|
28
|
+
// historical example for a narrower regression test that predates this
|
|
29
|
+
// repo-wide fix. It is not live code and this guard would otherwise treat
|
|
30
|
+
// that quote as a violation.
|
|
31
|
+
const ALLOWLIST = new Set(['video/CaptionListPanel.test.tsx'])
|
|
32
|
+
|
|
33
|
+
// Requires a digit or `[` right after the slash, matching how the modifier
|
|
34
|
+
// actually looks in real Tailwind classes (`/60`, `/[0.06]`) — this keeps
|
|
35
|
+
// prose like "`text-[var(--editor-text)]/N`" (an `N` placeholder, not a real
|
|
36
|
+
// modifier) out of the check without needing it on the allowlist below.
|
|
37
|
+
const DEAD_OPACITY_MODIFIER = /var\(--editor-[a-z-]+\)\]\/[\d[]/
|
|
38
|
+
|
|
39
|
+
function collectSourceFiles(dir: string, out: string[] = []): string[] {
|
|
40
|
+
for (const entry of readdirSync(dir)) {
|
|
41
|
+
if (entry === 'node_modules') continue
|
|
42
|
+
const full = join(dir, entry)
|
|
43
|
+
const stat = statSync(full)
|
|
44
|
+
if (stat.isDirectory()) {
|
|
45
|
+
collectSourceFiles(full, out)
|
|
46
|
+
} else if (/\.(ts|tsx)$/.test(entry)) {
|
|
47
|
+
out.push(full)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return out
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('no dead Tailwind opacity modifier on --editor-* vars', () => {
|
|
54
|
+
it('finds zero `[var(--editor-*)]/NN`-style classes anywhere in src', () => {
|
|
55
|
+
const thisFile = fileURLToPath(import.meta.url)
|
|
56
|
+
const offenders: { file: string; line: number; text: string }[] = []
|
|
57
|
+
|
|
58
|
+
for (const file of collectSourceFiles(SRC_ROOT)) {
|
|
59
|
+
if (file === thisFile) continue
|
|
60
|
+
const rel = relative(SRC_ROOT, file)
|
|
61
|
+
if (ALLOWLIST.has(rel)) continue
|
|
62
|
+
|
|
63
|
+
const lines = readFileSync(file, 'utf8').split('\n')
|
|
64
|
+
lines.forEach((line, i) => {
|
|
65
|
+
if (DEAD_OPACITY_MODIFIER.test(line)) {
|
|
66
|
+
offenders.push({ file: rel, line: i + 1, text: line.trim() })
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (offenders.length > 0) {
|
|
72
|
+
const report = offenders
|
|
73
|
+
.map((o) => ` ${o.file}:${o.line}: ${o.text}`)
|
|
74
|
+
.join('\n')
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Found ${offenders.length} dead Tailwind opacity-modifier class(es) on an --editor-* var ` +
|
|
77
|
+
`(Tailwind emits no rule for these — see this test's header comment).\n` +
|
|
78
|
+
`Replace each with color-mix(in_srgb,var(--editor-X)_NN%,transparent):\n${report}`,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
expect(offenders).toEqual([])
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// This package has no @types/node (it's a browser-facing library with no
|
|
2
|
+
// Node-touching runtime code otherwise), so plain TypeScript can't resolve
|
|
3
|
+
// `node:*` specifiers on its own. Vitest runs on Node and provides the real
|
|
4
|
+
// modules at runtime regardless — these ambient shapes cover only the
|
|
5
|
+
// handful of calls `no-opacity-modifier-on-editor-vars.test.ts` makes,
|
|
6
|
+
// purely so `tsc --noEmit` can typecheck that file.
|
|
7
|
+
declare module 'node:fs' {
|
|
8
|
+
export function readFileSync(path: string, encoding: 'utf8'): string
|
|
9
|
+
export function readdirSync(path: string): string[]
|
|
10
|
+
export function statSync(path: string): { isDirectory(): boolean }
|
|
11
|
+
}
|
|
12
|
+
declare module 'node:path' {
|
|
13
|
+
export function dirname(path: string): string
|
|
14
|
+
export function join(...parts: string[]): string
|
|
15
|
+
export function relative(from: string, to: string): string
|
|
16
|
+
}
|
|
17
|
+
declare module 'node:url' {
|
|
18
|
+
export function fileURLToPath(url: string): string
|
|
19
|
+
}
|
|
@@ -182,7 +182,7 @@ export default function AddElementMenu({ project, selectedSlideId, adapter, onAd
|
|
|
182
182
|
{showPrompt && !disabled && (
|
|
183
183
|
<div className="flex flex-col gap-2 p-3 bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded-lg">
|
|
184
184
|
<textarea
|
|
185
|
-
className="w-full bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded px-2 py-1.5 text-xs text-[var(--editor-text)] placeholder-[var(--editor-text)]
|
|
185
|
+
className="w-full bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded px-2 py-1.5 text-xs text-[var(--editor-text)] placeholder-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] resize-none focus:outline-none focus:border-[var(--editor-accent)]"
|
|
186
186
|
rows={3}
|
|
187
187
|
placeholder="Describe the image to generate…"
|
|
188
188
|
value={prompt}
|
|
@@ -73,7 +73,7 @@ function SlideGrid({
|
|
|
73
73
|
return (
|
|
74
74
|
<div className="w-56 flex-shrink-0 flex flex-col border-r border-[var(--editor-border)] bg-[var(--editor-bg)] overflow-y-auto min-h-0 h-full">
|
|
75
75
|
<div className="px-3 py-2 border-b border-[var(--editor-border)]">
|
|
76
|
-
<span className="text-xs font-semibold text-[var(--editor-text)]
|
|
76
|
+
<span className="text-xs font-semibold text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] uppercase tracking-wider">Slides</span>
|
|
77
77
|
</div>
|
|
78
78
|
<div className="flex-1 overflow-y-auto py-2 flex flex-col gap-2 px-2">
|
|
79
79
|
{slides.map((slide, idx) => (
|
|
@@ -392,7 +392,7 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
|
|
|
392
392
|
const canvasScale = Math.min(availW / w, availH / h, 1)
|
|
393
393
|
|
|
394
394
|
return (
|
|
395
|
-
<div ref={containerRef} className="flex flex-col h-full overflow-y-auto bg-[var(--editor-bg)]">
|
|
395
|
+
<div ref={containerRef} className="flex flex-col h-full overflow-y-auto bg-[var(--editor-bg)] text-[var(--editor-text)]">
|
|
396
396
|
{/* TOP: slide rail | canvas | editing panel (right). Fixed viewport-relative
|
|
397
397
|
height with min-h-0 so each of the three columns establishes its own
|
|
398
398
|
independent scroll context; the project-media region flows beneath and
|
|
@@ -424,7 +424,7 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
|
|
|
424
424
|
className={`flex items-center gap-2 px-3 py-2 rounded-md border transition-colors ${
|
|
425
425
|
refreshState === 'err'
|
|
426
426
|
? (mode === 'light' ? 'text-red-700 border-red-300 bg-red-50 hover:bg-red-100' : 'text-red-300 border-red-500/40 bg-red-950/60 hover:bg-red-900/70')
|
|
427
|
-
: 'text-[var(--editor-text)] border-[var(--editor-border)] bg-[var(--editor-surface)]
|
|
427
|
+
: 'text-[var(--editor-text)] border-[var(--editor-border)] bg-[color-mix(in_srgb,var(--editor-surface)_80%,transparent)] hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)] hover:bg-[var(--editor-surface)]'
|
|
428
428
|
}`}
|
|
429
429
|
title={refreshState === 'err' ? 'Refresh failed — check connection' : 'Refresh project'}
|
|
430
430
|
>
|
|
@@ -434,7 +434,7 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
|
|
|
434
434
|
<button
|
|
435
435
|
onClick={() => state.undo()}
|
|
436
436
|
disabled={!state.canUndo}
|
|
437
|
-
className="flex items-center justify-center p-2 rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)]
|
|
437
|
+
className="flex items-center justify-center p-2 rounded-md border border-[var(--editor-border)] bg-[color-mix(in_srgb,var(--editor-surface)_80%,transparent)] text-[var(--editor-text)] transition-colors hover:border-[var(--editor-accent)] hover:bg-[var(--editor-surface)] disabled:opacity-40 disabled:cursor-not-allowed"
|
|
438
438
|
title="Undo (Cmd/Ctrl+Z)"
|
|
439
439
|
aria-label="Undo"
|
|
440
440
|
>
|
|
@@ -443,7 +443,7 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
|
|
|
443
443
|
<button
|
|
444
444
|
onClick={() => state.redo()}
|
|
445
445
|
disabled={!state.canRedo}
|
|
446
|
-
className="flex items-center justify-center p-2 rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)]
|
|
446
|
+
className="flex items-center justify-center p-2 rounded-md border border-[var(--editor-border)] bg-[color-mix(in_srgb,var(--editor-surface)_80%,transparent)] text-[var(--editor-text)] transition-colors hover:border-[var(--editor-accent)] hover:bg-[var(--editor-surface)] disabled:opacity-40 disabled:cursor-not-allowed"
|
|
447
447
|
title="Redo (Cmd/Ctrl+Shift+Z)"
|
|
448
448
|
aria-label="Redo"
|
|
449
449
|
>
|
|
@@ -479,11 +479,11 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
|
|
|
479
479
|
{slots?.pendingStatus ?? (
|
|
480
480
|
<div className="flex flex-col items-center gap-2">
|
|
481
481
|
<p className="text-[var(--editor-text)] text-lg font-semibold">Message your agent to start</p>
|
|
482
|
-
<p className="text-[var(--editor-text)]
|
|
482
|
+
<p className="text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] text-sm">Nothing will happen automatically. Copy this and send it to your agent.</p>
|
|
483
483
|
</div>
|
|
484
484
|
)}
|
|
485
485
|
{!slots?.pendingStatus && skillPath && (
|
|
486
|
-
<div className="w-full rounded-xl border-2 border-[var(--editor-accent)] bg-[var(--editor-surface)] p-5 flex flex-col gap-3 text-left shadow-lg shadow-[var(--editor-accent)]
|
|
486
|
+
<div className="w-full rounded-xl border-2 border-[var(--editor-accent)] bg-[var(--editor-surface)] p-5 flex flex-col gap-3 text-left shadow-lg shadow-[color-mix(in_srgb,var(--editor-accent)_10%,transparent)]">
|
|
487
487
|
{/* indigo-600 in light mode: the accent (indigo-500) is ~4.06:1 on
|
|
488
488
|
`--editor-surface`, and 12px bold is still NORMAL text under WCAG
|
|
489
489
|
(large starts at 18.66px bold), so it needs the 4.5:1 floor. The
|
|
@@ -527,7 +527,7 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
|
|
|
527
527
|
</div>
|
|
528
528
|
</div>
|
|
529
529
|
)}
|
|
530
|
-
<p className="text-[var(--editor-text)]
|
|
530
|
+
<p className="text-[color-mix(in_srgb,var(--editor-text)_40%,transparent)] text-xs font-mono">project id: {project.id}</p>
|
|
531
531
|
</div>
|
|
532
532
|
) : selectedSlide ? (
|
|
533
533
|
<>
|
|
@@ -538,7 +538,7 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
|
|
|
538
538
|
instead, the same "tint with the foreground colour" idiom the rest
|
|
539
539
|
of the chrome uses, so it is a light ring on dark and a dark ring
|
|
540
540
|
on light at the same subtle strength. */}
|
|
541
|
-
<div className="flex-shrink-0 ring-1 ring-[var(--editor-text)]
|
|
541
|
+
<div className="flex-shrink-0 ring-1 ring-[color-mix(in_srgb,var(--editor-text)_10%,transparent)]">
|
|
542
542
|
<SlideCanvas
|
|
543
543
|
slide={selectedSlide}
|
|
544
544
|
slideId={selectedSlide.id}
|
|
@@ -562,7 +562,7 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
|
|
|
562
562
|
hiddenElementIds={hiddenElementIds}
|
|
563
563
|
/>
|
|
564
564
|
</div>
|
|
565
|
-
<div className="flex-shrink-0 flex items-center justify-center gap-1.5 text-xs text-[var(--editor-text)]
|
|
565
|
+
<div className="flex-shrink-0 flex items-center justify-center gap-1.5 text-xs text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] max-w-md">
|
|
566
566
|
<span className="text-center">
|
|
567
567
|
Drag to reposition, resize/rotate via handles, double-click text to edit. Cmd/Ctrl+Z to undo.
|
|
568
568
|
</span>
|
|
@@ -578,7 +578,7 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
|
|
|
578
578
|
</div>
|
|
579
579
|
</>
|
|
580
580
|
) : (
|
|
581
|
-
<div className="text-[var(--editor-text)]
|
|
581
|
+
<div className="text-[color-mix(in_srgb,var(--editor-text)_40%,transparent)] text-sm">No slides yet. Add one in the left panel.</div>
|
|
582
582
|
)}
|
|
583
583
|
|
|
584
584
|
{state.lastError && (
|
|
@@ -64,13 +64,13 @@ function slidesFromMedia(media: RenderMedia[]): SlideView[] {
|
|
|
64
64
|
|
|
65
65
|
function LogLine({ text, mode = 'dark' }: { text: string; mode?: 'light' | 'dark' }) {
|
|
66
66
|
const t = text.replace(/^\[render\]\s*/, '')
|
|
67
|
-
let color = 'text-[var(--editor-text)]
|
|
67
|
+
let color = 'text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]'
|
|
68
68
|
if (/done|complete|→/i.test(t)) color = mode === 'light' ? 'text-green-700' : 'text-green-400'
|
|
69
69
|
else if (/rendering|launching|bundling/i.test(t)) color = mode === 'light' ? 'text-sky-700' : 'text-sky-400'
|
|
70
70
|
else if (/error|fail/i.test(t)) color = mode === 'light' ? 'text-red-600' : 'text-red-400'
|
|
71
71
|
|
|
72
72
|
const prefix = text.startsWith('[render]')
|
|
73
|
-
? <span className="text-[var(--editor-text)]
|
|
73
|
+
? <span className="text-[color-mix(in_srgb,var(--editor-text)_40%,transparent)]">[render] </span>
|
|
74
74
|
: null
|
|
75
75
|
|
|
76
76
|
return (
|
|
@@ -266,13 +266,13 @@ export default function CarouselRenderModal({ projectId, adapter, slidesCount, r
|
|
|
266
266
|
/>
|
|
267
267
|
<div className="absolute bottom-0 left-0 right-0 px-2 py-1.5 bg-black text-[11px] text-[var(--editor-text)] font-mono flex justify-between">
|
|
268
268
|
<span>#{String(i + 1).padStart(2, '0')}</span>
|
|
269
|
-
<span className="text-[var(--editor-text)]
|
|
269
|
+
<span className="text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">{slide.filename}</span>
|
|
270
270
|
</div>
|
|
271
271
|
</a>
|
|
272
272
|
))}
|
|
273
273
|
</div>
|
|
274
274
|
) : (
|
|
275
|
-
<p className="text-sm text-[var(--editor-text)]
|
|
275
|
+
<p className="text-sm text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">Render complete.</p>
|
|
276
276
|
)}
|
|
277
277
|
</div>
|
|
278
278
|
|
|
@@ -283,12 +283,12 @@ export default function CarouselRenderModal({ projectId, adapter, slidesCount, r
|
|
|
283
283
|
<span className="w-2 h-2 rounded-full bg-green-400" />
|
|
284
284
|
<div>
|
|
285
285
|
<p className="text-sm font-semibold text-[var(--editor-text)]">Render complete</p>
|
|
286
|
-
<p className="text-xs text-[var(--editor-text)]
|
|
286
|
+
<p className="text-xs text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)]">
|
|
287
287
|
{slides.length || slidesCount} slide{(slides.length || slidesCount) === 1 ? '' : 's'} ready.
|
|
288
288
|
</p>
|
|
289
289
|
</div>
|
|
290
290
|
</div>
|
|
291
|
-
<button onClick={onClose} className="text-[var(--editor-text)]
|
|
291
|
+
<button onClick={onClose} className="text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] hover:text-[var(--editor-text)] transition-colors text-lg leading-none">×</button>
|
|
292
292
|
</div>
|
|
293
293
|
|
|
294
294
|
<div className="flex flex-col gap-3 p-5 flex-1">
|
|
@@ -325,7 +325,7 @@ export default function CarouselRenderModal({ projectId, adapter, slidesCount, r
|
|
|
325
325
|
</div>
|
|
326
326
|
</div>
|
|
327
327
|
{status !== 'running' && (
|
|
328
|
-
<button onClick={onClose} className="text-[var(--editor-text)]
|
|
328
|
+
<button onClick={onClose} className="text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] hover:text-[var(--editor-text)] transition-colors text-lg leading-none">×</button>
|
|
329
329
|
)}
|
|
330
330
|
</div>
|
|
331
331
|
|
|
@@ -334,8 +334,8 @@ export default function CarouselRenderModal({ projectId, adapter, slidesCount, r
|
|
|
334
334
|
{status === 'running' ? (
|
|
335
335
|
<>
|
|
336
336
|
<Loader size="lg" />
|
|
337
|
-
<p className="text-sm text-[var(--editor-text)]
|
|
338
|
-
<p className="text-xs text-[var(--editor-text)]
|
|
337
|
+
<p className="text-sm text-[color-mix(in_srgb,var(--editor-text)_70%,transparent)]">Rendering slides…</p>
|
|
338
|
+
<p className="text-xs text-[color-mix(in_srgb,var(--editor-text)_50%,transparent)]">This can take a moment.</p>
|
|
339
339
|
</>
|
|
340
340
|
) : (
|
|
341
341
|
<p className={`text-sm whitespace-pre-wrap break-words text-center ${mode === 'light' ? 'text-red-600' : 'text-red-400'}`}>
|
|
@@ -347,7 +347,7 @@ export default function CarouselRenderModal({ projectId, adapter, slidesCount, r
|
|
|
347
347
|
<div className="relative">
|
|
348
348
|
<button
|
|
349
349
|
onClick={() => navigator.clipboard.writeText(logs.join('\n') + (errorMsg ? '\n' + errorMsg : ''))}
|
|
350
|
-
className="absolute top-2 right-2 z-10 text-[10px] px-2 py-0.5 rounded bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[var(--editor-text)]
|
|
350
|
+
className="absolute top-2 right-2 z-10 text-[10px] px-2 py-0.5 rounded bg-[var(--editor-surface)] border border-[var(--editor-border)] text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)] transition-colors"
|
|
351
351
|
title="Copy logs"
|
|
352
352
|
>
|
|
353
353
|
Copy
|
|
@@ -357,7 +357,7 @@ export default function CarouselRenderModal({ projectId, adapter, slidesCount, r
|
|
|
357
357
|
className="h-96 overflow-y-auto px-4 py-3 font-mono text-[11px] text-[var(--editor-text)] bg-[var(--editor-bg)] flex flex-col gap-0.5"
|
|
358
358
|
>
|
|
359
359
|
{logs.length === 0 && status === 'running' && (
|
|
360
|
-
<span className="text-[var(--editor-text)]
|
|
360
|
+
<span className="text-[color-mix(in_srgb,var(--editor-text)_40%,transparent)] italic">Starting render engine…</span>
|
|
361
361
|
)}
|
|
362
362
|
{logs.map((line, i) => (
|
|
363
363
|
<LogLine key={i} text={line} mode={mode} />
|
|
@@ -108,7 +108,7 @@ export default function OverlayPicker({ open, onClose, project, adapter, onPick,
|
|
|
108
108
|
<h2 className="text-sm font-semibold text-[var(--editor-text)]">Add Overlay</h2>
|
|
109
109
|
<button
|
|
110
110
|
onClick={onClose}
|
|
111
|
-
className="text-[var(--editor-text)]
|
|
111
|
+
className="text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] hover:text-[var(--editor-text)] transition-colors text-lg leading-none"
|
|
112
112
|
>
|
|
113
113
|
×
|
|
114
114
|
</button>
|
|
@@ -124,7 +124,7 @@ export default function OverlayPicker({ open, onClose, project, adapter, onPick,
|
|
|
124
124
|
<div className={`text-center text-sm py-8 ${mode === 'light' ? 'text-red-600' : 'text-red-400'}`}>{error}</div>
|
|
125
125
|
)}
|
|
126
126
|
{!loading && !error && overlays.length === 0 && (
|
|
127
|
-
<div className="text-center text-[var(--editor-text)]
|
|
127
|
+
<div className="text-center text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] text-sm py-8">No overlays available</div>
|
|
128
128
|
)}
|
|
129
129
|
{!loading && !error && overlays.length > 0 && (
|
|
130
130
|
<div className="grid grid-cols-3 gap-3">
|
|
@@ -139,7 +139,7 @@ export default function OverlayPicker({ open, onClose, project, adapter, onPick,
|
|
|
139
139
|
<div className="text-xs text-[var(--editor-accent)] mt-0.5 truncate">{overlay.group}</div>
|
|
140
140
|
)}
|
|
141
141
|
{overlay.description && (
|
|
142
|
-
<div className="text-xs text-[var(--editor-text)]
|
|
142
|
+
<div className="text-xs text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] mt-1 line-clamp-2">{overlay.description}</div>
|
|
143
143
|
)}
|
|
144
144
|
</button>
|
|
145
145
|
))}
|
|
@@ -14,11 +14,11 @@ import { Loader } from '../ui/Loader'
|
|
|
14
14
|
import { TextFormattingToolbar } from '../text/TextFormattingToolbar'
|
|
15
15
|
|
|
16
16
|
// Shared small muted label that sits just above an inspector control.
|
|
17
|
-
const fieldLabelClass = 'text-[11px] uppercase tracking-wide text-[var(--editor-text)]
|
|
17
|
+
const fieldLabelClass = 'text-[11px] uppercase tracking-wide text-[color-mix(in_srgb,var(--editor-text)_55%,transparent)]'
|
|
18
18
|
|
|
19
19
|
// Section header: SLIDE / OVERLAY / IMAGE.
|
|
20
20
|
const sectionHeaderClass =
|
|
21
|
-
'text-xs font-semibold uppercase tracking-wider text-[var(--editor-text)]
|
|
21
|
+
'text-xs font-semibold uppercase tracking-wider text-[color-mix(in_srgb,var(--editor-text)_70%,transparent)]'
|
|
22
22
|
|
|
23
23
|
interface Props {
|
|
24
24
|
project: Project
|
|
@@ -71,7 +71,7 @@ function HideToggle({
|
|
|
71
71
|
title={isHidden ? 'Show in editor' : 'Hide from editor'}
|
|
72
72
|
aria-label={isHidden ? 'Show in editor' : 'Hide from editor'}
|
|
73
73
|
aria-pressed={isHidden}
|
|
74
|
-
className="flex h-7 w-7 items-center justify-center rounded text-[var(--editor-text)]
|
|
74
|
+
className="flex h-7 w-7 items-center justify-center rounded text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] hover:bg-[var(--editor-surface)] hover:text-[var(--editor-text)]"
|
|
75
75
|
>
|
|
76
76
|
{isHidden ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
|
77
77
|
</button>
|
|
@@ -238,7 +238,7 @@ export default function SlidePropertyPanel({
|
|
|
238
238
|
|
|
239
239
|
if (!slide) {
|
|
240
240
|
return (
|
|
241
|
-
<div className={cn('w-80 flex-shrink-0 flex items-center justify-center text-[var(--editor-text)]
|
|
241
|
+
<div className={cn('w-80 flex-shrink-0 flex items-center justify-center text-[color-mix(in_srgb,var(--editor-text)_40%,transparent)] text-xs p-4', className)}>
|
|
242
242
|
Select a slide
|
|
243
243
|
</div>
|
|
244
244
|
)
|
|
@@ -295,14 +295,14 @@ export default function SlidePropertyPanel({
|
|
|
295
295
|
/>
|
|
296
296
|
<button
|
|
297
297
|
onClick={() => onReorderElement(slide.id, element.id, 'forward')}
|
|
298
|
-
className="flex h-7 w-7 items-center justify-center rounded text-[var(--editor-text)]
|
|
298
|
+
className="flex h-7 w-7 items-center justify-center rounded text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] hover:bg-[var(--editor-surface)] hover:text-[var(--editor-text)]"
|
|
299
299
|
title="Bring forward"
|
|
300
300
|
>
|
|
301
301
|
↑
|
|
302
302
|
</button>
|
|
303
303
|
<button
|
|
304
304
|
onClick={() => onReorderElement(slide.id, element.id, 'backward')}
|
|
305
|
-
className="flex h-7 w-7 items-center justify-center rounded text-[var(--editor-text)]
|
|
305
|
+
className="flex h-7 w-7 items-center justify-center rounded text-[color-mix(in_srgb,var(--editor-text)_60%,transparent)] hover:bg-[var(--editor-surface)] hover:text-[var(--editor-text)]"
|
|
306
306
|
title="Send backward"
|
|
307
307
|
>
|
|
308
308
|
↓
|
|
@@ -105,6 +105,18 @@ describe('CarouselEditor — editor-core integration', () => {
|
|
|
105
105
|
expect(wrapper?.className).not.toContain('w-80')
|
|
106
106
|
})
|
|
107
107
|
|
|
108
|
+
// The root sets the editor text colour (as VideoEditor's root does), so chrome
|
|
109
|
+
// that inherits colour reads --editor-text instead of the host page's colour.
|
|
110
|
+
it('sets the editor text colour on its root', async () => {
|
|
111
|
+
const adapter = makeFakeAdapter()
|
|
112
|
+
const { container } = render(
|
|
113
|
+
<CarouselEditor project={makeProject()} adapter={adapter} onProjectChange={vi.fn()} />,
|
|
114
|
+
)
|
|
115
|
+
const root = container.firstElementChild as HTMLElement
|
|
116
|
+
expect(root.className).toContain('bg-[var(--editor-bg)]')
|
|
117
|
+
expect(root.className).toContain('text-[var(--editor-text)]')
|
|
118
|
+
})
|
|
119
|
+
|
|
108
120
|
// Regression: SlideGrid thumbnails must receive `compileOverlay` so overlay
|
|
109
121
|
// elements render in the left rail. Before the fix the thumbnail used a
|
|
110
122
|
// noopCompiler that always rejected → a red "overlay error" badge on every
|
|
@@ -266,7 +266,7 @@ export default function FilmstripScrubber({
|
|
|
266
266
|
)}
|
|
267
267
|
{phase === 'error' && (
|
|
268
268
|
<div
|
|
269
|
-
className="pointer-events-none absolute inset-0 flex items-center justify-center bg-[var(--editor-surface)] text-[10px] text-[var(--editor-text)]
|
|
269
|
+
className="pointer-events-none absolute inset-0 flex items-center justify-center bg-[var(--editor-surface)] text-[10px] text-[color-mix(in_srgb,var(--editor-text)_40%,transparent)]"
|
|
270
270
|
role="status"
|
|
271
271
|
>
|
|
272
272
|
No preview
|
|
@@ -165,7 +165,7 @@ export function VideoSourceCropModal({
|
|
|
165
165
|
>
|
|
166
166
|
<div className="flex items-center justify-between">
|
|
167
167
|
<h2 className="text-sm font-semibold text-[var(--editor-text)]">Crop source</h2>
|
|
168
|
-
<span className="text-xs text-[var(--editor-text)]
|
|
168
|
+
<span className="text-xs text-[color-mix(in_srgb,var(--editor-text)_50%,transparent)]">Pick the part of the footage to keep — position & zoom live on the canvas</span>
|
|
169
169
|
</div>
|
|
170
170
|
|
|
171
171
|
{/* Source frame + crop window */}
|
|
@@ -243,8 +243,8 @@ export function VideoSourceCropModal({
|
|
|
243
243
|
title={key === 'free' ? 'Free-form crop' : `Lock crop to ${label}`}
|
|
244
244
|
className={`flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${
|
|
245
245
|
active
|
|
246
|
-
? 'border-[var(--editor-selection)] bg-[var(--editor-selection)]
|
|
247
|
-
: 'border-[var(--editor-border)] text-[var(--editor-text)]
|
|
246
|
+
? 'border-[var(--editor-selection)] bg-[color-mix(in_srgb,var(--editor-selection)_15%,transparent)] text-[var(--editor-text)]'
|
|
247
|
+
: 'border-[var(--editor-border)] text-[color-mix(in_srgb,var(--editor-text)_70%,transparent)] hover:text-[var(--editor-text)]'
|
|
248
248
|
}`}
|
|
249
249
|
>
|
|
250
250
|
<Icon size={14} />
|
|
@@ -258,7 +258,7 @@ export function VideoSourceCropModal({
|
|
|
258
258
|
<div className="flex items-center justify-end gap-2 pt-1">
|
|
259
259
|
<button
|
|
260
260
|
onClick={onClose}
|
|
261
|
-
className="rounded-md px-3 py-1.5 text-xs font-medium text-[var(--editor-text)]
|
|
261
|
+
className="rounded-md px-3 py-1.5 text-xs font-medium text-[color-mix(in_srgb,var(--editor-text)_70%,transparent)] hover:text-[var(--editor-text)] transition-colors"
|
|
262
262
|
>
|
|
263
263
|
Cancel
|
|
264
264
|
</button>
|
|
@@ -369,3 +369,79 @@ describe('useProjectSync — subscription lifecycle', () => {
|
|
|
369
369
|
expect(adapter.unsub).toHaveBeenCalledTimes(1)
|
|
370
370
|
})
|
|
371
371
|
})
|
|
372
|
+
|
|
373
|
+
describe('useProjectSync — onLocalEdit', () => {
|
|
374
|
+
it('fires on the four user paths only (mutate, a real commit, undo, redo)', async () => {
|
|
375
|
+
const adapter = makeFakeAdapter()
|
|
376
|
+
const initial = makeProject()
|
|
377
|
+
const onLocalEdit = vi.fn()
|
|
378
|
+
const { result } = renderHook(() =>
|
|
379
|
+
useProjectSync(adapter, initial.id, initial, { onLocalEdit }),
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
await act(async () => { await result.current.mutate((p) => ({ ...p, name: 'A' })) })
|
|
383
|
+
expect(onLocalEdit).toHaveBeenCalledTimes(1)
|
|
384
|
+
|
|
385
|
+
act(() => {
|
|
386
|
+
result.current.mutateTransient((p) => ({ ...p, name: 'B1' }))
|
|
387
|
+
result.current.mutateTransient((p) => ({ ...p, name: 'B2' }))
|
|
388
|
+
result.current.mutateTransient((p) => ({ ...p, name: 'B3' }))
|
|
389
|
+
})
|
|
390
|
+
expect(onLocalEdit).toHaveBeenCalledTimes(1)
|
|
391
|
+
|
|
392
|
+
await act(async () => { await result.current.commit() })
|
|
393
|
+
expect(onLocalEdit).toHaveBeenCalledTimes(2)
|
|
394
|
+
|
|
395
|
+
// A commit with no transient gesture in flight changed nothing.
|
|
396
|
+
await act(async () => { await result.current.commit() })
|
|
397
|
+
expect(onLocalEdit).toHaveBeenCalledTimes(2)
|
|
398
|
+
|
|
399
|
+
await act(async () => { result.current.undo() })
|
|
400
|
+
expect(onLocalEdit).toHaveBeenCalledTimes(3)
|
|
401
|
+
|
|
402
|
+
await act(async () => { result.current.redo() })
|
|
403
|
+
expect(onLocalEdit).toHaveBeenCalledTimes(4)
|
|
404
|
+
|
|
405
|
+
act(() => { result.current.applyExternal(makeProject({ name: 'Ext' })) })
|
|
406
|
+
expect(onLocalEdit).toHaveBeenCalledTimes(4)
|
|
407
|
+
|
|
408
|
+
act(() => { adapter.emit(makeProject({ name: 'SSE' })) })
|
|
409
|
+
expect(result.current.project.name).toBe('SSE')
|
|
410
|
+
expect(onLocalEdit).toHaveBeenCalledTimes(4)
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it('does not fire for undo or redo on an empty stack, or for discardTransient', async () => {
|
|
414
|
+
const adapter = makeFakeAdapter()
|
|
415
|
+
const initial = makeProject()
|
|
416
|
+
const onLocalEdit = vi.fn()
|
|
417
|
+
const { result } = renderHook(() =>
|
|
418
|
+
useProjectSync(adapter, initial.id, initial, { onLocalEdit }),
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
act(() => { result.current.undo() })
|
|
422
|
+
act(() => { result.current.redo() })
|
|
423
|
+
act(() => {
|
|
424
|
+
result.current.mutateTransient((p) => ({ ...p, name: 'X' }))
|
|
425
|
+
result.current.discardTransient()
|
|
426
|
+
})
|
|
427
|
+
expect(onLocalEdit).not.toHaveBeenCalled()
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
it('calls the latest callback when the option identity changes', async () => {
|
|
431
|
+
const adapter = makeFakeAdapter()
|
|
432
|
+
const initial = makeProject()
|
|
433
|
+
const first = vi.fn()
|
|
434
|
+
const second = vi.fn()
|
|
435
|
+
const { result, rerender } = renderHook(
|
|
436
|
+
({ cb }) => useProjectSync(adapter, initial.id, initial, { onLocalEdit: cb }),
|
|
437
|
+
{ initialProps: { cb: first } },
|
|
438
|
+
)
|
|
439
|
+
const mutateBefore = result.current.mutate
|
|
440
|
+
rerender({ cb: second })
|
|
441
|
+
expect(result.current.mutate).toBe(mutateBefore)
|
|
442
|
+
|
|
443
|
+
await act(async () => { await result.current.mutate((p) => ({ ...p, name: 'A' })) })
|
|
444
|
+
expect(first).not.toHaveBeenCalled()
|
|
445
|
+
expect(second).toHaveBeenCalledTimes(1)
|
|
446
|
+
})
|
|
447
|
+
})
|
|
@@ -41,6 +41,16 @@ export interface UseProjectSyncOptions<P extends Project> {
|
|
|
41
41
|
* one here — it must be a pure function of its two arguments.
|
|
42
42
|
*/
|
|
43
43
|
reconcile?: (prev: P, next: P) => P
|
|
44
|
+
/**
|
|
45
|
+
* Called on every local user edit: `mutate`, a `commit` that closes a
|
|
46
|
+
* gesture which actually changed something, and an `undo`/`redo` that popped
|
|
47
|
+
* a snapshot. Never for `mutateTransient`, `discardTransient`,
|
|
48
|
+
* `applyExternal`, `refetch` or SSE frames, so a host can tell its own
|
|
49
|
+
* user's edits from server-authored writes. Fired after the optimistic
|
|
50
|
+
* apply, before the save resolves. Read through a ref: identity changes are
|
|
51
|
+
* harmless and never re-create the returned callbacks.
|
|
52
|
+
*/
|
|
53
|
+
onLocalEdit?: () => void
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
export interface UseProjectSync<P extends Project = Project> {
|
|
@@ -97,6 +107,10 @@ export function useProjectSync<P extends Project = Project>(
|
|
|
97
107
|
const reconcileRef = useRef(options?.reconcile)
|
|
98
108
|
reconcileRef.current = options?.reconcile
|
|
99
109
|
|
|
110
|
+
// `onLocalEdit` likewise, so the user-edit paths stay referentially stable.
|
|
111
|
+
const onLocalEditRef = useRef(options?.onLocalEdit)
|
|
112
|
+
onLocalEditRef.current = options?.onLocalEdit
|
|
113
|
+
|
|
100
114
|
// Latest deferred external frame. Held while saves are in flight because an
|
|
101
115
|
// echo for an earlier save can arrive while a later save is still mid-flight —
|
|
102
116
|
// applying it would regress the optimistic state to the older value (visible
|
|
@@ -193,6 +207,7 @@ export function useProjectSync<P extends Project = Project>(
|
|
|
193
207
|
// Non-transient mutations reset the baseline so any subsequent gesture
|
|
194
208
|
// starts from the freshly committed state.
|
|
195
209
|
transientBaseline.current = null
|
|
210
|
+
onLocalEditRef.current?.()
|
|
196
211
|
return queue.current.enqueue(() =>
|
|
197
212
|
save(next, snapshot).catch((err) => {
|
|
198
213
|
setLastError(err instanceof Error ? err.message : String(err))
|
|
@@ -222,7 +237,10 @@ export function useProjectSync<P extends Project = Project>(
|
|
|
222
237
|
const current = projectRef.current
|
|
223
238
|
const baseline = transientBaseline.current
|
|
224
239
|
transientBaseline.current = null
|
|
225
|
-
if (baseline !== null)
|
|
240
|
+
if (baseline !== null) {
|
|
241
|
+
pushUndo(baseline)
|
|
242
|
+
onLocalEditRef.current?.()
|
|
243
|
+
}
|
|
226
244
|
const rollbackTo = baseline ?? current
|
|
227
245
|
return queue.current.enqueue(() =>
|
|
228
246
|
save(current, rollbackTo).catch((err) => {
|
|
@@ -261,6 +279,7 @@ export function useProjectSync<P extends Project = Project>(
|
|
|
261
279
|
projectRef.current = prev
|
|
262
280
|
transientBaseline.current = null
|
|
263
281
|
setProject(prev)
|
|
282
|
+
onLocalEditRef.current?.()
|
|
264
283
|
void queue.current.enqueue(() =>
|
|
265
284
|
save(prev, current).catch((err) => {
|
|
266
285
|
setLastError(err instanceof Error ? err.message : String(err))
|
|
@@ -279,6 +298,7 @@ export function useProjectSync<P extends Project = Project>(
|
|
|
279
298
|
projectRef.current = next
|
|
280
299
|
transientBaseline.current = null
|
|
281
300
|
setProject(next)
|
|
301
|
+
onLocalEditRef.current?.()
|
|
282
302
|
void queue.current.enqueue(() =>
|
|
283
303
|
save(next, current).catch((err) => {
|
|
284
304
|
setLastError(err instanceof Error ? err.message : String(err))
|