konpeki 0.2.0 → 0.3.1
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/AGENTS.md +1 -1
- package/CONTRIBUTING.md +36 -0
- package/README.md +81 -28
- package/SECURITY.md +22 -0
- package/SETUP.md +128 -51
- package/composition/README.md +16 -3
- package/composition/compile.ts +1 -1
- package/composition/schema.json +9 -0
- package/composition/schema.ts +3 -1
- package/composition/types.ts +3 -1
- package/design/themes/README.md +25 -7
- package/design/themes/index.ts +15 -4
- package/docs/development.md +79 -11
- package/docs/workflow.md +11 -4
- package/index.html +17 -1
- package/package.json +10 -2
- package/plugin.json +22 -0
- package/public/og.png +0 -0
- package/runtime/konpeki.mjs +41 -13
- package/skills/konpeki/SKILL.md +186 -0
- package/skills/konpeki/assets/blank.json +23 -0
- package/skills/konpeki/scripts/ensure-runtime.mjs +69 -0
- package/skills/konpeki/scripts/prepare-document.mjs +40 -0
- package/slides/README.md +35 -132
- package/slides/drawing-references.md +32 -0
- package/slides/github-cover/PROMPT.md +21 -0
- package/slides/github-cover/README.md +21 -0
- package/slides/github-cover/author.ts +67 -0
- package/slides/github-cover/composition.json +595 -0
- package/slides/introducing-konpeki/PROMPT.md +25 -14
- package/slides/introducing-konpeki/README.md +32 -42
- package/slides/introducing-konpeki/SOURCE.md +13 -14
- package/slides/introducing-konpeki/author.ts +44 -42
- package/slides/introducing-konpeki/composition.json +135 -135
- package/src/app/App.tsx +149 -56
- package/src/components/Canvas.tsx +9 -3
- package/src/components/InspectorPanel.tsx +104 -67
- package/src/components/RevisionNotes.tsx +6 -7
- package/src/components/RightPanel.tsx +42 -10
- package/src/components/VectorOverflowWarning.tsx +35 -8
- package/src/components/WorkspaceChrome.tsx +125 -10
- package/src/components/ui.tsx +95 -1
- package/src/lib/storage.ts +38 -10
- package/src/lib/use-file-session.ts +60 -38
- package/src/main.tsx +3 -0
- package/src/styles/base.css +7 -0
- package/src/styles/chrome.css +188 -25
- package/src/styles/feedback.css +101 -49
- package/src/styles/left-panel.css +27 -6
- package/src/styles/right-panel.css +114 -30
- package/src/styles/shell.css +96 -33
- package/.agents/skills/authoring-visuals/SKILL.md +0 -93
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
themeIds,
|
|
3
|
+
typographyIds,
|
|
3
4
|
type CompositionComponent,
|
|
4
5
|
type CompositionSlide,
|
|
5
6
|
type ChartTemplate,
|
|
@@ -7,6 +8,7 @@ import {
|
|
|
7
8
|
type Rect,
|
|
8
9
|
type TextColor,
|
|
9
10
|
type ThemeId,
|
|
11
|
+
type TypographyId,
|
|
10
12
|
type VectorElement,
|
|
11
13
|
} from "../../composition/types.ts";
|
|
12
14
|
import {
|
|
@@ -30,11 +32,11 @@ import {
|
|
|
30
32
|
type Draft,
|
|
31
33
|
} from "../lib/model.ts";
|
|
32
34
|
import { composerPalette, themeLabel } from "../lib/theme.ts";
|
|
35
|
+
import { getTheme, typographyLabels } from "../../design/themes/index.ts";
|
|
33
36
|
import { DiagramTypeIcon } from "./DiagramTypeIcon.tsx";
|
|
34
|
-
import { Field, Select, humanize } from "./ui.tsx";
|
|
37
|
+
import { Field, NumberField, Select, humanize } from "./ui.tsx";
|
|
35
38
|
import { moveVectorElement, removeVectorElement } from "../../composition/vector.ts";
|
|
36
39
|
import { tokensForAttribute } from "../../composition/theme-tokens.ts";
|
|
37
|
-
import { VectorOverflowWarning } from "./VectorOverflowWarning.tsx";
|
|
38
40
|
import { PageSizePicker } from "./PageSizePicker.tsx";
|
|
39
41
|
|
|
40
42
|
const appearanceLabels: Record<string, string> = {
|
|
@@ -141,7 +143,7 @@ function AppearancePicker({
|
|
|
141
143
|
);
|
|
142
144
|
}
|
|
143
145
|
|
|
144
|
-
function
|
|
146
|
+
function PalettePicker({
|
|
145
147
|
value,
|
|
146
148
|
mode,
|
|
147
149
|
onChange,
|
|
@@ -152,7 +154,7 @@ function ThemePicker({
|
|
|
152
154
|
}) {
|
|
153
155
|
return (
|
|
154
156
|
<fieldset className="theme-picker">
|
|
155
|
-
<legend>
|
|
157
|
+
<legend>Color palette</legend>
|
|
156
158
|
<div className="theme-options">
|
|
157
159
|
{themeIds.map((id) => {
|
|
158
160
|
const palette = composerPalette(id, mode);
|
|
@@ -203,6 +205,22 @@ function SegmentedControl({ label, value, options, onChange }: {
|
|
|
203
205
|
);
|
|
204
206
|
}
|
|
205
207
|
|
|
208
|
+
function AutoTemplateButton({ selected, onClick }: { selected: boolean; onClick: () => void }) {
|
|
209
|
+
return (
|
|
210
|
+
<button type="button" className={selected ? "selected" : ""} aria-pressed={selected} onClick={onClick}>
|
|
211
|
+
<svg className="diagram-type-icon" viewBox="0 0 48 32" aria-hidden="true">
|
|
212
|
+
<rect x="13" y="5" width="22" height="22" rx="4" />
|
|
213
|
+
<circle className="filled" cx="19" cy="11" r="1.5" />
|
|
214
|
+
<circle className="filled" cx="29" cy="11" r="1.5" />
|
|
215
|
+
<circle className="filled" cx="24" cy="16" r="1.5" />
|
|
216
|
+
<circle className="filled" cx="19" cy="21" r="1.5" />
|
|
217
|
+
<circle className="filled" cx="29" cy="21" r="1.5" />
|
|
218
|
+
</svg>
|
|
219
|
+
<span>YOLO</span>
|
|
220
|
+
</button>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
206
224
|
function DiagramTypePicker({
|
|
207
225
|
value,
|
|
208
226
|
selection,
|
|
@@ -218,22 +236,7 @@ function DiagramTypePicker({
|
|
|
218
236
|
<div className="diagram-type-options" aria-labelledby="diagram-type-label">
|
|
219
237
|
<div className="diagram-type-category">
|
|
220
238
|
<div>
|
|
221
|
-
<
|
|
222
|
-
type="button"
|
|
223
|
-
className={selection === "auto" ? "selected" : ""}
|
|
224
|
-
aria-pressed={selection === "auto"}
|
|
225
|
-
onClick={() => onChange(value, "auto")}
|
|
226
|
-
>
|
|
227
|
-
<svg className="diagram-type-icon" viewBox="0 0 48 32" aria-hidden="true">
|
|
228
|
-
<rect x="13" y="5" width="22" height="22" rx="4" />
|
|
229
|
-
<circle className="filled" cx="19" cy="11" r="1.5" />
|
|
230
|
-
<circle className="filled" cx="29" cy="11" r="1.5" />
|
|
231
|
-
<circle className="filled" cx="24" cy="16" r="1.5" />
|
|
232
|
-
<circle className="filled" cx="19" cy="21" r="1.5" />
|
|
233
|
-
<circle className="filled" cx="29" cy="21" r="1.5" />
|
|
234
|
-
</svg>
|
|
235
|
-
<span>YOLO</span>
|
|
236
|
-
</button>
|
|
239
|
+
<AutoTemplateButton selected={selection === "auto"} onClick={() => onChange(value, "auto")} />
|
|
237
240
|
</div>
|
|
238
241
|
</div>
|
|
239
242
|
{diagramCategories.map((category) => (
|
|
@@ -270,21 +273,60 @@ function ChartTypePicker({ value, selection, hasTopology, onChange }: {
|
|
|
270
273
|
}) {
|
|
271
274
|
return (
|
|
272
275
|
<details className="field chart-type-field diagram-type-field">
|
|
273
|
-
<summary
|
|
274
|
-
<
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
276
|
+
<summary id="chart-type-label">Template</summary>
|
|
277
|
+
<div className="diagram-type-options chart-type-options" aria-labelledby="chart-type-label">
|
|
278
|
+
<div className="diagram-type-category">
|
|
279
|
+
<div>
|
|
280
|
+
<AutoTemplateButton selected={selection === "auto"} onClick={() => onChange(value, "auto")} />
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
<div className="diagram-type-category" role="group" aria-label="Chart templates">
|
|
284
|
+
<div>
|
|
285
|
+
{chartTemplates.map((template) => (
|
|
286
|
+
<button
|
|
287
|
+
key={template}
|
|
288
|
+
type="button"
|
|
289
|
+
className={selection === "explicit" && value === template ? "selected" : ""}
|
|
290
|
+
aria-pressed={selection === "explicit" && value === template}
|
|
291
|
+
disabled={hasTopology && template !== "sankey"}
|
|
292
|
+
title={hasTopology && template !== "sankey"
|
|
293
|
+
? "Remove the recorded Sankey topology before changing chart type."
|
|
294
|
+
: chartDefinitions[template].expression}
|
|
295
|
+
onClick={() => onChange(template, "explicit")}
|
|
296
|
+
>
|
|
297
|
+
<span className="appearance-preview" data-property="template" data-value={template} aria-hidden="true">
|
|
298
|
+
{template === "scatter" ? (
|
|
299
|
+
<svg viewBox="0 0 36 24">
|
|
300
|
+
<path d="M3 2v20h31" fill="none" stroke="var(--preview-muted)" strokeWidth="1.2" />
|
|
301
|
+
{[[7, 6], [10, 11], [14, 9], [17, 13], [20, 16], [24, 15], [28, 18], [29, 7], [32, 19]].map(([cx, cy], index) => (
|
|
302
|
+
<circle key={index} cx={cx} cy={cy} r="1.35" fill="var(--preview-accent)" />
|
|
303
|
+
))}
|
|
304
|
+
</svg>
|
|
305
|
+
) : template === "sankey" ? (
|
|
306
|
+
<svg viewBox="0 0 36 24" fill="var(--preview-accent)">
|
|
307
|
+
<g fillOpacity="0.3">
|
|
308
|
+
<path d="M4 4C10.5 4 10.5 2 17 2V12.5C10.5 12.5 10.5 14.5 4 14.5Z" />
|
|
309
|
+
<path d="M4 14.5C10.5 14.5 10.5 17.5 17 17.5V22C10.5 22 10.5 19 4 19Z" />
|
|
310
|
+
<path d="M19 8C25.5 8 25.5 10 32 10V14.5C25.5 14.5 25.5 12.5 19 12.5Z" />
|
|
311
|
+
<path d="M19 17.5C25.5 17.5 25.5 18 32 18V22.5C25.5 22.5 25.5 22 19 22Z" />
|
|
312
|
+
</g>
|
|
313
|
+
<path d="M19 2C25.5 2 25.5 1 32 1V7C25.5 7 25.5 8 19 8Z" fillOpacity="0.65" />
|
|
314
|
+
<rect x="2" y="4" width="2" height="15" />
|
|
315
|
+
<rect x="17" y="2" width="2" height="10.5" />
|
|
316
|
+
<rect x="17" y="17.5" width="2" height="4.5" />
|
|
317
|
+
<rect x="32" y="1" width="2" height="6" />
|
|
318
|
+
<rect x="32" y="10" width="2" height="4.5" />
|
|
319
|
+
<rect x="32" y="18" width="2" height="4.5" />
|
|
320
|
+
</svg>
|
|
321
|
+
) : Array.from({ length: 4 }, (_, index) => <i key={index} />)}
|
|
322
|
+
</span>
|
|
323
|
+
<span>{chartDefinitions[template].label}</span>
|
|
324
|
+
</button>
|
|
325
|
+
))}
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
{hasTopology && <small className="visualization-description">Recorded Sankey topology is preserved; remove it before changing chart type, even in YOLO.</small>}
|
|
288
330
|
</details>
|
|
289
331
|
);
|
|
290
332
|
}
|
|
@@ -326,38 +368,47 @@ export function InspectorPanel({
|
|
|
326
368
|
target?.scrollIntoView({ block: "nearest" });
|
|
327
369
|
control?.select();
|
|
328
370
|
}, [vectorEditRequest]);
|
|
371
|
+
const theme = draft.theme ?? { id: "plex", mode: "paper" };
|
|
372
|
+
const typography = getTheme(themeLabel(theme.id), theme.mode, theme.typography);
|
|
329
373
|
if (!component)
|
|
330
374
|
return (
|
|
331
375
|
<section className="inspector-content" aria-labelledby="inspector-heading">
|
|
332
376
|
<h2 id="inspector-heading">Page</h2>
|
|
333
377
|
<PageSizePicker key={slide.id} slide={slide} onChange={onSlide} />
|
|
334
378
|
<h2>Document appearance</h2>
|
|
335
|
-
<
|
|
336
|
-
|
|
337
|
-
|
|
379
|
+
<Select
|
|
380
|
+
label="Font"
|
|
381
|
+
value={typography.typographyId}
|
|
382
|
+
options={typographyIds}
|
|
383
|
+
optionLabels={typographyLabels}
|
|
384
|
+
onChange={(value) => onDraft({ theme: { ...theme, typography: value as TypographyId } })}
|
|
385
|
+
/>
|
|
386
|
+
<PalettePicker
|
|
387
|
+
value={theme.id}
|
|
388
|
+
mode={theme.mode}
|
|
338
389
|
onChange={(value) =>
|
|
339
390
|
onDraft({
|
|
340
391
|
theme: {
|
|
392
|
+
...theme,
|
|
341
393
|
id: value,
|
|
342
|
-
|
|
394
|
+
typography: typography.typographyId,
|
|
343
395
|
},
|
|
344
396
|
})
|
|
345
397
|
}
|
|
346
398
|
/>
|
|
347
399
|
<SegmentedControl
|
|
348
|
-
label="
|
|
349
|
-
value={
|
|
400
|
+
label="Background"
|
|
401
|
+
value={theme.mode}
|
|
350
402
|
options={["paper", "night"]}
|
|
351
403
|
onChange={(value) =>
|
|
352
404
|
onDraft({
|
|
353
405
|
theme: {
|
|
354
|
-
|
|
406
|
+
...theme,
|
|
355
407
|
mode: value as "paper" | "night",
|
|
356
408
|
},
|
|
357
409
|
})
|
|
358
410
|
}
|
|
359
411
|
/>
|
|
360
|
-
<VectorOverflowWarning slide={slide} theme={draft.theme} />
|
|
361
412
|
<h2 className="slide-settings-heading">Page numbering</h2>
|
|
362
413
|
<fieldset className="page-number-picker">
|
|
363
414
|
<SegmentedControl
|
|
@@ -424,9 +475,9 @@ export function InspectorPanel({
|
|
|
424
475
|
if (key === "x") next.x = clamp(value, 0, slide.canvas.width - rect.width);
|
|
425
476
|
if (key === "y") next.y = clamp(value, 0, slide.canvas.height - rect.height);
|
|
426
477
|
if (key === "width")
|
|
427
|
-
next.width = clamp(value, Math.min(180, slide.canvas.width - rect.x), slide.canvas.width - rect.x);
|
|
478
|
+
next.width = clamp(value, Math.min(180, rect.width, slide.canvas.width - rect.x), slide.canvas.width - rect.x);
|
|
428
479
|
if (key === "height")
|
|
429
|
-
next.height = clamp(value, Math.min(72, slide.canvas.height - rect.y), slide.canvas.height - rect.y);
|
|
480
|
+
next.height = clamp(value, Math.min(72, rect.height, slide.canvas.height - rect.y), slide.canvas.height - rect.y);
|
|
430
481
|
onComponent(
|
|
431
482
|
{ ...component!, preferredRect: next },
|
|
432
483
|
`geometry:${component!.id}`,
|
|
@@ -451,18 +502,17 @@ export function InspectorPanel({
|
|
|
451
502
|
<span>{componentLabel ?? componentLabels[component.kind]}</span>
|
|
452
503
|
</h2>
|
|
453
504
|
{component.kind === "text-block" && !component.customVisual && <>
|
|
454
|
-
<VectorOverflowWarning slide={slide} theme={draft.theme} />
|
|
455
505
|
<Field label="Text"><textarea name="text-content" value={component.content ?? ""}
|
|
456
506
|
onChange={(event) => onComponent({ ...component, content: event.target.value }, `content:${component.id}`)} onBlur={onEditEnd} /></Field>
|
|
457
507
|
<div className="text-typography-fields">
|
|
458
|
-
<
|
|
459
|
-
|
|
508
|
+
<NumberField label="Font size" min={8} max={240} value={component.textStyle?.size ?? 36}
|
|
509
|
+
onCommit={size => onComponent({ ...component, textStyle: { ...component.textStyle, size } }, `font:${component.id}`)} onEditEnd={onEditEnd} />
|
|
460
510
|
<Select label="Text color" value={component.textStyle?.color ?? "ink"} options={["ink", "muted", "accent"]}
|
|
461
511
|
onChange={(color) => onComponent({ ...component, textStyle: { ...component.textStyle, color: color as "ink" | "muted" | "accent" } })} />
|
|
462
512
|
<Select label="Font weight" value={String(component.textStyle?.weight ?? 400)} options={["400", "500", "600"]}
|
|
463
513
|
onChange={(weight) => onComponent({ ...component, textStyle: { ...component.textStyle, weight: Number(weight) as 400 | 500 | 600 } })} />
|
|
464
|
-
<
|
|
465
|
-
|
|
514
|
+
<NumberField label="Line height" min={1} max={3} step={0.1} value={component.textStyle?.lineHeight ?? 1.4}
|
|
515
|
+
onCommit={lineHeight => onComponent({ ...component, textStyle: { ...component.textStyle, lineHeight } }, `leading:${component.id}`)} onEditEnd={onEditEnd} />
|
|
466
516
|
</div>
|
|
467
517
|
</>}
|
|
468
518
|
<Field label={component.kind === "diagram" || component.kind === "chart" ? "What should this explain?" : "Content intent"}>
|
|
@@ -502,23 +552,10 @@ export function InspectorPanel({
|
|
|
502
552
|
<legend>Position & size</legend>
|
|
503
553
|
<div className="geometry">
|
|
504
554
|
{(["x", "y", "width", "height"] as const).map((key) => (
|
|
505
|
-
<
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
autoComplete="off"
|
|
510
|
-
type="number"
|
|
511
|
-
step="1"
|
|
512
|
-
value={Math.round(rect[key])}
|
|
513
|
-
onChange={(e) => {
|
|
514
|
-
if (e.target.value !== "")
|
|
515
|
-
geometry(key, e.target.valueAsNumber);
|
|
516
|
-
}}
|
|
517
|
-
onBlur={onEditEnd}
|
|
518
|
-
/>
|
|
519
|
-
<span className="parameter-unit" aria-hidden="true">px</span>
|
|
520
|
-
</span>
|
|
521
|
-
</Field>
|
|
555
|
+
<NumberField key={key} name={key} label={humanize(key)} unit="px" value={rect[key]}
|
|
556
|
+
min={key === "width" ? Math.min(180, rect.width, slide.canvas.width - rect.x) : key === "height" ? Math.min(72, rect.height, slide.canvas.height - rect.y) : 0}
|
|
557
|
+
max={key === "x" ? slide.canvas.width - rect.width : key === "y" ? slide.canvas.height - rect.height : key === "width" ? slide.canvas.width - rect.x : slide.canvas.height - rect.y}
|
|
558
|
+
onCommit={value => geometry(key, value)} onEditEnd={onEditEnd} />
|
|
522
559
|
))}
|
|
523
560
|
</div>
|
|
524
561
|
</fieldset>
|
|
@@ -3,7 +3,7 @@ import type { CompositionDocument } from "../../composition/types.ts";
|
|
|
3
3
|
import { componentInstanceLabel } from "../lib/model.ts";
|
|
4
4
|
import { activeRequest, type ReviewState, type ReviewTarget } from "../lib/review.ts";
|
|
5
5
|
|
|
6
|
-
export function RevisionNotes({ document, target, review, disabled, onAdd, onRemove, onSelect }: {
|
|
6
|
+
export function RevisionNotes({ document, target, review, disabled, onAdd, onRemove, onSelect, onNotice }: {
|
|
7
7
|
document: CompositionDocument;
|
|
8
8
|
target: ReviewTarget;
|
|
9
9
|
review: ReviewState;
|
|
@@ -11,10 +11,10 @@ export function RevisionNotes({ document, target, review, disabled, onAdd, onRem
|
|
|
11
11
|
onAdd: (target: ReviewTarget, text: string) => Promise<void>;
|
|
12
12
|
onRemove: (id: string) => Promise<void>;
|
|
13
13
|
onSelect: (target: ReviewTarget) => void;
|
|
14
|
+
onNotice: (message: string) => void;
|
|
14
15
|
}) {
|
|
15
16
|
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
|
16
17
|
const [saving, setSaving] = useState(false);
|
|
17
|
-
const [error, setError] = useState("");
|
|
18
18
|
const key = JSON.stringify(target);
|
|
19
19
|
const text = drafts[key] ?? "";
|
|
20
20
|
function label(value: ReviewTarget) {
|
|
@@ -28,16 +28,16 @@ export function RevisionNotes({ document, target, review, disabled, onAdd, onRem
|
|
|
28
28
|
return `${slide.name} · ${name}${value.elementId ? ` · ${element ? value.elementId : "Deleted element"}` : ""}`;
|
|
29
29
|
}
|
|
30
30
|
async function run(action: () => Promise<void>) {
|
|
31
|
-
setSaving(true);
|
|
32
|
-
try { await action(); }
|
|
33
|
-
catch (error) {
|
|
31
|
+
setSaving(true);
|
|
32
|
+
try { await action(); onNotice(""); }
|
|
33
|
+
catch (error) { onNotice(error instanceof Error ? error.message : "Could not save revision note"); }
|
|
34
34
|
finally { setSaving(false); }
|
|
35
35
|
}
|
|
36
36
|
const pending = review.notes.filter(n => !n.resolved);
|
|
37
37
|
return <section className="revision-notes" aria-label="Revision notes">
|
|
38
38
|
<form onSubmit={event => { event.preventDefault(); void run(async () => {
|
|
39
39
|
await onAdd(target, text);
|
|
40
|
-
setDrafts(current => ({ ...current, [key]: "" }));
|
|
40
|
+
setDrafts(current => ({ ...current, [key]: current[key] === text ? "" : current[key] }));
|
|
41
41
|
}); }}>
|
|
42
42
|
<label htmlFor="revision-note">Request a change</label>
|
|
43
43
|
<p className="revision-note-scope">{label(target)}</p>
|
|
@@ -45,7 +45,6 @@ export function RevisionNotes({ document, target, review, disabled, onAdd, onRem
|
|
|
45
45
|
onChange={event => setDrafts(current => ({ ...current, [key]: event.target.value }))} />
|
|
46
46
|
<div className="revision-note-submit"><button type="submit" disabled={disabled || saving || !text.trim()}>{saving ? "Saving…" : "Add note"}</button></div>
|
|
47
47
|
</form>
|
|
48
|
-
{error && <p role="alert" className="revision-note-error">{error}</p>}
|
|
49
48
|
<p className="revision-note-count">{pending.length} pending</p>
|
|
50
49
|
{pending.length > 0 && <ol className="revision-note-list">{pending.map((note, i) => <li key={note.id} id={`note-${note.id}`}>
|
|
51
50
|
<button type="button" className="revision-note-target" onClick={() => onSelect(note)}><span>{i + 1}</span>{label(note)}</button>
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
} from "../../composition/types.ts";
|
|
6
6
|
import { componentInstanceLabel, type Draft } from "../lib/model.ts";
|
|
7
7
|
import { InspectorPanel } from "./InspectorPanel.tsx";
|
|
8
|
+
import { VectorOverflowWarning } from "./VectorOverflowWarning.tsx";
|
|
8
9
|
|
|
9
10
|
export type RightPanelView = "settings" | "layers";
|
|
10
11
|
|
|
@@ -20,6 +21,7 @@ function LayersPanel({
|
|
|
20
21
|
onReorderPaintOrder: (ids: string[]) => void;
|
|
21
22
|
}) {
|
|
22
23
|
const [dragging, setDragging] = useState<string>();
|
|
24
|
+
const [dropIndex, setDropIndex] = useState<number>();
|
|
23
25
|
const layers = [...slide.paintOrder].reverse();
|
|
24
26
|
function moveLayer(id: string, toIndex: number) {
|
|
25
27
|
const next = layers.filter((item) => item !== id);
|
|
@@ -33,7 +35,36 @@ function LayersPanel({
|
|
|
33
35
|
<span>Front to back</span>
|
|
34
36
|
</div>
|
|
35
37
|
{layers.length ? (
|
|
36
|
-
<div className="layer-list"
|
|
38
|
+
<div className="layer-list"
|
|
39
|
+
onDragOver={(event) => {
|
|
40
|
+
if (!dragging) return;
|
|
41
|
+
event.preventDefault();
|
|
42
|
+
const rows = [...event.currentTarget.querySelectorAll<HTMLElement>(".layer-row")];
|
|
43
|
+
const next = rows.findIndex((row) => {
|
|
44
|
+
const rect = row.getBoundingClientRect();
|
|
45
|
+
return event.clientY < rect.top + rect.height / 2;
|
|
46
|
+
});
|
|
47
|
+
const boundary = next < 0 ? layers.length : next;
|
|
48
|
+
const sourceIndex = layers.indexOf(dragging);
|
|
49
|
+
const unchanged = boundary === sourceIndex || boundary === sourceIndex + 1;
|
|
50
|
+
event.dataTransfer.dropEffect = unchanged ? "none" : "move";
|
|
51
|
+
setDropIndex(unchanged ? undefined : boundary);
|
|
52
|
+
}}
|
|
53
|
+
onDragLeave={(event) => {
|
|
54
|
+
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
|
55
|
+
setDropIndex(undefined);
|
|
56
|
+
}
|
|
57
|
+
}}
|
|
58
|
+
onDrop={(event) => {
|
|
59
|
+
if (!dragging) return;
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
if (dropIndex !== undefined) {
|
|
62
|
+
moveLayer(dragging, dropIndex > layers.indexOf(dragging) ? dropIndex - 1 : dropIndex);
|
|
63
|
+
}
|
|
64
|
+
setDragging(undefined);
|
|
65
|
+
setDropIndex(undefined);
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
37
68
|
{layers.map((id, index) => {
|
|
38
69
|
const component = slide.components.find((item) => item.id === id)!;
|
|
39
70
|
const groups = slide.groups
|
|
@@ -44,23 +75,18 @@ function LayersPanel({
|
|
|
44
75
|
<div
|
|
45
76
|
key={id}
|
|
46
77
|
data-layer={id}
|
|
78
|
+
data-drop-edge={dropIndex === index ? "before" : dropIndex === layers.length && index === layers.length - 1 ? "after" : undefined}
|
|
47
79
|
className={`layer-row ${selectedComponentId === id ? "selected" : ""} ${dragging === id ? "dragging" : ""}`}
|
|
48
80
|
draggable
|
|
49
81
|
onDragStart={(event) => {
|
|
50
82
|
setDragging(id);
|
|
83
|
+
setDropIndex(undefined);
|
|
51
84
|
event.dataTransfer.effectAllowed = "move";
|
|
52
85
|
event.dataTransfer.setData("text/plain", id);
|
|
53
86
|
}}
|
|
54
|
-
onDragEnd={() =>
|
|
55
|
-
onDragOver={(event) => {
|
|
56
|
-
event.preventDefault();
|
|
57
|
-
event.dataTransfer.dropEffect = "move";
|
|
58
|
-
}}
|
|
59
|
-
onDrop={(event) => {
|
|
60
|
-
event.preventDefault();
|
|
61
|
-
const source = dragging || event.dataTransfer.getData("text/plain");
|
|
62
|
-
if (source && source !== id) moveLayer(source, index);
|
|
87
|
+
onDragEnd={() => {
|
|
63
88
|
setDragging(undefined);
|
|
89
|
+
setDropIndex(undefined);
|
|
64
90
|
}}
|
|
65
91
|
>
|
|
66
92
|
<span className="layer-drag" aria-hidden="true">⠿</span>
|
|
@@ -112,6 +138,7 @@ export function RightPanel({
|
|
|
112
138
|
onSelectSlide,
|
|
113
139
|
onSelectComponent,
|
|
114
140
|
onSelectVectorElement,
|
|
141
|
+
onSelectOverflow,
|
|
115
142
|
onReorderPaintOrder,
|
|
116
143
|
onComponent,
|
|
117
144
|
onSlide,
|
|
@@ -132,6 +159,7 @@ export function RightPanel({
|
|
|
132
159
|
onSelectSlide: () => void;
|
|
133
160
|
onSelectComponent: (id: string) => void;
|
|
134
161
|
onSelectVectorElement: (id?: string) => void;
|
|
162
|
+
onSelectOverflow: (componentId: string, elementId?: string) => void;
|
|
135
163
|
onReorderPaintOrder: (ids: string[]) => void;
|
|
136
164
|
onComponent: (component: CompositionComponent, mergeKey?: string) => void;
|
|
137
165
|
onSlide: (slide: CompositionSlide, mergeKey?: string) => void;
|
|
@@ -154,6 +182,9 @@ export function RightPanel({
|
|
|
154
182
|
</button>
|
|
155
183
|
))}
|
|
156
184
|
</div>
|
|
185
|
+
<div className="overflow-control" inert={collapsed}>
|
|
186
|
+
<VectorOverflowWarning slide={slide} theme={draft.theme} onSelect={onSelectOverflow} />
|
|
187
|
+
</div>
|
|
157
188
|
<button
|
|
158
189
|
type="button"
|
|
159
190
|
className="panel-toggle"
|
|
@@ -174,6 +205,7 @@ export function RightPanel({
|
|
|
174
205
|
<div className="right-panel-body" inert={collapsed}>
|
|
175
206
|
{view === "settings" ? (
|
|
176
207
|
<InspectorPanel
|
|
208
|
+
key={component?.id ?? slide.id}
|
|
177
209
|
component={component}
|
|
178
210
|
componentLabel={componentLabel}
|
|
179
211
|
slide={slide}
|
|
@@ -1,22 +1,31 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
1
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
2
2
|
import type { CompositionSlide, CompositionDocument } from "../../composition/types.ts";
|
|
3
|
+
import { componentInstanceLabel } from "../lib/model.ts";
|
|
3
4
|
|
|
4
5
|
/** Measure rendered text, including nested SVG transforms, after fonts settle. */
|
|
5
|
-
export function VectorOverflowWarning({ slide, theme }: {
|
|
6
|
+
export function VectorOverflowWarning({ slide, theme, onSelect }: {
|
|
6
7
|
slide: CompositionSlide;
|
|
7
8
|
theme: CompositionDocument["theme"];
|
|
9
|
+
onSelect: (componentId: string, elementId?: string) => void;
|
|
8
10
|
}) {
|
|
9
|
-
const [
|
|
11
|
+
const [issues, setIssues] = useState<{ componentId: string; elementId?: string; text: string }[]>([]);
|
|
12
|
+
const id = useId();
|
|
13
|
+
const popover = useRef<HTMLDivElement>(null);
|
|
14
|
+
useEffect(() => { popover.current?.hidePopover(); }, [slide.id]);
|
|
10
15
|
useEffect(() => {
|
|
11
16
|
let cancelled = false;
|
|
12
17
|
let frame = 0;
|
|
13
18
|
function measure() {
|
|
14
19
|
frame = requestAnimationFrame(() => {
|
|
15
20
|
if (cancelled) return;
|
|
16
|
-
|
|
21
|
+
const overflow: typeof issues = [];
|
|
22
|
+
function add(text: Element) {
|
|
23
|
+
const componentId = text.closest<HTMLElement>("[data-component]")?.dataset.component;
|
|
24
|
+
if (componentId) overflow.push({ componentId, elementId: text.getAttribute("data-vector-element") ?? undefined, text: text.textContent?.trim().slice(0, 80) ?? "Text" });
|
|
25
|
+
}
|
|
17
26
|
document.querySelectorAll<HTMLElement>(".stage .text-block-content").forEach((text) => {
|
|
18
27
|
const surface = text.parentElement;
|
|
19
|
-
if (surface && (text.scrollHeight > surface.clientHeight + 1 || text.scrollWidth > surface.clientWidth + 1))
|
|
28
|
+
if (surface && (text.scrollHeight > surface.clientHeight + 1 || text.scrollWidth > surface.clientWidth + 1)) add(text);
|
|
20
29
|
});
|
|
21
30
|
document.querySelectorAll<SVGSVGElement>(".stage .custom-vector-art").forEach((svg) => {
|
|
22
31
|
const root = svg.getScreenCTM();
|
|
@@ -32,15 +41,33 @@ export function VectorOverflowWarning({ slide, theme }: {
|
|
|
32
41
|
if (corners.some(([x, y]) => {
|
|
33
42
|
const p = new DOMPoint(x, y).matrixTransform(transform);
|
|
34
43
|
return p.x < view.x - 1 || p.y < view.y - 1 || p.x > view.x + view.width + 1 || p.y > view.y + view.height + 1;
|
|
35
|
-
}))
|
|
44
|
+
})) add(text);
|
|
36
45
|
});
|
|
37
46
|
});
|
|
38
|
-
|
|
47
|
+
setIssues(overflow);
|
|
48
|
+
if (!overflow.length) popover.current?.hidePopover();
|
|
39
49
|
});
|
|
40
50
|
}
|
|
41
51
|
void document.fonts.ready.then(measure);
|
|
42
52
|
document.fonts.addEventListener("loadingdone", measure);
|
|
43
53
|
return () => { cancelled = true; cancelAnimationFrame(frame); document.fonts.removeEventListener("loadingdone", measure); };
|
|
44
54
|
}, [slide, theme]);
|
|
45
|
-
return
|
|
55
|
+
return <>
|
|
56
|
+
<span className="sr-only" role="status">{issues.length ? `${issues.length} text overflow ${issues.length === 1 ? "warning" : "warnings"} on this page` : ""}</span>
|
|
57
|
+
<button type="button" className="overflow-trigger" popoverTarget={id} disabled={!issues.length}
|
|
58
|
+
aria-label={`${issues.length} text overflow warnings`} title="Text overflow warnings">
|
|
59
|
+
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 3 18 17H2ZM10 8v4m0 2v.5" /></svg>
|
|
60
|
+
<span>{issues.length}</span>
|
|
61
|
+
</button>
|
|
62
|
+
<div id={id} ref={popover} popover="auto" className="overflow-popover">
|
|
63
|
+
<strong>Text outside its frame</strong>
|
|
64
|
+
<p>Select an item to revise its text or size. Nothing was resized automatically.</p>
|
|
65
|
+
<ul>{issues.map(issue => <li key={`${issue.componentId}:${issue.elementId ?? "native"}`}>
|
|
66
|
+
<button type="button" onClick={() => { popover.current?.hidePopover(); onSelect(issue.componentId, issue.elementId); }}>
|
|
67
|
+
<strong>{componentInstanceLabel(slide.components, issue.componentId)}{issue.elementId ? ` · ${issue.elementId}` : ""}</strong>
|
|
68
|
+
<span>{issue.text}</span>
|
|
69
|
+
</button>
|
|
70
|
+
</li>)}</ul>
|
|
71
|
+
</div>
|
|
72
|
+
</>;
|
|
46
73
|
}
|