@heyramzi/ui 1.54.2 → 2.0.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/COMPONENTS.md +3 -2
- package/README.md +34 -9
- package/package.json +205 -13
- package/scripts/sync-shadcn.mjs +2 -0
- package/src/ai/generation-progress/GenerationProgress.svelte +12 -12
- package/src/ambient.d.ts +8 -0
- package/src/brand/brand.test.ts +67 -0
- package/src/brand/index.ts +137 -0
- package/src/canvas/README.md +236 -0
- package/src/canvas/card/card-frame.svelte +61 -0
- package/src/canvas/card/card.css +39 -0
- package/src/canvas/card/resize-handles.svelte +119 -0
- package/src/canvas/card/resize.ts +33 -0
- package/src/canvas/chrome/chrome-zoom.svelte +120 -0
- package/src/canvas/chrome/chrome.css +231 -0
- package/src/canvas/chrome/lock-badge.svelte +18 -0
- package/src/canvas/chrome/side-handles.svelte +30 -0
- package/src/canvas/chrome/size-chip.svelte +45 -0
- package/src/canvas/chrome/snap-guides.svelte +69 -0
- package/src/canvas/components/icons/ToolIcon.svelte +30 -0
- package/src/canvas/components/shared/svg/PreviewSvg.svelte +67 -0
- package/src/canvas/components/shared/svg/ShapeOutline.svelte +76 -0
- package/src/canvas/components/shared/svg/StrokeNode.svelte +69 -0
- package/src/canvas/components/shared/svg/TextRule.svelte +37 -0
- package/src/canvas/edges/bend-handle.svelte +76 -0
- package/src/canvas/edges/edge-gradient.svelte +30 -0
- package/src/canvas/edges/edge-path.test.ts +58 -0
- package/src/canvas/edges/edge-path.ts +293 -0
- package/src/canvas/edges/gradient-edge.svelte +43 -0
- package/src/canvas/editor/context-menu.test.ts +65 -0
- package/src/canvas/editor/context-menu.ts +48 -0
- package/src/canvas/editor/dispatch.svelte.test.ts +243 -0
- package/src/canvas/editor/dispatch.svelte.ts +205 -0
- package/src/canvas/editor/editor-context.ts +62 -0
- package/src/canvas/editor/editor-mode.ts +67 -0
- package/src/canvas/editor/frame.ts +98 -0
- package/src/canvas/editor/hand-context.ts +15 -0
- package/src/canvas/editor/history-core.test.ts +239 -0
- package/src/canvas/editor/history-core.ts +247 -0
- package/src/canvas/editor/keymap.test.ts +161 -0
- package/src/canvas/editor/keymap.ts +151 -0
- package/src/canvas/editor/layout-actions.test.ts +156 -0
- package/src/canvas/editor/layout-actions.ts +219 -0
- package/src/canvas/editor/lock.test.ts +68 -0
- package/src/canvas/editor/lock.ts +87 -0
- package/src/canvas/editor/rebase.test.ts +208 -0
- package/src/canvas/editor/rebase.ts +272 -0
- package/src/canvas/editor/style.ts +184 -0
- package/src/canvas/editor/text-field-context.ts +41 -0
- package/src/canvas/editor/z-order.test.ts +75 -0
- package/src/canvas/editor/z-order.ts +87 -0
- package/src/canvas/headless/alignment.test.ts +59 -0
- package/src/canvas/headless/alignment.ts +23 -0
- package/src/canvas/headless/break-apart-find.ts +418 -0
- package/src/canvas/headless/break-apart-pieces.ts +104 -0
- package/src/canvas/headless/break-apart-plan.ts +439 -0
- package/src/canvas/headless/break-apart-style.ts +106 -0
- package/src/canvas/headless/break-apart.ts +244 -0
- package/src/canvas/headless/canvas-cleanup.test.ts +181 -0
- package/src/canvas/headless/canvas-cleanup.ts +102 -0
- package/src/canvas/headless/edge-anchors.test.ts +70 -0
- package/src/canvas/headless/edge-anchors.ts +143 -0
- package/src/canvas/headless/edge-memo.test.ts +58 -0
- package/src/canvas/headless/edge-memo.ts +32 -0
- package/src/canvas/headless/edge-serialization.test.ts +55 -0
- package/src/canvas/headless/edge-serialization.ts +32 -0
- package/src/canvas/headless/figure-geometry.ts +293 -0
- package/src/canvas/headless/fit-viewport.test.ts +85 -0
- package/src/canvas/headless/fit-viewport.ts +137 -0
- package/src/canvas/headless/grid.test.ts +34 -0
- package/src/canvas/headless/grid.ts +94 -0
- package/src/canvas/headless/icon-node.test.ts +23 -0
- package/src/canvas/headless/icon-node.ts +66 -0
- package/src/canvas/headless/index.ts +9 -0
- package/src/canvas/headless/linkify.test.ts +23 -0
- package/src/canvas/headless/linkify.ts +25 -0
- package/src/canvas/headless/pen-marks.ts +133 -0
- package/src/canvas/headless/sketch.test.ts +99 -0
- package/src/canvas/headless/sketch.ts +302 -0
- package/src/canvas/headless/snap-guides.test.ts +61 -0
- package/src/canvas/headless/snap-guides.ts +146 -0
- package/src/canvas/headless/svg-figure.test.ts +49 -0
- package/src/canvas/headless/svg-figure.ts +176 -0
- package/src/canvas/headless/table-layout.test.ts +158 -0
- package/src/canvas/headless/table-layout.ts +309 -0
- package/src/canvas/index.ts +35 -0
- package/src/canvas/lod/lod-mode.svelte.ts +30 -0
- package/src/canvas/lod/lod-switch.svelte +140 -0
- package/src/canvas/lod/render-all.svelte.ts +52 -0
- package/src/canvas/ops/arrange.ts +108 -0
- package/src/canvas/ops/ops.test.ts +98 -0
- package/src/canvas/ops/ops.ts +347 -0
- package/src/canvas/pen/PenField.svelte +128 -0
- package/src/canvas/pen/PenField.test.ts +87 -0
- package/src/canvas/pen/PenOverlay.svelte +172 -0
- package/src/canvas/pen/ShapeNode.svelte +64 -0
- package/src/canvas/pen/TextNode.svelte +97 -0
- package/src/canvas/pen/colors.ts +28 -0
- package/src/canvas/pen/eraser.ts +46 -0
- package/src/canvas/pen/geometry.ts +148 -0
- package/src/canvas/pen/host-field.test.svelte +13 -0
- package/src/canvas/pen/index.ts +40 -0
- package/src/canvas/pen/ops.ts +16 -0
- package/src/canvas/pen/pen-session.ts +177 -0
- package/src/canvas/pen/pen.css +149 -0
- package/src/canvas/pen/plugin.ts +139 -0
- package/src/canvas/pen/shapes.ts +104 -0
- package/src/canvas/pen/stroke-path.ts +54 -0
- package/src/canvas/pen/types.ts +96 -0
- package/src/canvas/pen/ui.ts +26 -0
- package/src/canvas/plugin.test.ts +45 -0
- package/src/canvas/plugin.ts +159 -0
- package/src/canvas/screen/GlyphIcon.svelte +25 -0
- package/src/canvas/screen/PartView.svelte +176 -0
- package/src/canvas/screen/ScreenBody.svelte +142 -0
- package/src/canvas/screen/ScreenCard.svelte +32 -0
- package/src/canvas/screen/glyphs/button.svg +1 -0
- package/src/canvas/screen/glyphs/close.svg +1 -0
- package/src/canvas/screen/glyphs/down.svg +1 -0
- package/src/canvas/screen/glyphs/header.svg +1 -0
- package/src/canvas/screen/glyphs/hills.svg +1 -0
- package/src/canvas/screen/glyphs/image.svg +1 -0
- package/src/canvas/screen/glyphs/input.svg +1 -0
- package/src/canvas/screen/glyphs/list.svg +1 -0
- package/src/canvas/screen/glyphs/modal.svg +1 -0
- package/src/canvas/screen/glyphs/plus.svg +1 -0
- package/src/canvas/screen/glyphs/up.svg +1 -0
- package/src/canvas/screen/glyphs.ts +40 -0
- package/src/canvas/screen/labels.ts +41 -0
- package/src/canvas/screen/parts.ts +164 -0
- package/src/canvas/screen/plugin.ts +67 -0
- package/src/canvas/screen/screen.css +593 -0
- package/src/canvas/screen/types.ts +47 -0
- package/src/canvas/screen/ui.ts +13 -0
- package/src/canvas/test-utils.ts +18 -0
- package/src/canvas/types.ts +53 -0
- package/src/canvas/ui/CanvasToolbar.svelte +169 -0
- package/src/canvas/ui/FrameLayer.svelte +116 -0
- package/src/canvas/ui/PenLayer.svelte +39 -0
- package/src/canvas/ui/StylePanel.svelte +268 -0
- package/src/canvas/ui/canvas-grid.svelte +156 -0
- package/src/canvas/ui/canvas-ui.css +350 -0
- package/src/canvas/ui/forward-wheel.ts +29 -0
- package/src/canvas/ui/icons.ts +124 -0
- package/src/canvas/ui/place-panel.ts +80 -0
- package/src/canvas/ui/shortcut-tip.test.ts +16 -0
- package/src/canvas/ui/shortcut-tip.ts +17 -0
- package/src/canvas/ui/tools.ts +146 -0
- package/src/charts/README.md +104 -0
- package/src/charts/ambient.d.ts +4 -0
- package/src/charts/animated-value.css +7 -0
- package/src/charts/animated-value.svelte +110 -0
- package/src/charts/area-chart.css +29 -0
- package/src/charts/area-chart.svelte +204 -0
- package/src/charts/bar-chart.css +119 -0
- package/src/charts/bar-chart.svelte +275 -0
- package/src/charts/charts.css +201 -0
- package/src/charts/crosshair.svelte +35 -0
- package/src/charts/dashboard/chart.ts +5 -0
- package/src/charts/dashboard/dashboard-empty.svelte +55 -0
- package/src/charts/dashboard/dashboard-section.svelte +58 -0
- package/src/charts/dashboard/data-panel.svelte +81 -0
- package/src/charts/dashboard/data-sort-head.svelte +102 -0
- package/src/charts/dashboard/data-table.svelte +278 -0
- package/src/charts/dashboard/data-table.ts +26 -0
- package/src/charts/dashboard/index.ts +25 -0
- package/src/charts/dashboard/panel-grid.svelte +43 -0
- package/src/charts/dashboard/period-filter.svelte +53 -0
- package/src/charts/dashboard/rating-chip.svelte +35 -0
- package/src/charts/dashboard/stat-band.svelte +47 -0
- package/src/charts/dashboard/stat-tile.svelte +176 -0
- package/src/charts/dashboard/windows.ts +8 -0
- package/src/charts/donut-chart.css +56 -0
- package/src/charts/donut-chart.svelte +158 -0
- package/src/charts/edge-fade.svelte +43 -0
- package/src/charts/edge-fade.ts +16 -0
- package/src/charts/figure.css +100 -0
- package/src/charts/figure.svelte +122 -0
- package/src/charts/gauge-chart.css +94 -0
- package/src/charts/gauge-chart.svelte +302 -0
- package/src/charts/hover-band.test.ts +30 -0
- package/src/charts/hover-band.ts +21 -0
- package/src/charts/hover-focus.svelte +85 -0
- package/src/charts/index.ts +31 -0
- package/src/charts/legend.css +136 -0
- package/src/charts/legend.svelte +112 -0
- package/src/charts/line-chart.css +26 -0
- package/src/charts/line-chart.svelte +256 -0
- package/src/charts/motion.ts +19 -0
- package/src/charts/number-chart.css +179 -0
- package/src/charts/number-chart.svelte +187 -0
- package/src/charts/primitives/chart-container.svelte +57 -0
- package/src/charts/primitives/chart-style.svelte +36 -0
- package/src/charts/primitives/chart-tooltip.svelte +161 -0
- package/src/charts/primitives/chart-utils.ts +54 -0
- package/src/charts/primitives/index.ts +11 -0
- package/src/charts/reference-line.css +18 -0
- package/src/charts/reference-line.svelte +31 -0
- package/src/charts/reveal.svelte +61 -0
- package/src/charts/ring-chart.css +104 -0
- package/src/charts/ring-chart.svelte +166 -0
- package/src/charts/sparkline.css +45 -0
- package/src/charts/sparkline.svelte +141 -0
- package/src/charts/stat-card.css +105 -0
- package/src/charts/stat-card.svelte +267 -0
- package/src/charts/time-series-chart.css +28 -0
- package/src/charts/time-series-chart.svelte +179 -0
- package/src/charts/trend-pill.css +38 -0
- package/src/charts/trend-pill.svelte +48 -0
- package/src/components/copy/copy-button.svelte +1 -1
- package/src/components/docs/doc-page.svelte +1 -1
- package/src/components/docs/on-this-page.svelte +2 -2
- package/src/components/list-page/empty-state.svelte +1 -1
- package/src/components/list-page/filter-popover.svelte +1 -1
- package/src/components/list-page/section-label.svelte +1 -1
- package/src/components/segmented/segment.svelte +4 -4
- package/src/components/theme/theme-provider.svelte +6 -4
- package/src/components/toolbar/index.ts +5 -0
- package/src/components/toolbar/toolbar.svelte +168 -0
- package/src/components/toolbar/toolbar.test.ts +66 -0
- package/src/editor/README.md +58 -0
- package/src/editor/ambient.d.ts +1 -0
- package/src/editor/block-id.ts +77 -0
- package/src/editor/block-ops.test.ts +237 -0
- package/src/editor/block-ops.ts +157 -0
- package/src/editor/content.css +418 -0
- package/src/editor/editor-toolbar.svelte +303 -0
- package/src/editor/editor.svelte +469 -0
- package/src/editor/extensions.ts +145 -0
- package/src/editor/image-toolbar.svelte +123 -0
- package/src/editor/index.ts +17 -0
- package/src/editor/inline-editor.svelte +261 -0
- package/src/editor/markdown-escaping.test.ts +158 -0
- package/src/editor/markdown-marks.test.ts +89 -0
- package/src/editor/markdown-test-helpers.ts +13 -0
- package/src/editor/markdown-youtube.test.ts +93 -0
- package/src/editor/markdown.test.ts +250 -0
- package/src/editor/markdown.ts +511 -0
- package/src/editor/renderer.svelte +92 -0
- package/src/editor/serializer.test.ts +133 -0
- package/src/editor/serializer.ts +370 -0
- package/src/editor/slash-items.ts +115 -0
- package/src/editor/slash-menu.svelte +159 -0
- package/src/editor/youtube-url.ts +35 -0
- package/src/effects/interactive-hover-button/InteractiveHoverButton.svelte +13 -13
- package/src/gallery.test.ts +20 -1
- package/src/lib/result.ts +19 -0
- package/src/onboarding/README.md +120 -0
- package/src/onboarding/define-tours.test.ts +50 -0
- package/src/onboarding/define-tours.ts +31 -0
- package/src/onboarding/demo.svelte +86 -0
- package/src/onboarding/index.ts +12 -0
- package/src/onboarding/onboarding-provider.svelte +120 -0
- package/src/onboarding/onboarding.svelte.ts +24 -0
- package/src/onboarding/pick.test.ts +79 -0
- package/src/onboarding/pick.ts +36 -0
- package/src/onboarding/stores.test.ts +103 -0
- package/src/onboarding/stores.ts +58 -0
- package/src/onboarding/tour-card.svelte +70 -0
- package/src/onboarding/tour-runner.svelte +197 -0
- package/src/onboarding/types.ts +34 -0
- package/src/onboarding/wait.ts +29 -0
- package/src/shadcn/command/command-input.svelte +1 -1
- package/src/slides/Controls.svelte +138 -0
- package/src/slides/Deck.svelte +271 -0
- package/src/slides/Fragment.svelte +71 -0
- package/src/slides/Overview.svelte +113 -0
- package/src/slides/Presenter.svelte +292 -0
- package/src/slides/README.md +89 -0
- package/src/slides/Slide.svelte +179 -0
- package/src/slides/SlideList.svelte +19 -0
- package/src/slides/ambient.d.ts +1 -0
- package/src/slides/bridges.ts +69 -0
- package/src/slides/deck-controller.svelte.ts +292 -0
- package/src/slides/demo-slides.svelte +358 -0
- package/src/slides/demo.svelte +176 -0
- package/src/slides/export/capture.ts +113 -0
- package/src/slides/export/ffmpeg.ts +120 -0
- package/src/slides/export/index.ts +9 -0
- package/src/slides/export/launch.ts +10 -0
- package/src/slides/export/pdf.ts +152 -0
- package/src/slides/export/types.ts +48 -0
- package/src/slides/index.ts +19 -0
- package/src/slides/labels.ts +48 -0
- package/src/slides/layout.ts +51 -0
- package/src/slides/model.ts +47 -0
- package/src/slides/navigation.ts +100 -0
- package/src/slides/theme.css +205 -0
- package/src/slides/types.ts +86 -0
- package/src/tasks/README.md +312 -0
- package/src/tasks/ambient.d.ts +3 -0
- package/src/tasks/board-order.ts +137 -0
- package/src/tasks/dates.ts +31 -0
- package/src/tasks/define-statuses.ts +32 -0
- package/src/tasks/demo.svelte +389 -0
- package/src/tasks/detail/detail-demo.svelte +186 -0
- package/src/tasks/detail/detail-types.ts +54 -0
- package/src/tasks/detail/task-chip.svelte +28 -0
- package/src/tasks/detail/task-detail.svelte +315 -0
- package/src/tasks/detail/task-property.svelte +17 -0
- package/src/tasks/detail/task-thread.svelte +258 -0
- package/src/tasks/gantt/gantt-bar.svelte +243 -0
- package/src/tasks/gantt/gantt-grid.svelte +94 -0
- package/src/tasks/gantt/gantt-group-row.svelte +55 -0
- package/src/tasks/gantt/gantt-header.svelte +71 -0
- package/src/tasks/gantt/gantt-task-row.svelte +186 -0
- package/src/tasks/gantt/gantt.css +22 -0
- package/src/tasks/gantt/gantt.test.ts +77 -0
- package/src/tasks/gantt/logic.test.ts +163 -0
- package/src/tasks/gantt/logic.ts +248 -0
- package/src/tasks/inbox/inbox-card.svelte +283 -0
- package/src/tasks/inbox/inbox-draft.svelte +84 -0
- package/src/tasks/inbox/inbox-logic.ts +55 -0
- package/src/tasks/inbox/inbox-statuses.ts +48 -0
- package/src/tasks/inbox/inbox-trace.svelte +45 -0
- package/src/tasks/inbox/inbox-types.ts +108 -0
- package/src/tasks/inbox/inbox.svelte +203 -0
- package/src/tasks/inbox/index.ts +23 -0
- package/src/tasks/index.ts +85 -0
- package/src/tasks/kanban-board.svelte +561 -0
- package/src/tasks/kanban-column.svelte +117 -0
- package/src/tasks/kanban-drag.ts +38 -0
- package/src/tasks/kanban-types.ts +65 -0
- package/src/tasks/presets/sales.ts +16 -0
- package/src/tasks/record/demo-data.ts +286 -0
- package/src/tasks/record/field-section.svelte +53 -0
- package/src/tasks/record/fields.ts +142 -0
- package/src/tasks/record/format.ts +87 -0
- package/src/tasks/record/gutter.ts +2 -0
- package/src/tasks/record/index.ts +54 -0
- package/src/tasks/record/info-list.svelte +316 -0
- package/src/tasks/record/kinds.ts +42 -0
- package/src/tasks/record/logic.ts +324 -0
- package/src/tasks/record/next-up.svelte +70 -0
- package/src/tasks/record/project-list.svelte +133 -0
- package/src/tasks/record/record-demo.svelte +324 -0
- package/src/tasks/record/record-header.svelte +70 -0
- package/src/tasks/record/record-shell.svelte +138 -0
- package/src/tasks/record/record-stats.svelte +48 -0
- package/src/tasks/record/record-timeline.svelte +231 -0
- package/src/tasks/record/record.test.ts +350 -0
- package/src/tasks/record/steps-rail.svelte +474 -0
- package/src/tasks/record/types.ts +280 -0
- package/src/tasks/record/where-we-are.svelte +121 -0
- package/src/tasks/reorder.ts +19 -0
- package/src/tasks/side-panel-types.ts +16 -0
- package/src/tasks/side-panel.svelte +193 -0
- package/src/tasks/status-badge.svelte +63 -0
- package/src/tasks/status-dot.svelte +228 -0
- package/src/tasks/status-icon.svelte +30 -0
- package/src/tasks/status-types.ts +40 -0
- package/src/tasks/task-gantt.svelte +230 -0
- package/src/tasks/task-list.svelte +470 -0
- package/src/tasks/task-types.ts +33 -0
- package/src/tasks/time/duration.ts +69 -0
- package/src/tasks/time/time-chip.svelte +62 -0
- package/src/tasks/time/time-dot.svelte +58 -0
- package/src/tasks/time/time-ring.svelte +74 -0
- package/src/tasks/time/time-tracker.svelte +565 -0
- package/src/tasks/time/time-types.ts +74 -0
- package/src/tasks/use-kanban-dnd.svelte.ts +173 -0
- package/src/tokens/base.css +5 -5
- package/src/tokens/palette.css +115 -120
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// The canvas keyboard map, as a resolver rather than a switch in the editor.
|
|
2
|
+
// Mirrors resolveMindmapKeyAction in mindmap-keyboard.ts, which the mode
|
|
3
|
+
// controllers already read.
|
|
4
|
+
|
|
5
|
+
export type EditorKeyAction =
|
|
6
|
+
| "undo"
|
|
7
|
+
| "redo"
|
|
8
|
+
| "copy"
|
|
9
|
+
| "paste"
|
|
10
|
+
| "select-all"
|
|
11
|
+
| "save"
|
|
12
|
+
| "delete"
|
|
13
|
+
| "duplicate"
|
|
14
|
+
| "group"
|
|
15
|
+
| "ungroup"
|
|
16
|
+
| "zoom-in"
|
|
17
|
+
| "zoom-out"
|
|
18
|
+
| "zoom-100"
|
|
19
|
+
| "zoom-fit"
|
|
20
|
+
| "zoom-selection"
|
|
21
|
+
| "bring-forward"
|
|
22
|
+
| "send-backward"
|
|
23
|
+
| "bring-to-front"
|
|
24
|
+
| "send-to-back"
|
|
25
|
+
| "lock"
|
|
26
|
+
| "copy-as-png"
|
|
27
|
+
| "present";
|
|
28
|
+
|
|
29
|
+
/** True while the event's target owns its own text editing. */
|
|
30
|
+
export function isEditingTarget(target: EventTarget | null): boolean {
|
|
31
|
+
const el = target as HTMLElement | null;
|
|
32
|
+
const tag = el?.tagName;
|
|
33
|
+
return tag === "INPUT" || tag === "TEXTAREA" || el?.isContentEditable === true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The action a key means on the canvas, or null when the canvas does not own it. */
|
|
37
|
+
export function resolveEditorKeyAction(
|
|
38
|
+
event: KeyboardEvent,
|
|
39
|
+
hasSelection: boolean,
|
|
40
|
+
): EditorKeyAction | null {
|
|
41
|
+
const key = event.key.toLowerCase();
|
|
42
|
+
const mod = event.metaKey || event.ctrlKey;
|
|
43
|
+
const editing = isEditingTarget(event.target);
|
|
44
|
+
if (mod) return modAction(key, event.shiftKey, editing);
|
|
45
|
+
if (event.key === "Delete" || event.key === "Backspace") {
|
|
46
|
+
return hasSelection && !editing ? "delete" : null;
|
|
47
|
+
}
|
|
48
|
+
return editing || event.altKey ? null : plainAction(event);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Shift with a digit zooms. Read by code, so a layout that moves the digits still works. */
|
|
52
|
+
const SHIFT_ZOOM = new Map<string, EditorKeyAction>([
|
|
53
|
+
["Digit0", "zoom-100"],
|
|
54
|
+
["Digit1", "zoom-fit"],
|
|
55
|
+
["Digit2", "zoom-selection"],
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
const PLAIN_KEYS = new Map<string, EditorKeyAction>([
|
|
59
|
+
["]", "bring-forward"],
|
|
60
|
+
["[", "send-backward"],
|
|
61
|
+
["+", "zoom-in"],
|
|
62
|
+
["=", "zoom-in"],
|
|
63
|
+
["-", "zoom-out"],
|
|
64
|
+
["_", "zoom-out"],
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
/** Zoom keys with no chord: + and -, and Shift with 0, 1 or 2. */
|
|
68
|
+
function plainAction(event: KeyboardEvent): EditorKeyAction | null {
|
|
69
|
+
const zoom = event.shiftKey ? SHIFT_ZOOM.get(event.code) : undefined;
|
|
70
|
+
return zoom ?? PLAIN_KEYS.get(event.key) ?? null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The move an arrow key asks for: 1px, or 10px with Shift. Null for anything else, or while a field owns the keys. */
|
|
74
|
+
export function resolveNudge(event: KeyboardEvent): { dx: number; dy: number } | null {
|
|
75
|
+
if (event.metaKey || event.ctrlKey || event.altKey || isEditingTarget(event.target)) return null;
|
|
76
|
+
const step = event.shiftKey ? 10 : 1;
|
|
77
|
+
switch (event.key) {
|
|
78
|
+
case "ArrowLeft":
|
|
79
|
+
return { dx: -step, dy: 0 };
|
|
80
|
+
case "ArrowRight":
|
|
81
|
+
return { dx: step, dy: 0 };
|
|
82
|
+
case "ArrowUp":
|
|
83
|
+
return { dx: 0, dy: -step };
|
|
84
|
+
case "ArrowDown":
|
|
85
|
+
return { dx: 0, dy: step };
|
|
86
|
+
default:
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** What a chord means without Shift, then with it. Null where that variant is not ours. */
|
|
92
|
+
type Chord = readonly [plain: EditorKeyAction | null, shifted: EditorKeyAction | null];
|
|
93
|
+
|
|
94
|
+
const both = (action: EditorKeyAction): Chord => [action, action];
|
|
95
|
+
|
|
96
|
+
const CHORDS = new Map<string, Chord>([
|
|
97
|
+
["c", ["copy", "copy-as-png"]],
|
|
98
|
+
["v", both("paste")],
|
|
99
|
+
["a", both("select-all")],
|
|
100
|
+
["d", ["duplicate", null]],
|
|
101
|
+
["g", ["group", "ungroup"]],
|
|
102
|
+
["l", [null, "lock"]],
|
|
103
|
+
["]", both("bring-to-front")],
|
|
104
|
+
["[", both("send-to-back")],
|
|
105
|
+
["enter", both("present")],
|
|
106
|
+
["=", both("zoom-in")],
|
|
107
|
+
["+", both("zoom-in")],
|
|
108
|
+
["-", both("zoom-out")],
|
|
109
|
+
["_", both("zoom-out")],
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
/** The chord half of the map. `editing` is true while a field owns the keys. */
|
|
113
|
+
function modAction(key: string, shift: boolean, editing: boolean): EditorKeyAction | null {
|
|
114
|
+
if (key === "z") return shift ? "redo" : "undo";
|
|
115
|
+
if (key === "y") return "redo";
|
|
116
|
+
// Save works whatever has focus, and whatever the autosave preference is.
|
|
117
|
+
if (key === "s" && !shift) return "save";
|
|
118
|
+
// Cut, copy, paste and select-all belong to the focused field while one is
|
|
119
|
+
// being typed in; the canvas only claims them otherwise.
|
|
120
|
+
if (editing) return null;
|
|
121
|
+
return CHORDS.get(key)?.[shift ? 1 : 0] ?? null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Tags and roles that activate on Space themselves, so the canvas must not steal it. */
|
|
125
|
+
const SPACE_OWNING_TAGS = new Set(["BUTTON", "A", "SUMMARY", "SELECT", "OPTION"]);
|
|
126
|
+
const SPACE_OWNING_ROLES = new Set([
|
|
127
|
+
"button",
|
|
128
|
+
"checkbox",
|
|
129
|
+
"switch",
|
|
130
|
+
"menuitem",
|
|
131
|
+
"menuitemcheckbox",
|
|
132
|
+
"menuitemradio",
|
|
133
|
+
"option",
|
|
134
|
+
"radio",
|
|
135
|
+
"tab",
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
/** Space means hand tool over the canvas, never in a field or on a Space-activating control. Reads code, so remapped layouts still pan. */
|
|
139
|
+
export function isPanActivationKey(event: KeyboardEvent): boolean {
|
|
140
|
+
if (event.code !== "Space" && event.key !== " ") return false;
|
|
141
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return false;
|
|
142
|
+
if (isEditingTarget(event.target)) return false;
|
|
143
|
+
return !ownsSpace(event.target as HTMLElement | null);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A control that already does something on Space: a button, a checkbox, a tab. */
|
|
147
|
+
function ownsSpace(el: HTMLElement | null): boolean {
|
|
148
|
+
if (el?.tagName && SPACE_OWNING_TAGS.has(el.tagName)) return true;
|
|
149
|
+
const role = el?.getAttribute?.("role");
|
|
150
|
+
return Boolean(role && SPACE_OWNING_ROLES.has(role));
|
|
151
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { expectDefined } from "../test-utils.js";
|
|
2
|
+
import { describe, it, expect } from "vite-plus/test";
|
|
3
|
+
import { alignNodes, groupSelectionOps, tidyGroup } from "./layout-actions.js";
|
|
4
|
+
|
|
5
|
+
function pos(id: string, x: number, y: number) {
|
|
6
|
+
return { id, position: { x, y } };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("alignNodes", () => {
|
|
10
|
+
it("returns empty map for fewer than 2 nodes", () => {
|
|
11
|
+
const result = alignNodes([pos("a", 10, 20)], "left");
|
|
12
|
+
expect(result.size).toBe(0);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("aligns left to minimum x", () => {
|
|
16
|
+
const result = alignNodes([pos("a", 100, 0), pos("b", 200, 0), pos("c", 50, 0)], "left");
|
|
17
|
+
expect(expectDefined(result.get("a"), "entry a").x).toBe(50);
|
|
18
|
+
expect(expectDefined(result.get("b"), "entry b").x).toBe(50);
|
|
19
|
+
expect(expectDefined(result.get("c"), "entry c").x).toBe(50);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("aligns right to maximum x", () => {
|
|
23
|
+
const result = alignNodes([pos("a", 100, 0), pos("b", 200, 0)], "right");
|
|
24
|
+
expect(expectDefined(result.get("a"), "entry a").x).toBe(200);
|
|
25
|
+
expect(expectDefined(result.get("b"), "entry b").x).toBe(200);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("aligns top to minimum y", () => {
|
|
29
|
+
const result = alignNodes([pos("a", 0, 100), pos("b", 0, 50)], "top");
|
|
30
|
+
expect(expectDefined(result.get("a"), "entry a").y).toBe(50);
|
|
31
|
+
expect(expectDefined(result.get("b"), "entry b").y).toBe(50);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("aligns bottom to maximum y", () => {
|
|
35
|
+
const result = alignNodes([pos("a", 0, 100), pos("b", 0, 200)], "bottom");
|
|
36
|
+
expect(expectDefined(result.get("a"), "entry a").y).toBe(200);
|
|
37
|
+
expect(expectDefined(result.get("b"), "entry b").y).toBe(200);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("center-h aligns to minimum x (vertical center)", () => {
|
|
41
|
+
const result = alignNodes([pos("a", 100, 0), pos("b", 200, 0)], "center-h");
|
|
42
|
+
// alignNodesVertically sets all to min x
|
|
43
|
+
expect(expectDefined(result.get("a"), "entry a").x).toBe(100);
|
|
44
|
+
expect(expectDefined(result.get("b"), "entry b").x).toBe(100);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("center-v aligns to average y", () => {
|
|
48
|
+
const result = alignNodes([pos("a", 0, 100), pos("b", 0, 200), pos("c", 0, 300)], "center-v");
|
|
49
|
+
expect(expectDefined(result.get("a"), "entry a").y).toBe(200);
|
|
50
|
+
expect(expectDefined(result.get("b"), "entry b").y).toBe(200);
|
|
51
|
+
expect(expectDefined(result.get("c"), "entry c").y).toBe(200);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("does not mutate input positions", () => {
|
|
55
|
+
const nodes = [pos("a", 100, 0), pos("b", 200, 0)];
|
|
56
|
+
alignNodes(nodes, "left");
|
|
57
|
+
expect(nodes[0].position.x).toBe(100);
|
|
58
|
+
expect(nodes[1].position.x).toBe(200);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("groupSelectionOps", () => {
|
|
63
|
+
const box = (id: string, x: number, y: number, parentId?: string) => ({
|
|
64
|
+
id,
|
|
65
|
+
position: { x, y },
|
|
66
|
+
measured: { width: 100, height: 50 },
|
|
67
|
+
...(parentId ? { parentId } : {}),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("refuses to group fewer than two nodes", () => {
|
|
71
|
+
expect(groupSelectionOps([box("a", 0, 0)], "Group")).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("draws the box around the selection and re-parents every member", () => {
|
|
75
|
+
const ops = groupSelectionOps([box("a", 100, 100), box("b", 300, 200)], "Group");
|
|
76
|
+
const [add, ...rest] = ops;
|
|
77
|
+
|
|
78
|
+
expect(add).toMatchObject({
|
|
79
|
+
kind: "addNode",
|
|
80
|
+
node: {
|
|
81
|
+
type: "group",
|
|
82
|
+
// 32 of padding on every side, plus 36 of title row above.
|
|
83
|
+
position: { x: 68, y: 32 },
|
|
84
|
+
width: 364,
|
|
85
|
+
height: 250,
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const groupId = (add as { node: { id: string } }).node.id;
|
|
90
|
+
expect(rest).toEqual([
|
|
91
|
+
{ kind: "setParent", nodeId: "a", parentId: groupId, extent: "parent" },
|
|
92
|
+
{ kind: "moveNode", nodeId: "a", position: { x: 32, y: 68 } },
|
|
93
|
+
{ kind: "setParent", nodeId: "b", parentId: groupId, extent: "parent" },
|
|
94
|
+
{ kind: "moveNode", nodeId: "b", position: { x: 232, y: 168 } },
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("takes only the members sharing the first one's parent, and joins it", () => {
|
|
99
|
+
const ops = groupSelectionOps(
|
|
100
|
+
[box("a", 0, 0, "p1"), box("b", 100, 0, "p1"), box("loose", 900, 900)],
|
|
101
|
+
"Group",
|
|
102
|
+
);
|
|
103
|
+
expect(ops).toHaveLength(5);
|
|
104
|
+
expect(ops[0]).toMatchObject({ kind: "addNode", node: { parentId: "p1" } });
|
|
105
|
+
expect(ops.some((op) => "nodeId" in op && op.nodeId === "loose")).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("tidyGroup", () => {
|
|
110
|
+
const box = (id: string, x: number, y: number, width: number, height: number) => ({
|
|
111
|
+
id,
|
|
112
|
+
position: { x, y },
|
|
113
|
+
measured: { width, height },
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("has nothing to tidy in an empty group", () => {
|
|
117
|
+
expect(tidyGroup([])).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("keeps side-by-side cards in one row, stacks the rest, and fits the box", () => {
|
|
121
|
+
const result = expectDefined(
|
|
122
|
+
tidyGroup([
|
|
123
|
+
box("card2", 800, 110, 640, 220),
|
|
124
|
+
box("title", 0, 10, 200, 30),
|
|
125
|
+
box("card1", 5, 120, 750, 500),
|
|
126
|
+
box("subtitle", 5, 70, 600, 20),
|
|
127
|
+
]),
|
|
128
|
+
"tidy result",
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// 32 of padding, 36 of title row, 24 between rows and between cards.
|
|
132
|
+
expect(Object.fromEntries(result.positions)).toEqual({
|
|
133
|
+
title: { x: 32, y: 68 },
|
|
134
|
+
subtitle: { x: 32, y: 122 },
|
|
135
|
+
card1: { x: 32, y: 166 },
|
|
136
|
+
card2: { x: 806, y: 166 },
|
|
137
|
+
});
|
|
138
|
+
expect(result.size).toEqual({ width: 1478, height: 698 });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("pulls overlapping nodes apart along their row", () => {
|
|
142
|
+
const result = expectDefined(
|
|
143
|
+
tidyGroup([box("b", 10, 5, 100, 50), box("a", 0, 0, 100, 50)]),
|
|
144
|
+
"tidy result",
|
|
145
|
+
);
|
|
146
|
+
expect(result.positions.get("a")).toEqual({ x: 32, y: 68 });
|
|
147
|
+
expect(result.positions.get("b")).toEqual({ x: 156, y: 68 });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("never shrinks the box below the container minimum", () => {
|
|
151
|
+
expect(expectDefined(tidyGroup([box("a", 400, 400, 10, 10)]), "tidy result").size).toEqual({
|
|
152
|
+
width: 250,
|
|
153
|
+
height: 150,
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import type { CanvasOp } from "../ops/ops.js";
|
|
2
|
+
import type { CanvasNode } from "../types.js";
|
|
3
|
+
import { alignNodesHorizontally, alignNodesVertically } from "../headless/alignment.js";
|
|
4
|
+
|
|
5
|
+
/** The smallest a group frame gets. An app passes its own to `tidyGroup`. */
|
|
6
|
+
const DEFAULT_GROUP_MIN = { width: 250, height: 150 } as const;
|
|
7
|
+
|
|
8
|
+
type AlignDirection = "left" | "right" | "top" | "bottom" | "center-h" | "center-v";
|
|
9
|
+
|
|
10
|
+
type Positioned = { id: string; position: { x: number; y: number } };
|
|
11
|
+
|
|
12
|
+
function setX(nodes: Positioned[], x: number): void {
|
|
13
|
+
for (const n of nodes) n.position.x = x;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function setY(nodes: Positioned[], y: number): void {
|
|
17
|
+
for (const n of nodes) n.position.y = y;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const ALIGNERS: Record<AlignDirection, (nodes: Positioned[]) => void> = {
|
|
21
|
+
left: (nodes) => setX(nodes, Math.min(...nodes.map((n) => n.position.x))),
|
|
22
|
+
right: (nodes) => setX(nodes, Math.max(...nodes.map((n) => n.position.x))),
|
|
23
|
+
top: (nodes) => alignNodesHorizontally(nodes as CanvasNode[]),
|
|
24
|
+
bottom: (nodes) => setY(nodes, Math.max(...nodes.map((n) => n.position.y))),
|
|
25
|
+
"center-h": (nodes) => alignNodesVertically(nodes as CanvasNode[]),
|
|
26
|
+
"center-v": (nodes) =>
|
|
27
|
+
setY(nodes, nodes.reduce((sum, n) => sum + n.position.y, 0) / nodes.length),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Compute new positions for the given nodes based on alignment direction.
|
|
32
|
+
* Returns a Map of nodeId to new position.
|
|
33
|
+
*/
|
|
34
|
+
export function alignNodes(
|
|
35
|
+
selected: Positioned[],
|
|
36
|
+
direction: AlignDirection,
|
|
37
|
+
): Map<string, { x: number; y: number }> {
|
|
38
|
+
if (selected.length < 2) return new Map();
|
|
39
|
+
|
|
40
|
+
const mutableNodes = selected.map((n) => ({
|
|
41
|
+
id: n.id,
|
|
42
|
+
position: { ...n.position },
|
|
43
|
+
}));
|
|
44
|
+
ALIGNERS[direction](mutableNodes);
|
|
45
|
+
|
|
46
|
+
return new Map(mutableNodes.map((n) => [n.id, n.position]));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const TIDY_GAP = 40;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Distribute selected nodes evenly along their dominant axis.
|
|
53
|
+
* Detects whether nodes are mostly horizontal or vertical and
|
|
54
|
+
* equalizes gaps while preserving the cross-axis positions.
|
|
55
|
+
*/
|
|
56
|
+
export function tidySelectedNodes(
|
|
57
|
+
selected: Array<{
|
|
58
|
+
id: string;
|
|
59
|
+
position: { x: number; y: number };
|
|
60
|
+
measured?: { width?: number; height?: number };
|
|
61
|
+
width?: number;
|
|
62
|
+
height?: number;
|
|
63
|
+
}>,
|
|
64
|
+
): Map<string, { x: number; y: number }> {
|
|
65
|
+
if (selected.length < 2) return new Map();
|
|
66
|
+
|
|
67
|
+
const xs = selected.map((n) => n.position.x);
|
|
68
|
+
const ys = selected.map((n) => n.position.y);
|
|
69
|
+
const xSpread = Math.max(...xs) - Math.min(...xs);
|
|
70
|
+
const ySpread = Math.max(...ys) - Math.min(...ys);
|
|
71
|
+
|
|
72
|
+
const horizontal = xSpread >= ySpread;
|
|
73
|
+
|
|
74
|
+
const sorted = [...selected].sort((a, b) =>
|
|
75
|
+
horizontal ? a.position.x - b.position.x : a.position.y - b.position.y,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return horizontal ? stackAlongX(sorted) : stackAlongY(sorted);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type Tidyable = Parameters<typeof tidySelectedNodes>[0][number];
|
|
82
|
+
|
|
83
|
+
function stackAlongX(sorted: Tidyable[]): Map<string, { x: number; y: number }> {
|
|
84
|
+
const result = new Map<string, { x: number; y: number }>();
|
|
85
|
+
let cursorX = sorted[0].position.x;
|
|
86
|
+
for (const node of sorted) {
|
|
87
|
+
result.set(node.id, { x: cursorX, y: node.position.y });
|
|
88
|
+
cursorX += (node.measured?.width ?? node.width ?? 200) + TIDY_GAP;
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function stackAlongY(sorted: Tidyable[]): Map<string, { x: number; y: number }> {
|
|
94
|
+
const result = new Map<string, { x: number; y: number }>();
|
|
95
|
+
let cursorY = sorted[0].position.y;
|
|
96
|
+
for (const node of sorted) {
|
|
97
|
+
result.set(node.id, { x: node.position.x, y: cursorY });
|
|
98
|
+
cursorY += (node.measured?.height ?? node.height ?? 100) + TIDY_GAP;
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// A group box drawn around a selection: the padding on every side, plus the
|
|
104
|
+
// room the container's own title row takes at the top.
|
|
105
|
+
const GROUP_PADDING = 32;
|
|
106
|
+
const GROUP_HEADER = 36;
|
|
107
|
+
|
|
108
|
+
type Measurable = {
|
|
109
|
+
id: string;
|
|
110
|
+
position: { x: number; y: number };
|
|
111
|
+
measured?: { width?: number; height?: number };
|
|
112
|
+
width?: number;
|
|
113
|
+
height?: number;
|
|
114
|
+
parentId?: string;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const GROUP_GAP = 24;
|
|
118
|
+
const ROW_TOLERANCE = 8;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Tidy a group's direct children: rows by reading order, even gaps, then the
|
|
122
|
+
* box fitted around them. Null when the group is empty.
|
|
123
|
+
*/
|
|
124
|
+
export function tidyGroup(
|
|
125
|
+
children: Measurable[],
|
|
126
|
+
min: { width: number; height: number } = DEFAULT_GROUP_MIN,
|
|
127
|
+
): {
|
|
128
|
+
positions: Map<string, { x: number; y: number }>;
|
|
129
|
+
size: { width: number; height: number };
|
|
130
|
+
} | null {
|
|
131
|
+
if (children.length === 0) return null;
|
|
132
|
+
const width = (n: Measurable) => n.measured?.width ?? n.width ?? 200;
|
|
133
|
+
const height = (n: Measurable) => n.measured?.height ?? n.height ?? 100;
|
|
134
|
+
|
|
135
|
+
// A node joins the current row when it overlaps it vertically by more than a hair.
|
|
136
|
+
const rows: { bottom: number; items: Measurable[] }[] = [];
|
|
137
|
+
for (const n of [...children].sort((a, b) => a.position.y - b.position.y)) {
|
|
138
|
+
const row = rows.at(-1);
|
|
139
|
+
if (row && n.position.y < row.bottom - ROW_TOLERANCE) {
|
|
140
|
+
row.items.push(n);
|
|
141
|
+
row.bottom = Math.max(row.bottom, n.position.y + height(n));
|
|
142
|
+
} else {
|
|
143
|
+
rows.push({ bottom: n.position.y + height(n), items: [n] });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const positions = new Map<string, { x: number; y: number }>();
|
|
148
|
+
let y = GROUP_PADDING + GROUP_HEADER;
|
|
149
|
+
let right = 0;
|
|
150
|
+
for (const row of rows) {
|
|
151
|
+
let x = GROUP_PADDING;
|
|
152
|
+
for (const n of row.items.sort((a, b) => a.position.x - b.position.x)) {
|
|
153
|
+
positions.set(n.id, { x, y });
|
|
154
|
+
x += width(n) + GROUP_GAP;
|
|
155
|
+
}
|
|
156
|
+
right = Math.max(right, x - GROUP_GAP);
|
|
157
|
+
y += Math.max(...row.items.map(height)) + GROUP_GAP;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
positions,
|
|
162
|
+
size: {
|
|
163
|
+
width: Math.max(min.width, right + GROUP_PADDING),
|
|
164
|
+
height: Math.max(min.height, y - GROUP_GAP + GROUP_PADDING),
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export type GroupOptions = {
|
|
170
|
+
/** The node type of the frame. Defaults to "group". */
|
|
171
|
+
type?: string;
|
|
172
|
+
/** Extra `data` on the frame beside its label. */
|
|
173
|
+
data?: Record<string, unknown>;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/** Wrap a selection in a frame node, or [] when there is nothing to group. */
|
|
177
|
+
export function groupSelectionOps(
|
|
178
|
+
selected: Measurable[],
|
|
179
|
+
groupLabel: string,
|
|
180
|
+
{ type = "group", data = {} }: GroupOptions = {},
|
|
181
|
+
): CanvasOp[] {
|
|
182
|
+
if (selected.length < 2) return [];
|
|
183
|
+
const parentId = selected[0].parentId;
|
|
184
|
+
const members = selected.filter((n) => n.parentId === parentId);
|
|
185
|
+
if (members.length < 2) return [];
|
|
186
|
+
|
|
187
|
+
const width = (n: Measurable) => n.measured?.width ?? n.width ?? 200;
|
|
188
|
+
const height = (n: Measurable) => n.measured?.height ?? n.height ?? 100;
|
|
189
|
+
const minX = Math.min(...members.map((n) => n.position.x));
|
|
190
|
+
const minY = Math.min(...members.map((n) => n.position.y));
|
|
191
|
+
const maxX = Math.max(...members.map((n) => n.position.x + width(n)));
|
|
192
|
+
const maxY = Math.max(...members.map((n) => n.position.y + height(n)));
|
|
193
|
+
|
|
194
|
+
const origin = { x: minX - GROUP_PADDING, y: minY - GROUP_PADDING - GROUP_HEADER };
|
|
195
|
+
const groupId = crypto.randomUUID();
|
|
196
|
+
|
|
197
|
+
return [
|
|
198
|
+
{
|
|
199
|
+
kind: "addNode",
|
|
200
|
+
node: {
|
|
201
|
+
id: groupId,
|
|
202
|
+
type,
|
|
203
|
+
position: origin,
|
|
204
|
+
data: { ...data, label: groupLabel },
|
|
205
|
+
width: maxX - minX + GROUP_PADDING * 2,
|
|
206
|
+
height: maxY - minY + GROUP_PADDING * 2 + GROUP_HEADER,
|
|
207
|
+
...(parentId ? { parentId } : {}),
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
...members.flatMap((n): CanvasOp[] => [
|
|
211
|
+
{ kind: "setParent", nodeId: n.id, parentId: groupId, extent: "parent" },
|
|
212
|
+
{
|
|
213
|
+
kind: "moveNode",
|
|
214
|
+
nodeId: n.id,
|
|
215
|
+
position: { x: n.position.x - origin.x, y: n.position.y - origin.y },
|
|
216
|
+
},
|
|
217
|
+
]),
|
|
218
|
+
];
|
|
219
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
2
|
+
import type { Node } from "@xyflow/svelte";
|
|
3
|
+
import { deletableIds, refuseLockedOps, toggleLock, withLockFlags } from "./lock.js";
|
|
4
|
+
|
|
5
|
+
function card(id: string, data: Record<string, unknown> = {}, extra: Partial<Node> = {}): Node {
|
|
6
|
+
return { id, type: "note", position: { x: 0, y: 0 }, data, ...extra };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("refuseLockedOps", () => {
|
|
10
|
+
const nodes = [card("free"), card("held", { locked: true })];
|
|
11
|
+
|
|
12
|
+
it("drops moves, edits and removals on a locked card", () => {
|
|
13
|
+
const ops = [
|
|
14
|
+
{ kind: "moveNode", nodeId: "held", position: { x: 1, y: 1 } },
|
|
15
|
+
{ kind: "patchNode", nodeId: "held", data: { content: "x" } },
|
|
16
|
+
{ kind: "removeNode", nodeId: "held" },
|
|
17
|
+
{ kind: "moveNode", nodeId: "free", position: { x: 1, y: 1 } },
|
|
18
|
+
];
|
|
19
|
+
expect(refuseLockedOps(nodes, ops)).toEqual([ops[3]]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("lets a locked card be unlocked and linked", () => {
|
|
23
|
+
const ops = [
|
|
24
|
+
{ kind: "patchNode", nodeId: "held", data: { locked: false } },
|
|
25
|
+
{ kind: "addEdge", edge: { source: "held", target: "free" } },
|
|
26
|
+
];
|
|
27
|
+
expect(refuseLockedOps(nodes, ops)).toEqual(ops);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("refuses an unlock that also edits the card", () => {
|
|
31
|
+
const op = { kind: "patchNode", nodeId: "held", data: { locked: false, content: "x" } };
|
|
32
|
+
expect(refuseLockedOps(nodes, [op])).toEqual([]);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("toggleLock", () => {
|
|
37
|
+
it("locks, then unlocks when every pick is locked", () => {
|
|
38
|
+
const board = [card("a"), card("b", { locked: true })];
|
|
39
|
+
const locked = toggleLock(board, new Set(["a", "b"]));
|
|
40
|
+
expect(locked?.locked).toBe(true);
|
|
41
|
+
expect(locked?.nodes.every((n) => n.draggable === false && n.deletable === false)).toBe(true);
|
|
42
|
+
const unlocked = toggleLock(locked?.nodes ?? [], new Set(["a", "b"]));
|
|
43
|
+
expect(unlocked?.locked).toBe(false);
|
|
44
|
+
expect(unlocked?.nodes.some((n) => "draggable" in n)).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("does nothing for an empty pick", () => {
|
|
48
|
+
expect(toggleLock([card("a")], new Set())).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("deletableIds", () => {
|
|
53
|
+
it("keeps a locked card and the group that holds one", () => {
|
|
54
|
+
const board = [
|
|
55
|
+
card("g", {}, { type: "wrapper" }),
|
|
56
|
+
card("held", { locked: true }, { parentId: "g" }),
|
|
57
|
+
card("free"),
|
|
58
|
+
];
|
|
59
|
+
expect(deletableIds(board, ["g", "held", "free"])).toEqual(["free"]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("withLockFlags", () => {
|
|
64
|
+
it("leaves a synced copy's own flags alone", () => {
|
|
65
|
+
const copy = card("c", { syncedCopy: true, locked: true });
|
|
66
|
+
expect(withLockFlags(copy)).toBe(copy);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// A locked card can be selected, copied and connected, and nothing else. `data.locked` saves,
|
|
2
|
+
// rebases and undoes with the card.
|
|
3
|
+
|
|
4
|
+
import type { Node } from "@xyflow/svelte";
|
|
5
|
+
import type { OpLike } from "../plugin.js";
|
|
6
|
+
|
|
7
|
+
export function isLocked(node: { data?: unknown } | null | undefined): boolean {
|
|
8
|
+
const data = node?.data as Record<string, unknown> | undefined;
|
|
9
|
+
return data?.locked === true;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** What xyflow needs to hold a locked card still. */
|
|
13
|
+
export const LOCK_FLAGS = { draggable: false, deletable: false } as const;
|
|
14
|
+
|
|
15
|
+
/** The card with its flow flags matching `data.locked`. A synced copy keeps its own flags. */
|
|
16
|
+
export function withLockFlags<N extends Node>(node: N): N {
|
|
17
|
+
if ((node.data as Record<string, unknown> | undefined)?.syncedCopy) return node;
|
|
18
|
+
if (isLocked(node)) return { ...node, ...LOCK_FLAGS };
|
|
19
|
+
if (node.draggable !== false && node.deletable !== false) return node;
|
|
20
|
+
const { draggable: _drag, deletable: _delete, ...rest } = node;
|
|
21
|
+
return rest as N;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `locked` alone is the one data change a locked card takes: it is how it comes unlocked. */
|
|
25
|
+
function onlyUnlocks(op: OpLike): boolean {
|
|
26
|
+
if (op.kind !== "patchNode") return false;
|
|
27
|
+
const patch = op as {
|
|
28
|
+
data?: Record<string, unknown>;
|
|
29
|
+
style?: unknown;
|
|
30
|
+
width?: unknown;
|
|
31
|
+
height?: unknown;
|
|
32
|
+
};
|
|
33
|
+
return (
|
|
34
|
+
patch.data !== undefined &&
|
|
35
|
+
patch.style === undefined &&
|
|
36
|
+
patch.width === undefined &&
|
|
37
|
+
patch.height === undefined &&
|
|
38
|
+
Object.keys(patch.data).every((key) => key === "locked")
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The one gate every dispatched edit passes: ops that don't reach a locked card. An op on an
|
|
44
|
+
* arrow, or a card not on the board, passes.
|
|
45
|
+
*/
|
|
46
|
+
export function refuseLockedOps<Op extends OpLike>(
|
|
47
|
+
nodes: readonly Node[],
|
|
48
|
+
ops: readonly Op[],
|
|
49
|
+
): Op[] {
|
|
50
|
+
const locked = new Set(nodes.filter(isLocked).map((node) => node.id));
|
|
51
|
+
if (locked.size === 0) return [...ops];
|
|
52
|
+
return ops.filter((op) => {
|
|
53
|
+
const nodeId = (op as { nodeId?: unknown }).nodeId;
|
|
54
|
+
return typeof nodeId !== "string" || !locked.has(nodeId) || onlyUnlocks(op);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Of these ids, the ones a delete may take: not locked, and not a group holding something locked. */
|
|
59
|
+
export function deletableIds(nodes: readonly Node[], ids: readonly string[]): string[] {
|
|
60
|
+
const parentOf = new Map(nodes.map((node) => [node.id, node.parentId]));
|
|
61
|
+
const holdsLocked = new Set<string>();
|
|
62
|
+
for (const node of nodes) {
|
|
63
|
+
if (!isLocked(node)) continue;
|
|
64
|
+
for (let up = node.parentId; up; up = parentOf.get(up)) holdsLocked.add(up);
|
|
65
|
+
}
|
|
66
|
+
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
67
|
+
return ids.filter((id) => !isLocked(byId.get(id)) && !holdsLocked.has(id));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The board with the picked cards locked, or all unlocked when every one of them already was locked. */
|
|
71
|
+
export function toggleLock(
|
|
72
|
+
nodes: readonly Node[],
|
|
73
|
+
picked: ReadonlySet<string>,
|
|
74
|
+
): { nodes: Node[]; locked: boolean } | null {
|
|
75
|
+
const targets = nodes.filter(
|
|
76
|
+
(node) => picked.has(node.id) && !(node.data as { syncedCopy?: unknown }).syncedCopy,
|
|
77
|
+
);
|
|
78
|
+
if (targets.length === 0) return null;
|
|
79
|
+
const locked = !targets.every(isLocked);
|
|
80
|
+
const ids = new Set(targets.map((node) => node.id));
|
|
81
|
+
return {
|
|
82
|
+
locked,
|
|
83
|
+
nodes: nodes.map((node) =>
|
|
84
|
+
ids.has(node.id) ? withLockFlags({ ...node, data: { ...node.data, locked } }) : node,
|
|
85
|
+
),
|
|
86
|
+
};
|
|
87
|
+
}
|