@1agh/maude 0.42.0 → 0.43.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/apps/studio/.ai/cache/_stats.json +7 -0
- package/apps/studio/acp/bootstrap-brief.ts +12 -2
- package/apps/studio/annotations-layer.tsx +1055 -278
- package/apps/studio/annotations-model.ts +58 -0
- package/apps/studio/api.ts +173 -22
- package/apps/studio/bin/_ingest-footage.mjs +386 -0
- package/apps/studio/bin/_probe-footage-playwright.mjs +269 -0
- package/apps/studio/bin/_video-playwright.mjs +141 -71
- package/apps/studio/bin/ingest-footage.sh +27 -0
- package/apps/studio/bin/photo-adjust.sh +163 -0
- package/apps/studio/bin/photo-bg-remove.sh +160 -0
- package/apps/studio/bin/probe-footage.sh +28 -0
- package/apps/studio/bin/read-annotations.mjs +125 -2
- package/apps/studio/canvas-edit.ts +459 -10
- package/apps/studio/canvas-lib.tsx +523 -5
- package/apps/studio/canvas-pipeline.ts +65 -7
- package/apps/studio/canvas-shell.tsx +156 -16
- package/apps/studio/client/app.jsx +451 -34
- package/apps/studio/client/export-center.jsx +35 -1
- package/apps/studio/client/github.js +4 -0
- package/apps/studio/client/panels/GitPanel.jsx +8 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +133 -21
- package/apps/studio/client/panels/StickerPicker.jsx +160 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +15 -2
- package/apps/studio/client/photo-knobs.jsx +437 -0
- package/apps/studio/client/styles/3-shell-maude.css +42 -0
- package/apps/studio/commands/edit-source-command.ts +31 -4
- package/apps/studio/context-menu.tsx +31 -6
- package/apps/studio/dist/client.bundle.js +5561 -19
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/runtime/.min-sizes.json +1 -0
- package/apps/studio/dist/runtime/@imgly_background-removal.js +5543 -0
- package/apps/studio/dist/runtime/pixi-js.js +519 -519
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +25 -0
- package/apps/studio/exporters/jobs.ts +47 -5
- package/apps/studio/exporters/video.ts +24 -4
- package/apps/studio/footage/schema.test.ts +195 -0
- package/apps/studio/footage/schema.ts +484 -0
- package/apps/studio/footage-store.ts +235 -0
- package/apps/studio/fs-watch.ts +10 -0
- package/apps/studio/git/endpoints.ts +68 -7
- package/apps/studio/git/service.ts +196 -20
- package/apps/studio/github/service.ts +67 -0
- package/apps/studio/handoff.ts +3 -2
- package/apps/studio/http.ts +173 -14
- package/apps/studio/input-router.tsx +10 -0
- package/apps/studio/inspect.ts +53 -1
- package/apps/studio/paths.ts +10 -0
- package/apps/studio/photo/filters.ts +285 -0
- package/apps/studio/photo/pipeline.ts +607 -0
- package/apps/studio/photo/schema.ts +367 -0
- package/apps/studio/photo-store.ts +154 -0
- package/apps/studio/runtime-bundle.ts +29 -6
- package/apps/studio/server.ts +5 -1
- package/apps/studio/stickers/figjam-doodle/manifest.json +104 -0
- package/apps/studio/stickers/figjam-doodle/slice1.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice10.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice11.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice12.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice13.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice14.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice15.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice16.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice17.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice18.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice19.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice2.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice20.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice21.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice22.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice23.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice24.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice3.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice4.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice5.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice6.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice7.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice8.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice9.png +0 -0
- package/apps/studio/stickers/life-style/best.png +0 -0
- package/apps/studio/stickers/life-style/come-on.png +0 -0
- package/apps/studio/stickers/life-style/cut.png +0 -0
- package/apps/studio/stickers/life-style/detox.png +0 -0
- package/apps/studio/stickers/life-style/just.png +0 -0
- package/apps/studio/stickers/life-style/manifest.json +68 -0
- package/apps/studio/stickers/life-style/mirror.png +0 -0
- package/apps/studio/stickers/life-style/nice.png +0 -0
- package/apps/studio/stickers/life-style/objects.png +0 -0
- package/apps/studio/stickers/life-style/ok.png +0 -0
- package/apps/studio/stickers/life-style/queen.png +0 -0
- package/apps/studio/stickers/life-style/star.png +0 -0
- package/apps/studio/stickers/life-style/sun.png +0 -0
- package/apps/studio/stickers/life-style/water.png +0 -0
- package/apps/studio/stickers/life-style/yeah.png +0 -0
- package/apps/studio/stickers/life-style/you-can.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/check-the-deets.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/cut-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/dope.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/fresh.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/hot.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/idea.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/keep-exploring.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/killed-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/love-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/manifest.json +88 -0
- package/apps/studio/stickers/opposing-thoughts/not-sure.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/ok.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/pure-gold.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/save-for-later.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/steal-this.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/take-a-peek.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/this-or-that.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/thoughts.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/vibes.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/winner.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/wip.png +0 -0
- package/apps/studio/stickers/project-status/group-100.png +0 -0
- package/apps/studio/stickers/project-status/group-101.png +0 -0
- package/apps/studio/stickers/project-status/group-102.png +0 -0
- package/apps/studio/stickers/project-status/group-103.png +0 -0
- package/apps/studio/stickers/project-status/group-104.png +0 -0
- package/apps/studio/stickers/project-status/group-105.png +0 -0
- package/apps/studio/stickers/project-status/group-106.png +0 -0
- package/apps/studio/stickers/project-status/group-107.png +0 -0
- package/apps/studio/stickers/project-status/group-108.png +0 -0
- package/apps/studio/stickers/project-status/group-109.png +0 -0
- package/apps/studio/stickers/project-status/group-110.png +0 -0
- package/apps/studio/stickers/project-status/group-111.png +0 -0
- package/apps/studio/stickers/project-status/group-112.png +0 -0
- package/apps/studio/stickers/project-status/group-113.png +0 -0
- package/apps/studio/stickers/project-status/group-114.png +0 -0
- package/apps/studio/stickers/project-status/group-115.png +0 -0
- package/apps/studio/stickers/project-status/group-117.png +0 -0
- package/apps/studio/stickers/project-status/group-118.png +0 -0
- package/apps/studio/stickers/project-status/group-119.png +0 -0
- package/apps/studio/stickers/project-status/group-120.png +0 -0
- package/apps/studio/stickers/project-status/group-121.png +0 -0
- package/apps/studio/stickers/project-status/group-122.png +0 -0
- package/apps/studio/stickers/project-status/group-41.png +0 -0
- package/apps/studio/stickers/project-status/group-42.png +0 -0
- package/apps/studio/stickers/project-status/group-43.png +0 -0
- package/apps/studio/stickers/project-status/group-44.png +0 -0
- package/apps/studio/stickers/project-status/group-45.png +0 -0
- package/apps/studio/stickers/project-status/group-46.png +0 -0
- package/apps/studio/stickers/project-status/group-47.png +0 -0
- package/apps/studio/stickers/project-status/group-48.png +0 -0
- package/apps/studio/stickers/project-status/group-49.png +0 -0
- package/apps/studio/stickers/project-status/group-50.png +0 -0
- package/apps/studio/stickers/project-status/group-51.png +0 -0
- package/apps/studio/stickers/project-status/group-52.png +0 -0
- package/apps/studio/stickers/project-status/group-53.png +0 -0
- package/apps/studio/stickers/project-status/group-54.png +0 -0
- package/apps/studio/stickers/project-status/group-55.png +0 -0
- package/apps/studio/stickers/project-status/group-56.png +0 -0
- package/apps/studio/stickers/project-status/group-57.png +0 -0
- package/apps/studio/stickers/project-status/group-58.png +0 -0
- package/apps/studio/stickers/project-status/group-59.png +0 -0
- package/apps/studio/stickers/project-status/group-60.png +0 -0
- package/apps/studio/stickers/project-status/group-61.png +0 -0
- package/apps/studio/stickers/project-status/group-62.png +0 -0
- package/apps/studio/stickers/project-status/group-63.png +0 -0
- package/apps/studio/stickers/project-status/group-64.png +0 -0
- package/apps/studio/stickers/project-status/group-65.png +0 -0
- package/apps/studio/stickers/project-status/group-66.png +0 -0
- package/apps/studio/stickers/project-status/group-67.png +0 -0
- package/apps/studio/stickers/project-status/group-68.png +0 -0
- package/apps/studio/stickers/project-status/group-69.png +0 -0
- package/apps/studio/stickers/project-status/group-70.png +0 -0
- package/apps/studio/stickers/project-status/group-71.png +0 -0
- package/apps/studio/stickers/project-status/group-72.png +0 -0
- package/apps/studio/stickers/project-status/group-73.png +0 -0
- package/apps/studio/stickers/project-status/group-74.png +0 -0
- package/apps/studio/stickers/project-status/group-75.png +0 -0
- package/apps/studio/stickers/project-status/group-76.png +0 -0
- package/apps/studio/stickers/project-status/group-77.png +0 -0
- package/apps/studio/stickers/project-status/group-78.png +0 -0
- package/apps/studio/stickers/project-status/group-79.png +0 -0
- package/apps/studio/stickers/project-status/group-80.png +0 -0
- package/apps/studio/stickers/project-status/group-81.png +0 -0
- package/apps/studio/stickers/project-status/group-82.png +0 -0
- package/apps/studio/stickers/project-status/group-83.png +0 -0
- package/apps/studio/stickers/project-status/group-84.png +0 -0
- package/apps/studio/stickers/project-status/group-85.png +0 -0
- package/apps/studio/stickers/project-status/group-86.png +0 -0
- package/apps/studio/stickers/project-status/group-87.png +0 -0
- package/apps/studio/stickers/project-status/group-88.png +0 -0
- package/apps/studio/stickers/project-status/group-89.png +0 -0
- package/apps/studio/stickers/project-status/group-90.png +0 -0
- package/apps/studio/stickers/project-status/group-91.png +0 -0
- package/apps/studio/stickers/project-status/group-92.png +0 -0
- package/apps/studio/stickers/project-status/group-93.png +0 -0
- package/apps/studio/stickers/project-status/group-94.png +0 -0
- package/apps/studio/stickers/project-status/group-96.png +0 -0
- package/apps/studio/stickers/project-status/group-97.png +0 -0
- package/apps/studio/stickers/project-status/group-98.png +0 -0
- package/apps/studio/stickers/project-status/group-99.png +0 -0
- package/apps/studio/stickers/project-status/manifest.json +328 -0
- package/apps/studio/test/acp-bootstrap-brief.test.ts +11 -0
- package/apps/studio/test/acp-commands.test.ts +2 -1
- package/apps/studio/test/asset-api.test.ts +3 -3
- package/apps/studio/test/canvas-edit.test.ts +17 -6
- package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
- package/apps/studio/test/dynamic-text-edit.test.ts +162 -0
- package/apps/studio/test/edit-source-command.test.ts +23 -0
- package/apps/studio/test/element-structural-edit.test.ts +46 -0
- package/apps/studio/test/footage-store.test.ts +100 -0
- package/apps/studio/test/git-branches.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +56 -1
- package/apps/studio/test/input-router.test.ts +33 -0
- package/apps/studio/test/inspect-script-syntax.test.ts +53 -0
- package/apps/studio/test/photo-canvas-bundle.test.ts +61 -0
- package/apps/studio/test/photo-edit-api.test.ts +201 -0
- package/apps/studio/test/photo-filters.test.ts +149 -0
- package/apps/studio/test/photo-pipeline.test.ts +106 -0
- package/apps/studio/test/photo-taxonomy.test.ts +96 -0
- package/apps/studio/test/read-annotations.test.ts +164 -0
- package/apps/studio/test/shell-importmap.test.ts +49 -0
- package/apps/studio/test/text-editability-stamp.test.ts +131 -0
- package/apps/studio/test/timeline-parse.test.ts +57 -0
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +5 -3
- package/apps/studio/test/undo-stack.test.ts +33 -0
- package/apps/studio/test/video-asset.test.ts +5 -5
- package/apps/studio/test/video-render-bridge.test.ts +23 -1
- package/apps/studio/text-caret.ts +239 -0
- package/apps/studio/tool-palette.tsx +43 -20
- package/apps/studio/undo-stack.ts +94 -2
- package/apps/studio/use-annotation-resize.tsx +4 -4
- package/apps/studio/use-canvas-media-drop.tsx +38 -1
- package/apps/studio/use-selection-set.tsx +21 -0
- package/apps/studio/video-comp.tsx +48 -7
- package/apps/studio/whats-new.json +53 -0
- package/cli/commands/design.mjs +34 -3
- package/cli/lib/gitignore-block.mjs +1 -0
- package/package.json +10 -10
- package/plugins/design/templates/_shell.html +1 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
// photo-knobs.jsx — the Inspector "Photo" tab body (feature-photo-editor, Task 15).
|
|
2
|
+
//
|
|
3
|
+
// A self-contained editor for a PhotoEdit sidecar. Unlike CssKnobs (whose write
|
|
4
|
+
// path is /_api/edit-css keyed on a source `data-cd-id`), PhotoKnobs writes the
|
|
5
|
+
// non-destructive `assets/<sha8>.photo.json` sidecar through /_api/photo-edit —
|
|
6
|
+
// so it works for BOTH an artboard `<img>` and an annotation ImageStroke (both
|
|
7
|
+
// carry an `assets/<sha8>.<ext>` source), neither of which CssKnobs can express.
|
|
8
|
+
//
|
|
9
|
+
// Runs on the MAIN origin (the studio shell), so inline styles are fine
|
|
10
|
+
// (style-src 'self' 'unsafe-inline'). Renders the controls; the live pixel
|
|
11
|
+
// preview happens in the canvas iframe's <PhotoLayer>, driven by `onEdit`
|
|
12
|
+
// (app.jsx broadcasts the updated edit down to the canvas). Reuses the shell's
|
|
13
|
+
// HSV `ColorPicker` when injected; falls back to a native color input.
|
|
14
|
+
//
|
|
15
|
+
// Sections + ranges mirror photo/schema.ts exactly:
|
|
16
|
+
// Adjustments (brightness/contrast/saturation/exposure −1..1, hue −180..180,
|
|
17
|
+
// sepia/grayscale/invert 0..1) · Duotone · Grain · Pattern · Mask · Background.
|
|
18
|
+
|
|
19
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
20
|
+
|
|
21
|
+
const PATTERN_TYPES = ['dots', 'grid', 'lines', 'diagonal', 'crosshatch'];
|
|
22
|
+
const PATTERN_BLENDS = ['normal', 'multiply', 'screen', 'overlay', 'soft-light'];
|
|
23
|
+
const MASK_PRESETS = ['none', 'vignette', 'radial-reveal', 'edge-fade'];
|
|
24
|
+
|
|
25
|
+
const ADJUSTMENTS = [
|
|
26
|
+
{ key: 'brightness', label: 'Brightness', min: -1, max: 1 },
|
|
27
|
+
{ key: 'contrast', label: 'Contrast', min: -1, max: 1 },
|
|
28
|
+
{ key: 'saturation', label: 'Saturation', min: -1, max: 1 },
|
|
29
|
+
{ key: 'exposure', label: 'Exposure', min: -1, max: 1 },
|
|
30
|
+
{ key: 'hue', label: 'Hue', min: -180, max: 180, step: 1, unit: '°' },
|
|
31
|
+
{ key: 'sepia', label: 'Sepia', min: 0, max: 1 },
|
|
32
|
+
{ key: 'grayscale', label: 'Grayscale', min: 0, max: 1 },
|
|
33
|
+
{ key: 'invert', label: 'Invert', min: 0, max: 1 },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// Deep-clone a small plain edit object (structuredClone is available in the shell).
|
|
37
|
+
const clone = (o) => (o ? JSON.parse(JSON.stringify(o)) : {});
|
|
38
|
+
|
|
39
|
+
/** Prune empty sub-objects so a neutral edit serializes back to `{}`. */
|
|
40
|
+
function prune(edit) {
|
|
41
|
+
const out = {};
|
|
42
|
+
for (const [k, v] of Object.entries(edit)) {
|
|
43
|
+
if (v == null) continue;
|
|
44
|
+
if (typeof v === 'object' && !Array.isArray(v)) {
|
|
45
|
+
const inner = {};
|
|
46
|
+
for (const [ik, iv] of Object.entries(v)) if (iv != null && iv !== '') inner[ik] = iv;
|
|
47
|
+
if (Object.keys(inner).length) out[k] = inner;
|
|
48
|
+
} else {
|
|
49
|
+
out[k] = v;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── small inline-styled primitives ──────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
// Theme tokens — the REAL shell tokens (client/styles/1-tokens.css), defined
|
|
58
|
+
// for both `:root`/`[data-theme="light"]` and `[data-theme="dark"]`. Earlier
|
|
59
|
+
// this used invented `--st-*` names (--st-fg/--st-border/--st-btn-bg/--st-mono)
|
|
60
|
+
// that were never actually declared anywhere in the stylesheet, so every rule
|
|
61
|
+
// silently fell back to its hardcoded dark-mode default — the panel looked
|
|
62
|
+
// fine in dark mode purely by coincidence and was low-contrast/washed out in
|
|
63
|
+
// light mode.
|
|
64
|
+
const S = {
|
|
65
|
+
body: { padding: '10px 12px', fontSize: 12, color: 'var(--fg-0)', overflowY: 'auto' },
|
|
66
|
+
sec: { marginBottom: 14 },
|
|
67
|
+
secHead: {
|
|
68
|
+
display: 'flex',
|
|
69
|
+
alignItems: 'center',
|
|
70
|
+
justifyContent: 'space-between',
|
|
71
|
+
fontSize: 11,
|
|
72
|
+
textTransform: 'uppercase',
|
|
73
|
+
letterSpacing: '.06em',
|
|
74
|
+
color: 'var(--fg-2)',
|
|
75
|
+
margin: '4px 0 8px',
|
|
76
|
+
},
|
|
77
|
+
row: { display: 'flex', alignItems: 'center', gap: 8, margin: '5px 0' },
|
|
78
|
+
label: { flex: '0 0 78px', fontSize: 11, color: 'var(--fg-1)' },
|
|
79
|
+
range: { flex: 1, minWidth: 0 },
|
|
80
|
+
num: {
|
|
81
|
+
flex: '0 0 42px',
|
|
82
|
+
textAlign: 'right',
|
|
83
|
+
fontFamily: 'var(--font-mono, ui-monospace, monospace)',
|
|
84
|
+
fontSize: 11,
|
|
85
|
+
color: 'var(--fg-2)',
|
|
86
|
+
},
|
|
87
|
+
btn: {
|
|
88
|
+
font: 'inherit',
|
|
89
|
+
fontSize: 11,
|
|
90
|
+
padding: '5px 10px',
|
|
91
|
+
borderRadius: 6,
|
|
92
|
+
border: '1px solid var(--border-default)',
|
|
93
|
+
background: 'var(--bg-3)',
|
|
94
|
+
color: 'var(--fg-0)',
|
|
95
|
+
cursor: 'pointer',
|
|
96
|
+
},
|
|
97
|
+
reset: {
|
|
98
|
+
font: 'inherit',
|
|
99
|
+
fontSize: 10,
|
|
100
|
+
padding: '1px 6px',
|
|
101
|
+
borderRadius: 4,
|
|
102
|
+
border: '1px solid transparent',
|
|
103
|
+
background: 'transparent',
|
|
104
|
+
color: 'var(--fg-2)',
|
|
105
|
+
cursor: 'pointer',
|
|
106
|
+
},
|
|
107
|
+
select: {
|
|
108
|
+
font: 'inherit',
|
|
109
|
+
fontSize: 11,
|
|
110
|
+
padding: '3px 6px',
|
|
111
|
+
borderRadius: 5,
|
|
112
|
+
border: '1px solid var(--border-default)',
|
|
113
|
+
background: 'var(--bg-3)',
|
|
114
|
+
color: 'var(--fg-0)',
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
function Section({ title, right, children }) {
|
|
119
|
+
return (
|
|
120
|
+
<section style={S.sec}>
|
|
121
|
+
<div style={S.secHead}>
|
|
122
|
+
<span>{title}</span>
|
|
123
|
+
{right}
|
|
124
|
+
</div>
|
|
125
|
+
{children}
|
|
126
|
+
</section>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function Slider({ label, value, min, max, step = 0.01, unit = '', onChange, onCommit }) {
|
|
131
|
+
const v = value ?? (min < 0 ? 0 : min);
|
|
132
|
+
return (
|
|
133
|
+
<div style={S.row}>
|
|
134
|
+
<span style={S.label}>{label}</span>
|
|
135
|
+
<input
|
|
136
|
+
type="range"
|
|
137
|
+
style={S.range}
|
|
138
|
+
min={min}
|
|
139
|
+
max={max}
|
|
140
|
+
step={step}
|
|
141
|
+
value={v}
|
|
142
|
+
onChange={(e) => onChange(Number(e.target.value))}
|
|
143
|
+
onPointerUp={onCommit}
|
|
144
|
+
onKeyUp={onCommit}
|
|
145
|
+
aria-label={label}
|
|
146
|
+
/>
|
|
147
|
+
<span style={S.num}>
|
|
148
|
+
{step >= 1 ? Math.round(v) : v.toFixed(2)}
|
|
149
|
+
{unit}
|
|
150
|
+
</span>
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function Toggle({ checked, onChange, label }) {
|
|
156
|
+
return (
|
|
157
|
+
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, cursor: 'pointer' }}>
|
|
158
|
+
<input type="checkbox" checked={!!checked} onChange={(e) => onChange(e.target.checked)} />
|
|
159
|
+
{label}
|
|
160
|
+
</label>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function ColorSwatch({ value, fallback, ColorPicker, onApply }) {
|
|
165
|
+
const hex = value || fallback;
|
|
166
|
+
if (ColorPicker) return <ColorPicker seed={hex} onApply={onApply} />;
|
|
167
|
+
return (
|
|
168
|
+
<input
|
|
169
|
+
type="color"
|
|
170
|
+
value={hex}
|
|
171
|
+
onChange={(e) => onApply(e.target.value)}
|
|
172
|
+
style={{ width: 28, height: 20, padding: 0, border: 'none', background: 'none' }}
|
|
173
|
+
aria-label="color"
|
|
174
|
+
/>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── the panel ────────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* @param {object} p
|
|
182
|
+
* @param {string} p.asset `assets/<sha8>.<ext>` source path.
|
|
183
|
+
* @param {object} [p.initialEdit] Seed PhotoEdit (else fetched on mount).
|
|
184
|
+
* @param {Function} [p.ColorPicker] Injected HSV picker component (optional).
|
|
185
|
+
* @param {Function} [p.onEdit] (edit) => void — live-preview broadcast.
|
|
186
|
+
* @param {Function} [p.onRemoveBackground] async (asset) => { maskAsset } | null.
|
|
187
|
+
* @param {Function} [p.onRecordEdit] (before, after) => void — undo integration.
|
|
188
|
+
*/
|
|
189
|
+
export function PhotoKnobs({ asset, initialEdit, ColorPicker, onEdit, onRemoveBackground, onRecordEdit }) {
|
|
190
|
+
const [edit, setEditState] = useState(() => clone(initialEdit));
|
|
191
|
+
const [loading, setLoading] = useState(!initialEdit);
|
|
192
|
+
const [saveState, setSaveState] = useState('idle'); // idle | saving | saved | error
|
|
193
|
+
const [bgBusy, setBgBusy] = useState(false);
|
|
194
|
+
const putTimer = useRef(null);
|
|
195
|
+
const editRef = useRef(edit);
|
|
196
|
+
editRef.current = edit;
|
|
197
|
+
|
|
198
|
+
// Hydrate from the sidecar unless seeded.
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
if (initialEdit || !asset) return;
|
|
201
|
+
let dead = false;
|
|
202
|
+
setLoading(true);
|
|
203
|
+
fetch(`/_api/photo-edit?asset=${encodeURIComponent(asset)}`)
|
|
204
|
+
.then((r) => (r.ok ? r.json() : {}))
|
|
205
|
+
.then((j) => {
|
|
206
|
+
if (dead) return;
|
|
207
|
+
setEditState(j && typeof j === 'object' ? j : {});
|
|
208
|
+
setLoading(false);
|
|
209
|
+
})
|
|
210
|
+
.catch(() => !dead && setLoading(false));
|
|
211
|
+
return () => {
|
|
212
|
+
dead = true;
|
|
213
|
+
};
|
|
214
|
+
}, [asset, initialEdit]);
|
|
215
|
+
|
|
216
|
+
const put = useCallback(
|
|
217
|
+
(next) => {
|
|
218
|
+
clearTimeout(putTimer.current);
|
|
219
|
+
putTimer.current = setTimeout(async () => {
|
|
220
|
+
setSaveState('saving');
|
|
221
|
+
try {
|
|
222
|
+
const res = await fetch(`/_api/photo-edit?asset=${encodeURIComponent(asset)}`, {
|
|
223
|
+
method: 'PUT',
|
|
224
|
+
headers: { 'Content-Type': 'application/json' },
|
|
225
|
+
body: JSON.stringify(prune(next)),
|
|
226
|
+
});
|
|
227
|
+
setSaveState(res.ok ? 'saved' : 'error');
|
|
228
|
+
} catch {
|
|
229
|
+
setSaveState('error');
|
|
230
|
+
}
|
|
231
|
+
}, 160);
|
|
232
|
+
},
|
|
233
|
+
[asset]
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
// Apply a mutation, push the optimistic edit to the live preview, persist
|
|
237
|
+
// (debounced), and record undo (before → after).
|
|
238
|
+
const mutate = useCallback(
|
|
239
|
+
(fn, { commit = false } = {}) => {
|
|
240
|
+
const before = editRef.current;
|
|
241
|
+
const next = clone(before);
|
|
242
|
+
fn(next);
|
|
243
|
+
setEditState(next);
|
|
244
|
+
onEdit?.(next);
|
|
245
|
+
put(next);
|
|
246
|
+
if (commit) onRecordEdit?.(before, next);
|
|
247
|
+
},
|
|
248
|
+
[onEdit, put, onRecordEdit]
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
const setAdj = (key, value, commit) =>
|
|
252
|
+
mutate((e) => {
|
|
253
|
+
e.adjustments = e.adjustments || {};
|
|
254
|
+
if (value === 0 || value == null) delete e.adjustments[key];
|
|
255
|
+
else e.adjustments[key] = value;
|
|
256
|
+
if (Object.keys(e.adjustments).length === 0) delete e.adjustments;
|
|
257
|
+
}, { commit });
|
|
258
|
+
|
|
259
|
+
const setSection = (section, patch, commit) =>
|
|
260
|
+
mutate((e) => {
|
|
261
|
+
e[section] = { ...(e[section] || {}), ...patch };
|
|
262
|
+
}, { commit });
|
|
263
|
+
|
|
264
|
+
const resetAdjustments = () => mutate((e) => delete e.adjustments, { commit: true });
|
|
265
|
+
|
|
266
|
+
if (loading) return <div style={{ ...S.body, opacity: 0.6 }}>Loading photo edit…</div>;
|
|
267
|
+
|
|
268
|
+
const adj = edit.adjustments || {};
|
|
269
|
+
const duo = edit.duotone || {};
|
|
270
|
+
const grain = edit.grain || {};
|
|
271
|
+
const pat = edit.pattern || {};
|
|
272
|
+
const mask = edit.mask || {};
|
|
273
|
+
const bg = edit.backgroundRemoved || {};
|
|
274
|
+
|
|
275
|
+
return (
|
|
276
|
+
<div style={S.body} data-testid="photo-knobs">
|
|
277
|
+
<div style={{ ...S.secHead, marginTop: 0, color: 'var(--fg-3)', textTransform: 'none', letterSpacing: 0 }}>
|
|
278
|
+
<span style={{ fontFamily: 'var(--font-mono, monospace)', fontSize: 10 }}>{asset}</span>
|
|
279
|
+
<span style={{ fontSize: 10, color: saveState === 'error' ? 'var(--status-error)' : undefined }}>
|
|
280
|
+
{saveState === 'saving' ? 'saving…' : saveState === 'error' ? '⚠ save failed' : saveState === 'saved' ? 'saved' : ''}
|
|
281
|
+
</span>
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
<Section
|
|
285
|
+
title="Adjustments"
|
|
286
|
+
right={
|
|
287
|
+
<button type="button" style={S.reset} onClick={resetAdjustments} title="Reset adjustments">
|
|
288
|
+
reset
|
|
289
|
+
</button>
|
|
290
|
+
}
|
|
291
|
+
>
|
|
292
|
+
{ADJUSTMENTS.map((a) => (
|
|
293
|
+
<Slider
|
|
294
|
+
key={a.key}
|
|
295
|
+
label={a.label}
|
|
296
|
+
value={adj[a.key]}
|
|
297
|
+
min={a.min}
|
|
298
|
+
max={a.max}
|
|
299
|
+
step={a.step ?? 0.01}
|
|
300
|
+
unit={a.unit ?? ''}
|
|
301
|
+
onChange={(v) => setAdj(a.key, v)}
|
|
302
|
+
onCommit={() => setAdj(a.key, adj[a.key] ?? 0, true)}
|
|
303
|
+
/>
|
|
304
|
+
))}
|
|
305
|
+
</Section>
|
|
306
|
+
|
|
307
|
+
<Section
|
|
308
|
+
title="Duotone"
|
|
309
|
+
right={<Toggle checked={duo.enabled} onChange={(v) => setSection('duotone', { enabled: v }, true)} label="on" />}
|
|
310
|
+
>
|
|
311
|
+
{duo.enabled && (
|
|
312
|
+
<>
|
|
313
|
+
<div style={S.row}>
|
|
314
|
+
<span style={S.label}>Shadow</span>
|
|
315
|
+
<ColorSwatch value={duo.colorA} fallback="#111111" ColorPicker={ColorPicker} onApply={(hex) => setSection('duotone', { colorA: hex }, true)} />
|
|
316
|
+
<span style={S.label}>Highlight</span>
|
|
317
|
+
<ColorSwatch value={duo.colorB} fallback="#ffffff" ColorPicker={ColorPicker} onApply={(hex) => setSection('duotone', { colorB: hex }, true)} />
|
|
318
|
+
</div>
|
|
319
|
+
<Slider label="Intensity" value={duo.intensity ?? 1} min={0} max={1} onChange={(v) => setSection('duotone', { intensity: v })} onCommit={() => setSection('duotone', { intensity: duo.intensity ?? 1 }, true)} />
|
|
320
|
+
</>
|
|
321
|
+
)}
|
|
322
|
+
</Section>
|
|
323
|
+
|
|
324
|
+
<Section
|
|
325
|
+
title="Grain"
|
|
326
|
+
right={<Toggle checked={grain.enabled} onChange={(v) => setSection('grain', { enabled: v }, true)} label="on" />}
|
|
327
|
+
>
|
|
328
|
+
{grain.enabled && (
|
|
329
|
+
<>
|
|
330
|
+
<Slider label="Amount" value={grain.amount ?? 0.4} min={0} max={1} onChange={(v) => setSection('grain', { amount: v })} onCommit={() => setSection('grain', { amount: grain.amount ?? 0.4 }, true)} />
|
|
331
|
+
<Slider label="Size" value={grain.size ?? 1} min={1} max={8} step={0.5} onChange={(v) => setSection('grain', { size: v })} onCommit={() => setSection('grain', { size: grain.size ?? 1 }, true)} />
|
|
332
|
+
</>
|
|
333
|
+
)}
|
|
334
|
+
</Section>
|
|
335
|
+
|
|
336
|
+
<Section
|
|
337
|
+
title="Pattern"
|
|
338
|
+
right={<Toggle checked={pat.enabled} onChange={(v) => setSection('pattern', { enabled: v }, true)} label="on" />}
|
|
339
|
+
>
|
|
340
|
+
{pat.enabled && (
|
|
341
|
+
<>
|
|
342
|
+
<div style={S.row}>
|
|
343
|
+
<span style={S.label}>Type</span>
|
|
344
|
+
<select style={S.select} value={pat.type || 'dots'} onChange={(e) => setSection('pattern', { type: e.target.value }, true)}>
|
|
345
|
+
{PATTERN_TYPES.map((t) => (
|
|
346
|
+
<option key={t} value={t}>
|
|
347
|
+
{t}
|
|
348
|
+
</option>
|
|
349
|
+
))}
|
|
350
|
+
</select>
|
|
351
|
+
<span style={S.label}>Blend</span>
|
|
352
|
+
<select style={S.select} value={pat.blend || 'normal'} onChange={(e) => setSection('pattern', { blend: e.target.value }, true)}>
|
|
353
|
+
{PATTERN_BLENDS.map((b) => (
|
|
354
|
+
<option key={b} value={b}>
|
|
355
|
+
{b}
|
|
356
|
+
</option>
|
|
357
|
+
))}
|
|
358
|
+
</select>
|
|
359
|
+
</div>
|
|
360
|
+
<div style={S.row}>
|
|
361
|
+
<span style={S.label}>Color</span>
|
|
362
|
+
<ColorSwatch value={pat.color} fallback="#ffffff" ColorPicker={ColorPicker} onApply={(hex) => setSection('pattern', { color: hex }, true)} />
|
|
363
|
+
<span style={{ ...S.num, flex: 1, textAlign: 'left', color: 'var(--fg-3)' }}>tip: dark + multiply</span>
|
|
364
|
+
</div>
|
|
365
|
+
<Slider label="Scale" value={pat.scale ?? 1} min={0.25} max={4} step={0.25} onChange={(v) => setSection('pattern', { scale: v })} onCommit={() => setSection('pattern', { scale: pat.scale ?? 1 }, true)} />
|
|
366
|
+
<Slider label="Opacity" value={pat.opacity ?? 0.5} min={0} max={1} onChange={(v) => setSection('pattern', { opacity: v })} onCommit={() => setSection('pattern', { opacity: pat.opacity ?? 0.5 }, true)} />
|
|
367
|
+
</>
|
|
368
|
+
)}
|
|
369
|
+
</Section>
|
|
370
|
+
|
|
371
|
+
<Section title="Mask">
|
|
372
|
+
<div style={S.row}>
|
|
373
|
+
<span style={S.label}>Preset</span>
|
|
374
|
+
<select style={S.select} value={mask.preset || 'none'} onChange={(e) => setSection('mask', { preset: e.target.value }, true)}>
|
|
375
|
+
{MASK_PRESETS.map((m) => (
|
|
376
|
+
<option key={m} value={m}>
|
|
377
|
+
{m}
|
|
378
|
+
</option>
|
|
379
|
+
))}
|
|
380
|
+
</select>
|
|
381
|
+
</div>
|
|
382
|
+
{mask.preset && mask.preset !== 'none' && (
|
|
383
|
+
<Slider label="Strength" value={mask.strength ?? 0.6} min={0} max={1} onChange={(v) => setSection('mask', { strength: v })} onCommit={() => setSection('mask', { strength: mask.strength ?? 0.6 }, true)} />
|
|
384
|
+
)}
|
|
385
|
+
</Section>
|
|
386
|
+
|
|
387
|
+
<Section title="Background">
|
|
388
|
+
<div style={{ ...S.row, gap: 10 }}>
|
|
389
|
+
{bg.maskAsset ? (
|
|
390
|
+
<>
|
|
391
|
+
<Toggle checked={bg.enabled} onChange={(v) => setSection('backgroundRemoved', { enabled: v }, true)} label="applied" />
|
|
392
|
+
<button
|
|
393
|
+
type="button"
|
|
394
|
+
style={{ ...S.reset, opacity: bgBusy ? 0.6 : 1 }}
|
|
395
|
+
disabled={bgBusy || !onRemoveBackground}
|
|
396
|
+
title="Run background removal again"
|
|
397
|
+
onClick={async () => {
|
|
398
|
+
if (!onRemoveBackground) return;
|
|
399
|
+
setBgBusy(true);
|
|
400
|
+
try {
|
|
401
|
+
const res = await onRemoveBackground(asset);
|
|
402
|
+
if (res?.maskAsset) setSection('backgroundRemoved', { enabled: true, maskAsset: res.maskAsset }, true);
|
|
403
|
+
} finally {
|
|
404
|
+
setBgBusy(false);
|
|
405
|
+
}
|
|
406
|
+
}}
|
|
407
|
+
>
|
|
408
|
+
{bgBusy ? 'removing…' : 'redo'}
|
|
409
|
+
</button>
|
|
410
|
+
</>
|
|
411
|
+
) : (
|
|
412
|
+
<button
|
|
413
|
+
type="button"
|
|
414
|
+
style={{ ...S.btn, opacity: bgBusy ? 0.6 : 1 }}
|
|
415
|
+
disabled={bgBusy || !onRemoveBackground}
|
|
416
|
+
data-testid="photo-remove-bg"
|
|
417
|
+
onClick={async () => {
|
|
418
|
+
if (!onRemoveBackground) return;
|
|
419
|
+
setBgBusy(true);
|
|
420
|
+
try {
|
|
421
|
+
const res = await onRemoveBackground(asset);
|
|
422
|
+
if (res?.maskAsset) setSection('backgroundRemoved', { enabled: true, maskAsset: res.maskAsset }, true);
|
|
423
|
+
} finally {
|
|
424
|
+
setBgBusy(false);
|
|
425
|
+
}
|
|
426
|
+
}}
|
|
427
|
+
>
|
|
428
|
+
{bgBusy ? 'Removing…' : '✦ Remove Background'}
|
|
429
|
+
</button>
|
|
430
|
+
)}
|
|
431
|
+
</div>
|
|
432
|
+
</Section>
|
|
433
|
+
</div>
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export default PhotoKnobs;
|
|
@@ -513,6 +513,28 @@
|
|
|
513
513
|
.st-ap-cell:hover { border-color: var(--accent, var(--border-strong, var(--border-default))); background: var(--bg-3); }
|
|
514
514
|
.st-ap-thumb { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: var(--radius-xs); background: var(--bg-4, var(--bg-3)); display: block; }
|
|
515
515
|
.st-ap-name { font-family: var(--font-mono); font-size: 10px; color: var(--fg-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
516
|
+
|
|
517
|
+
/* ─── StickerPicker (feature-whiteboard-annotation-improvements Phase 4) ──── */
|
|
518
|
+
.st-sticker-picker { width: 640px; }
|
|
519
|
+
.st-sticker-picker .input {
|
|
520
|
+
font-family: var(--font-body); font-size: var(--type-sm); color: var(--fg-0);
|
|
521
|
+
background: var(--bg-3); border: 1px solid var(--border-default); border-radius: var(--radius-sm);
|
|
522
|
+
padding: var(--space-3) var(--space-4); width: 100%;
|
|
523
|
+
transition: border-color var(--dur-soft) var(--ease-out), box-shadow var(--dur-soft) var(--ease-out);
|
|
524
|
+
}
|
|
525
|
+
.st-sticker-picker .input::placeholder { color: var(--fg-3); }
|
|
526
|
+
.st-sticker-picker .input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-tint); }
|
|
527
|
+
.st-sp-body { max-height: 56vh; overflow-y: auto; padding: 2px; display: flex; flex-direction: column; gap: var(--space-5); }
|
|
528
|
+
.st-sp-pack-hd { display: flex; align-items: baseline; gap: var(--space-2); padding: 0 2px; }
|
|
529
|
+
.st-sp-pack-name { font-family: var(--font-mono); font-size: var(--type-xs); font-weight: 600; color: var(--fg-1); text-transform: uppercase; letter-spacing: var(--tracking-wide); }
|
|
530
|
+
.st-sp-pack-author { font-family: var(--font-mono); font-size: var(--type-xs); color: var(--fg-3); }
|
|
531
|
+
.st-sp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(76px, 1fr)); gap: var(--space-2); }
|
|
532
|
+
.st-sp-cell { appearance: none; cursor: pointer; display: flex; flex-direction: column; align-items: center; padding: 6px; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); background: var(--bg-2); transition: border-color var(--dur-soft) var(--ease-out), background var(--dur-soft) var(--ease-out); }
|
|
533
|
+
.st-sp-cell:hover { border-color: var(--accent, var(--border-strong, var(--border-default))); background: var(--bg-3); }
|
|
534
|
+
.st-sp-thumb { width: 100%; aspect-ratio: 1; object-fit: contain; display: block; }
|
|
535
|
+
.st-sp-empty { color: var(--fg-2); font-family: var(--font-mono); font-size: var(--type-xs); padding: var(--space-6) 0; text-align: center; }
|
|
536
|
+
.st-sp-ft { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-4); padding: var(--space-3) var(--space-5); border-top: 1px solid var(--border-subtle); font-family: var(--font-mono); font-size: 10px; color: var(--fg-3); }
|
|
537
|
+
.st-sp-ft a { color: var(--fg-2); }
|
|
516
538
|
.st-cp-replace { width: 100%; justify-content: center; }
|
|
517
539
|
/* Media "Replace…" — a full-width button, NOT inside the 3-col .st-cp-row grid
|
|
518
540
|
(which would drop it into the 10px provenance-dot column and clip it). */
|
|
@@ -1887,6 +1909,25 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
|
|
|
1887
1909
|
box-shadow: 0 -10px 18px -12px color-mix(in oklab, var(--bg-0) 80%, transparent);
|
|
1888
1910
|
}
|
|
1889
1911
|
.rb-dock { position: relative; }
|
|
1912
|
+
/* Proactive "remote has updates" nudge — a full-width primary button above the dock
|
|
1913
|
+
trigger, shown when the tracking remote is ahead (status.remoteAhead). */
|
|
1914
|
+
.rb-getlatest { width: 100%; justify-content: flex-start; gap: var(--space-2); animation: rb-getlatest-in var(--dur-soft, 160ms) var(--ease-out, ease-out); }
|
|
1915
|
+
.rb-getlatest-tx { flex: 0 0 auto; }
|
|
1916
|
+
.rb-getlatest-sub { margin-left: auto; font-weight: 500; opacity: 0.9; font-size: var(--type-xs); white-space: nowrap; }
|
|
1917
|
+
@keyframes rb-getlatest-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
|
|
1918
|
+
@media (prefers-reduced-motion: reduce) { .rb-getlatest, .rb-resume { animation: none; } }
|
|
1919
|
+
/* "Continue on <draft>" resume nudge — a subtle secondary row (not the primary blue),
|
|
1920
|
+
shown above the dock trigger when you're on the Shared version but have a draft. */
|
|
1921
|
+
.rb-resume { display: flex; align-items: center; gap: var(--space-1); width: 100%; animation: rb-getlatest-in var(--dur-soft, 160ms) var(--ease-out, ease-out); }
|
|
1922
|
+
.rb-resume-go { flex: 1 1 auto; display: flex; align-items: center; gap: var(--space-2); min-width: 0; text-align: left; padding: var(--space-2) var(--space-3); border: 1px solid var(--border-default); border-radius: var(--radius-md); background: var(--bg-2); color: var(--fg-1); cursor: pointer; font-size: var(--type-sm); transition: border-color var(--dur-soft) var(--ease-out), background var(--dur-soft) var(--ease-out); }
|
|
1923
|
+
.rb-resume-go:hover { border-color: var(--border-strong); background: var(--bg-3); }
|
|
1924
|
+
.rb-resume-go:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
1925
|
+
.rb-resume-icon { color: var(--status-warn); flex: 0 0 auto; display: grid; place-items: center; }
|
|
1926
|
+
.rb-resume-tx { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1927
|
+
.rb-resume-arrow { color: var(--fg-3); flex: 0 0 auto; }
|
|
1928
|
+
.rb-resume-x { flex: 0 0 auto; display: grid; place-items: center; width: 28px; height: 28px; border: none; border-radius: var(--radius-md); background: transparent; color: var(--fg-3); cursor: pointer; }
|
|
1929
|
+
.rb-resume-x:hover { background: var(--bg-3); color: var(--fg-1); }
|
|
1930
|
+
.rb-resume-x:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
1890
1931
|
/* The compact one-line trigger — "📁 <project> · ◐ <version> ⌃" */
|
|
1891
1932
|
.rb-trigger {
|
|
1892
1933
|
display: flex; align-items: center; gap: var(--space-2);
|
|
@@ -1983,6 +2024,7 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
|
|
|
1983
2024
|
.rb-sheet-body { margin: 0; color: var(--fg-1); font-size: var(--type-md); line-height: var(--lh-md); }
|
|
1984
2025
|
.rb-sheet-body b { color: var(--fg-0); font-weight: 600; }
|
|
1985
2026
|
.rb-sheet-meta { margin: 0; color: var(--fg-2); font-size: var(--type-sm); line-height: var(--lh-md); }
|
|
2027
|
+
.rb-pr-url { font-family: var(--font-mono, ui-monospace, monospace); font-size: var(--type-xs); word-break: break-all; user-select: all; opacity: 0.8; }
|
|
1986
2028
|
.rb-sheet-actions { display: flex; gap: var(--space-2); justify-content: flex-end; margin-top: var(--space-2); }
|
|
1987
2029
|
@media (prefers-reduced-motion: reduce) {
|
|
1988
2030
|
.rb-spin { animation-duration: 1ms; }
|
|
@@ -46,6 +46,14 @@ export interface EditSourcePayload {
|
|
|
46
46
|
before: string | null;
|
|
47
47
|
/** Value after the edit. `null` = the edit removed the prop/attr (the reset path). */
|
|
48
48
|
after: string | null;
|
|
49
|
+
/**
|
|
50
|
+
* `op:'text'` only — which rendered instance this edit targeted (index among
|
|
51
|
+
* same-cd-id DOM nodes). Needed to re-apply a `{variable}` text edit through
|
|
52
|
+
* undo/redo: the engine traces the string back to the right `.map()` item or
|
|
53
|
+
* component-prop usage, and that resolution needs the occurrence. Undefined
|
|
54
|
+
* for literal text (the engine ignores it there) and for css/attr.
|
|
55
|
+
*/
|
|
56
|
+
occurrence?: number;
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
/**
|
|
@@ -60,6 +68,14 @@ export type EditSourceApplyFn = (apply: {
|
|
|
60
68
|
id: string;
|
|
61
69
|
key: string;
|
|
62
70
|
value: string | null;
|
|
71
|
+
/**
|
|
72
|
+
* `op:'text'` re-application only — the value CURRENTLY on disk (the side we
|
|
73
|
+
* replace FROM: `before` on redo, `after` on undo), so a `{variable}` edit
|
|
74
|
+
* can be traced to the right source string. Plus the occurrence. Ignored by
|
|
75
|
+
* css/attr and by literal-text edits.
|
|
76
|
+
*/
|
|
77
|
+
from?: string | null;
|
|
78
|
+
occurrence?: number;
|
|
63
79
|
}) => void | Promise<void>;
|
|
64
80
|
|
|
65
81
|
export const EDIT_SOURCE_KIND = 'edit-source';
|
|
@@ -72,16 +88,27 @@ export interface EditSourceCommandInit {
|
|
|
72
88
|
|
|
73
89
|
export function createEditSourceCommand(init: EditSourceCommandInit): EditCommand {
|
|
74
90
|
const { payload, applyFn } = init;
|
|
75
|
-
|
|
76
|
-
|
|
91
|
+
// `from` = the value on disk BEFORE this re-application (what a `{variable}`
|
|
92
|
+
// text resolver matches against). do() writes `after` over `before`; undo()
|
|
93
|
+
// writes `before` over `after`.
|
|
94
|
+
const apply = (value: string | null, from: string | null) =>
|
|
95
|
+
applyFn({
|
|
96
|
+
op: payload.op,
|
|
97
|
+
canvas: payload.canvas,
|
|
98
|
+
id: payload.id,
|
|
99
|
+
key: payload.key,
|
|
100
|
+
value,
|
|
101
|
+
from,
|
|
102
|
+
occurrence: payload.occurrence,
|
|
103
|
+
});
|
|
77
104
|
return {
|
|
78
105
|
kind: EDIT_SOURCE_KIND,
|
|
79
106
|
label: init.label ?? defaultLabel(payload),
|
|
80
107
|
async do() {
|
|
81
|
-
await apply(payload.after);
|
|
108
|
+
await apply(payload.after, payload.before);
|
|
82
109
|
},
|
|
83
110
|
async undo() {
|
|
84
|
-
await apply(payload.before);
|
|
111
|
+
await apply(payload.before, payload.after);
|
|
85
112
|
},
|
|
86
113
|
};
|
|
87
114
|
}
|
|
@@ -54,7 +54,12 @@ export interface MenuItem {
|
|
|
54
54
|
/** Right-aligned shortcut hint (e.g. `⌘C`, `⌫`). */
|
|
55
55
|
shortcut?: string;
|
|
56
56
|
destructive?: boolean;
|
|
57
|
-
|
|
57
|
+
/**
|
|
58
|
+
* A FUNCTION form is resolved per-click against `target` — same rationale as
|
|
59
|
+
* the function-form `submenu` below (e.g. "Open Timeline" only enabled when
|
|
60
|
+
* THIS right-clicked artboard actually contains a video sequence).
|
|
61
|
+
*/
|
|
62
|
+
disabled?: boolean | ((target: ContextTarget) => boolean);
|
|
58
63
|
/**
|
|
59
64
|
* Optional nested flyout (e.g. `Theme ▸ DS default / Light / Dark / Follow
|
|
60
65
|
* chrome`). When present the row opens a submenu on hover / ArrowRight /
|
|
@@ -70,6 +75,13 @@ export interface MenuItem {
|
|
|
70
75
|
submenu?: MenuItem[] | ((target: ContextTarget) => MenuItem[]);
|
|
71
76
|
/** Hover/title hint shown when the item is `disabled` (a11y affordance). */
|
|
72
77
|
disabledHint?: string;
|
|
78
|
+
/**
|
|
79
|
+
* Fully REMOVE the item for a target (vs. `disabled`, which greys it in place).
|
|
80
|
+
* Resolved per-click. Use when an entry is meaningless — not merely
|
|
81
|
+
* unavailable — for the right-clicked element (e.g. "Edit Photo…" only makes
|
|
82
|
+
* sense on an `<img>`, so it shouldn't appear at all on a `<div>`).
|
|
83
|
+
*/
|
|
84
|
+
hidden?: boolean | ((target: ContextTarget) => boolean);
|
|
73
85
|
onSelect: (target: ContextTarget) => void;
|
|
74
86
|
}
|
|
75
87
|
|
|
@@ -371,9 +383,19 @@ export function ContextMenuView({
|
|
|
371
383
|
};
|
|
372
384
|
}, [target.clientX, target.clientY, onClose, pos.x, pos.y]);
|
|
373
385
|
|
|
386
|
+
// Resolve per-target `hidden` and drop now-empty sections (so a section that
|
|
387
|
+
// held only a hidden item doesn't leave a dangling separator).
|
|
388
|
+
const visibleSections = sections
|
|
389
|
+
.map((section) =>
|
|
390
|
+
section.filter((item) => {
|
|
391
|
+
const h = typeof item.hidden === 'function' ? item.hidden(target) : item.hidden;
|
|
392
|
+
return !h;
|
|
393
|
+
})
|
|
394
|
+
)
|
|
395
|
+
.filter((section) => section.length > 0);
|
|
374
396
|
return (
|
|
375
397
|
<div ref={ref} className="dc-context-menu" role="menu" style={{ left: pos.x, top: pos.y }}>
|
|
376
|
-
{
|
|
398
|
+
{visibleSections.map((section, si) => {
|
|
377
399
|
const sectionKey = section.map((i) => i.id).join('|') || `s${si}`;
|
|
378
400
|
return (
|
|
379
401
|
<div key={sectionKey} role="group">
|
|
@@ -411,17 +433,20 @@ function MenuItemRow({
|
|
|
411
433
|
// Resolve a function-form submenu against THIS click's target (e.g.
|
|
412
434
|
// "Select layer" computing candidates from `target.clientX/clientY`).
|
|
413
435
|
const submenuItems = typeof item.submenu === 'function' ? item.submenu(target) : item.submenu;
|
|
436
|
+
// Same per-click resolution for a function-form `disabled` (e.g. "Open
|
|
437
|
+
// Timeline" only enabled on an artboard that actually contains video).
|
|
438
|
+
const disabled = typeof item.disabled === 'function' ? item.disabled(target) : item.disabled;
|
|
414
439
|
|
|
415
440
|
if (!submenuItems || submenuItems.length === 0) {
|
|
416
441
|
return (
|
|
417
442
|
<button
|
|
418
443
|
type="button"
|
|
419
444
|
role="menuitem"
|
|
420
|
-
disabled={
|
|
421
|
-
title={
|
|
445
|
+
disabled={disabled}
|
|
446
|
+
title={disabled ? item.disabledHint : undefined}
|
|
422
447
|
className={`dc-menu-item${item.destructive ? ' is-destructive' : ''}`}
|
|
423
448
|
onClick={() => {
|
|
424
|
-
if (
|
|
449
|
+
if (disabled) return;
|
|
425
450
|
item.onSelect(target);
|
|
426
451
|
onClose();
|
|
427
452
|
}}
|
|
@@ -464,7 +489,7 @@ function MenuItemRow({
|
|
|
464
489
|
role="menuitem"
|
|
465
490
|
aria-haspopup="menu"
|
|
466
491
|
aria-expanded={subOpen}
|
|
467
|
-
disabled={
|
|
492
|
+
disabled={disabled}
|
|
468
493
|
className="dc-menu-item"
|
|
469
494
|
onClick={() => (subOpen ? setSubOpen(false) : open())}
|
|
470
495
|
onKeyDown={(e) => {
|