@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,243 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
2
|
+
import type { Node } from "@xyflow/svelte";
|
|
3
|
+
import { createCanvasDispatcher } from "./dispatch.svelte.js";
|
|
4
|
+
import type { FlowState } from "./history-core.js";
|
|
5
|
+
|
|
6
|
+
function node(id: string, label = id): Node {
|
|
7
|
+
return { id, type: "node", position: { x: 0, y: 0 }, data: { label } } as Node;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function setup(nodes: Node[] = [node("a"), node("b")]) {
|
|
11
|
+
let state: FlowState = { nodes, edges: [] };
|
|
12
|
+
let now = 1_000;
|
|
13
|
+
const dispatcher = createCanvasDispatcher({
|
|
14
|
+
getState: () => state,
|
|
15
|
+
setState: (next) => {
|
|
16
|
+
state = next;
|
|
17
|
+
},
|
|
18
|
+
onMutated: () => {},
|
|
19
|
+
now: () => now,
|
|
20
|
+
});
|
|
21
|
+
const label = (id: string) => state.nodes.find((n) => n.id === id)?.data.label;
|
|
22
|
+
const type = (id: string, text: string, key = `text:${id}`) =>
|
|
23
|
+
dispatcher.dispatch(
|
|
24
|
+
{ kind: "patchNode", nodeId: id, data: { label: text } },
|
|
25
|
+
{ coalesceKey: key },
|
|
26
|
+
);
|
|
27
|
+
return {
|
|
28
|
+
dispatcher,
|
|
29
|
+
label,
|
|
30
|
+
type,
|
|
31
|
+
advance: (ms: number) => {
|
|
32
|
+
now += ms;
|
|
33
|
+
},
|
|
34
|
+
// A drag writes the store itself between begin and commit.
|
|
35
|
+
moveNode: (id: string, x: number) => {
|
|
36
|
+
state = {
|
|
37
|
+
...state,
|
|
38
|
+
nodes: state.nodes.map((n) => (n.id === id ? { ...n, position: { x, y: 0 } } : n)),
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
position: (id: string) => state.nodes.find((n) => n.id === id)?.position.x,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("text coalescing", () => {
|
|
46
|
+
it("merges keystrokes under 1 s apart into one undo step", () => {
|
|
47
|
+
const t = setup();
|
|
48
|
+
t.type("a", "h");
|
|
49
|
+
t.advance(300);
|
|
50
|
+
t.type("a", "he");
|
|
51
|
+
t.advance(900);
|
|
52
|
+
t.type("a", "hey");
|
|
53
|
+
expect(t.label("a")).toBe("hey");
|
|
54
|
+
|
|
55
|
+
t.dispatcher.undo();
|
|
56
|
+
expect(t.label("a")).toBe("a");
|
|
57
|
+
expect(t.dispatcher.canUndo).toBe(false);
|
|
58
|
+
|
|
59
|
+
t.dispatcher.redo();
|
|
60
|
+
expect(t.label("a")).toBe("hey");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("starts a new step after a pause of 1 s or more", () => {
|
|
64
|
+
const t = setup();
|
|
65
|
+
t.type("a", "h");
|
|
66
|
+
t.advance(1000);
|
|
67
|
+
t.type("a", "he");
|
|
68
|
+
|
|
69
|
+
t.dispatcher.undo();
|
|
70
|
+
expect(t.label("a")).toBe("h");
|
|
71
|
+
t.dispatcher.undo();
|
|
72
|
+
expect(t.label("a")).toBe("a");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("does not merge across different keys", () => {
|
|
76
|
+
const t = setup();
|
|
77
|
+
t.type("a", "x");
|
|
78
|
+
t.advance(100);
|
|
79
|
+
t.type("b", "y");
|
|
80
|
+
|
|
81
|
+
t.dispatcher.undo();
|
|
82
|
+
expect(t.label("b")).toBe("b");
|
|
83
|
+
expect(t.label("a")).toBe("x");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("ends the session on endCoalesce", () => {
|
|
87
|
+
const t = setup();
|
|
88
|
+
t.type("a", "h");
|
|
89
|
+
t.dispatcher.endCoalesce();
|
|
90
|
+
t.advance(100);
|
|
91
|
+
t.type("a", "he");
|
|
92
|
+
|
|
93
|
+
t.dispatcher.undo();
|
|
94
|
+
expect(t.label("a")).toBe("h");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("ends the session on a plain commit between two keystrokes", () => {
|
|
98
|
+
const t = setup();
|
|
99
|
+
t.type("a", "h");
|
|
100
|
+
t.dispatcher.dispatch({ kind: "moveNode", nodeId: "b", position: { x: 5, y: 5 } });
|
|
101
|
+
t.type("a", "he");
|
|
102
|
+
|
|
103
|
+
t.dispatcher.undo();
|
|
104
|
+
expect(t.label("a")).toBe("h");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("ends the session on undo and redo", () => {
|
|
108
|
+
const t = setup();
|
|
109
|
+
t.type("a", "h");
|
|
110
|
+
t.dispatcher.undo();
|
|
111
|
+
t.dispatcher.redo();
|
|
112
|
+
t.advance(100);
|
|
113
|
+
t.type("a", "he");
|
|
114
|
+
|
|
115
|
+
t.dispatcher.undo();
|
|
116
|
+
expect(t.label("a")).toBe("h");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("undoes one step to the text before the session after a merged run", () => {
|
|
120
|
+
const t = setup([node("a", "start")]);
|
|
121
|
+
for (const text of ["s", "st", "sta", "star", "start!"]) {
|
|
122
|
+
t.type("a", text);
|
|
123
|
+
t.advance(200);
|
|
124
|
+
}
|
|
125
|
+
t.dispatcher.undo();
|
|
126
|
+
expect(t.label("a")).toBe("start");
|
|
127
|
+
expect(t.dispatcher.canUndo).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("history cap", () => {
|
|
132
|
+
it("keeps 200 entries and drops the oldest", () => {
|
|
133
|
+
const t = setup([node("a", "0")]);
|
|
134
|
+
for (let i = 1; i <= 250; i++) {
|
|
135
|
+
t.dispatcher.dispatch({ kind: "patchNode", nodeId: "a", data: { label: String(i) } });
|
|
136
|
+
}
|
|
137
|
+
let undone = 0;
|
|
138
|
+
while (t.dispatcher.canUndo) {
|
|
139
|
+
t.dispatcher.undo();
|
|
140
|
+
undone += 1;
|
|
141
|
+
}
|
|
142
|
+
expect(undone).toBe(200);
|
|
143
|
+
expect(t.label("a")).toBe("50");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("open transaction", () => {
|
|
148
|
+
it("reports isOpen while a gesture is in flight", () => {
|
|
149
|
+
const t = setup();
|
|
150
|
+
expect(t.dispatcher.isOpen).toBe(false);
|
|
151
|
+
t.dispatcher.begin();
|
|
152
|
+
expect(t.dispatcher.isOpen).toBe(true);
|
|
153
|
+
t.dispatcher.commit();
|
|
154
|
+
expect(t.dispatcher.isOpen).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("undo is blocked while a gesture is fresh", () => {
|
|
158
|
+
const t = setup();
|
|
159
|
+
t.dispatcher.dispatch({ kind: "moveNode", nodeId: "b", position: { x: 5, y: 5 } });
|
|
160
|
+
t.dispatcher.begin();
|
|
161
|
+
t.advance(9_000);
|
|
162
|
+
t.dispatcher.undo();
|
|
163
|
+
expect(t.dispatcher.canUndo).toBe(true);
|
|
164
|
+
expect(t.dispatcher.isOpen).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("recovers a drag whose stop never fired: undo closes it, then undoes it", () => {
|
|
168
|
+
const t = setup();
|
|
169
|
+
t.dispatcher.begin();
|
|
170
|
+
t.moveNode("a", 100);
|
|
171
|
+
t.advance(10_001);
|
|
172
|
+
expect(t.dispatcher.isOpen).toBe(false);
|
|
173
|
+
|
|
174
|
+
t.dispatcher.undo();
|
|
175
|
+
expect(t.position("a")).toBe(0);
|
|
176
|
+
expect(t.dispatcher.canUndo).toBe(false);
|
|
177
|
+
expect(t.dispatcher.canRedo).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("recovers on begin, so the next edit gets its own step", () => {
|
|
181
|
+
const t = setup();
|
|
182
|
+
t.dispatcher.begin();
|
|
183
|
+
t.moveNode("a", 100);
|
|
184
|
+
t.advance(10_001);
|
|
185
|
+
t.dispatcher.dispatch({ kind: "patchNode", nodeId: "b", data: { label: "B" } });
|
|
186
|
+
|
|
187
|
+
t.dispatcher.undo();
|
|
188
|
+
expect(t.label("b")).toBe("b");
|
|
189
|
+
expect(t.position("a")).toBe(100);
|
|
190
|
+
t.dispatcher.undo();
|
|
191
|
+
expect(t.position("a")).toBe(0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("treats a commit with no begin as harmless", () => {
|
|
195
|
+
const t = setup();
|
|
196
|
+
t.dispatcher.commit();
|
|
197
|
+
t.dispatcher.commit();
|
|
198
|
+
expect(t.dispatcher.isOpen).toBe(false);
|
|
199
|
+
t.dispatcher.begin();
|
|
200
|
+
expect(t.dispatcher.isOpen).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("a drag held longer than the stuck limit", () => {
|
|
205
|
+
it("stays open while drag frames keep reporting activity", () => {
|
|
206
|
+
const t = setup();
|
|
207
|
+
t.dispatcher.begin();
|
|
208
|
+
for (let i = 1; i <= 8; i += 1) {
|
|
209
|
+
t.advance(3_000);
|
|
210
|
+
t.moveNode("a", i * 10);
|
|
211
|
+
t.dispatcher.touch();
|
|
212
|
+
}
|
|
213
|
+
expect(t.dispatcher.isOpen).toBe(true);
|
|
214
|
+
t.dispatcher.commit();
|
|
215
|
+
t.dispatcher.undo();
|
|
216
|
+
expect(t.position("a")).toBe(0);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("still reads stuck when nothing reports for the full limit", () => {
|
|
220
|
+
const t = setup();
|
|
221
|
+
t.dispatcher.begin();
|
|
222
|
+
t.advance(11_000);
|
|
223
|
+
expect(t.dispatcher.isOpen).toBe(false);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("a big board", () => {
|
|
228
|
+
it("records and undoes an edit without reading the cards it did not touch", () => {
|
|
229
|
+
const untouched = Array.from({ length: 50 }, (_, i) => ({
|
|
230
|
+
...node(`n${i}`),
|
|
231
|
+
toJSON: () => {
|
|
232
|
+
throw new Error("an untouched card was serialised");
|
|
233
|
+
},
|
|
234
|
+
}));
|
|
235
|
+
const t = setup([...untouched, node("a", "one")]);
|
|
236
|
+
t.type("a", "two");
|
|
237
|
+
expect(t.label("a")).toBe("two");
|
|
238
|
+
t.dispatcher.undo();
|
|
239
|
+
expect(t.label("a")).toBe("one");
|
|
240
|
+
t.dispatcher.redo();
|
|
241
|
+
expect(t.label("a")).toBe("two");
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// The single write path for diagram mutations. Every edit, from any source, is
|
|
2
|
+
// one transaction: a before/after history entry, then dirty and autosave.
|
|
3
|
+
|
|
4
|
+
// Undo and redo replay those entries.
|
|
5
|
+
|
|
6
|
+
import type { Node, Edge } from "@xyflow/svelte";
|
|
7
|
+
import { applyOps, type CanvasOp } from "../ops/ops.js";
|
|
8
|
+
import type { OpDefinition, OpLike } from "../plugin.js";
|
|
9
|
+
import { tryCatch } from "../../lib/result.js";
|
|
10
|
+
import {
|
|
11
|
+
applyHistoryEntry,
|
|
12
|
+
diffFlowState,
|
|
13
|
+
mergeHistoryEntries,
|
|
14
|
+
type FlowState,
|
|
15
|
+
type HistoryEntry,
|
|
16
|
+
} from "./history-core.js";
|
|
17
|
+
|
|
18
|
+
const MAX_ENTRIES = 200;
|
|
19
|
+
|
|
20
|
+
/** Keystrokes under this far apart share one undo step. */
|
|
21
|
+
const COALESCE_WINDOW_MS = 1000;
|
|
22
|
+
|
|
23
|
+
// A drag whose stop never fires (pointer lost, node unmounted) leaves its transaction open,
|
|
24
|
+
// and drag frames never reach the dispatcher, so this counts from the last begin or commit.
|
|
25
|
+
const STUCK_TRANSACTION_MS = 10_000;
|
|
26
|
+
|
|
27
|
+
export type CanvasDispatcher<Op extends OpLike = CanvasOp> = ReturnType<
|
|
28
|
+
typeof createCanvasDispatcher<Op>
|
|
29
|
+
>;
|
|
30
|
+
|
|
31
|
+
/** `Op` is the union of op shapes the caller dispatches: the canvas's own plus its plugins'. */
|
|
32
|
+
export function createCanvasDispatcher<Op extends OpLike = CanvasOp>(io: {
|
|
33
|
+
getState: () => FlowState;
|
|
34
|
+
setState: (state: FlowState) => void;
|
|
35
|
+
/** Called after every state-changing commit, undo and redo. */
|
|
36
|
+
onMutated: () => void;
|
|
37
|
+
/** The op kinds beyond the canvas's own, from the app's plugins. */
|
|
38
|
+
opDefinitions?: readonly OpDefinition[];
|
|
39
|
+
/** Drops the ops a board refuses before they apply, such as an edit to a locked card. */
|
|
40
|
+
guard?: (nodes: readonly Node[], ops: Op[]) => Op[];
|
|
41
|
+
/** The clock for the coalescing window and the stuck-transaction guard. A test injects its own. */
|
|
42
|
+
now?: () => number;
|
|
43
|
+
}) {
|
|
44
|
+
const now = io.now ?? Date.now;
|
|
45
|
+
let undoStack = $state.raw<HistoryEntry[]>([]);
|
|
46
|
+
let redoStack = $state.raw<HistoryEntry[]>([]);
|
|
47
|
+
let depth = 0;
|
|
48
|
+
let baseline: FlowState | null = null;
|
|
49
|
+
let lastActivityAt = 0;
|
|
50
|
+
// The text session the top undo entry belongs to, while keystrokes keep merging into it.
|
|
51
|
+
let session: { key: string; at: number } | null = null;
|
|
52
|
+
|
|
53
|
+
// Cards are replaced, never edited in place, so holding the objects equals a deep copy for a pointer each.
|
|
54
|
+
function snapshot(): FlowState {
|
|
55
|
+
const state = io.getState();
|
|
56
|
+
return { nodes: [...state.nodes], edges: [...state.edges] };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isStale(): boolean {
|
|
60
|
+
return depth > 0 && now() - lastActivityAt > STUCK_TRANSACTION_MS;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Record the open transaction's diff as one history entry, merged into the text session it continues. */
|
|
64
|
+
function finish(coalesceKey?: string) {
|
|
65
|
+
depth = 0;
|
|
66
|
+
const before = baseline;
|
|
67
|
+
baseline = null;
|
|
68
|
+
const entry = before && diffFlowState(before, io.getState());
|
|
69
|
+
if (!entry) return;
|
|
70
|
+
const at = now();
|
|
71
|
+
const top = undoStack.at(-1);
|
|
72
|
+
if (
|
|
73
|
+
coalesceKey &&
|
|
74
|
+
top &&
|
|
75
|
+
session?.key === coalesceKey &&
|
|
76
|
+
at - session.at < COALESCE_WINDOW_MS
|
|
77
|
+
) {
|
|
78
|
+
undoStack = [...undoStack.slice(0, -1), mergeHistoryEntries(top, entry)];
|
|
79
|
+
session = { key: coalesceKey, at };
|
|
80
|
+
} else {
|
|
81
|
+
undoStack = [...undoStack.slice(-(MAX_ENTRIES - 1)), entry];
|
|
82
|
+
session = coalesceKey ? { key: coalesceKey, at } : null;
|
|
83
|
+
}
|
|
84
|
+
redoStack = [];
|
|
85
|
+
io.onMutated();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Force-close a transaction nobody closed, so undo and the next gesture work again. */
|
|
89
|
+
function recoverStuck() {
|
|
90
|
+
if (isStale()) finish();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Open a transaction. Nested calls join the outermost one. */
|
|
94
|
+
function begin() {
|
|
95
|
+
recoverStuck();
|
|
96
|
+
lastActivityAt = now();
|
|
97
|
+
if (depth === 0) baseline = snapshot();
|
|
98
|
+
depth += 1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Close a transaction. The outermost close diffs and records the entry. */
|
|
102
|
+
function commit(coalesceKey?: string) {
|
|
103
|
+
lastActivityAt = now();
|
|
104
|
+
if (depth > 1) {
|
|
105
|
+
depth -= 1;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// A commit with no begin has nothing to record.
|
|
109
|
+
if (depth === 1) finish(coalesceKey);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function run<T>(fn: () => T, coalesceKey?: string): T {
|
|
113
|
+
begin();
|
|
114
|
+
const res = tryCatch(fn);
|
|
115
|
+
commit(coalesceKey);
|
|
116
|
+
if (!res.ok) throw res.error;
|
|
117
|
+
return res.value;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Apply granular ops to the flow state inside a transaction. A `coalesceKey` folds the
|
|
122
|
+
* step into the top undo entry while that key keeps committing under a second apart.
|
|
123
|
+
*/
|
|
124
|
+
function dispatch(ops: Op | Op[], options?: { coalesceKey?: string }): boolean {
|
|
125
|
+
const asked = Array.isArray(ops) ? ops : [ops];
|
|
126
|
+
const list = io.guard ? io.guard(io.getState().nodes, asked) : asked;
|
|
127
|
+
if (list.length === 0) return true;
|
|
128
|
+
return run(() => {
|
|
129
|
+
const state = io.getState();
|
|
130
|
+
const res = tryCatch(() =>
|
|
131
|
+
applyOps(
|
|
132
|
+
{
|
|
133
|
+
// `applyOps` patches plain records so one op log replays over xyflow
|
|
134
|
+
// and persisted shapes. Widening in and narrowing out is that seam.
|
|
135
|
+
nodes: state.nodes as Record<string, unknown>[],
|
|
136
|
+
edges: state.edges as Record<string, unknown>[],
|
|
137
|
+
},
|
|
138
|
+
list,
|
|
139
|
+
io.opDefinitions,
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
if (!res.ok) return false;
|
|
143
|
+
io.setState({
|
|
144
|
+
nodes: res.value.nodes as unknown as Node[],
|
|
145
|
+
edges: res.value.edges as unknown as Edge[],
|
|
146
|
+
});
|
|
147
|
+
return true;
|
|
148
|
+
}, options?.coalesceKey);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// An open gesture owns the baseline, so a replay under it would fold into its entry.
|
|
152
|
+
function undo() {
|
|
153
|
+
recoverStuck();
|
|
154
|
+
session = null;
|
|
155
|
+
const entry = undoStack.at(-1);
|
|
156
|
+
if (!entry || depth > 0) return;
|
|
157
|
+
undoStack = undoStack.slice(0, -1);
|
|
158
|
+
redoStack = [...redoStack, entry];
|
|
159
|
+
io.setState(applyHistoryEntry(io.getState(), entry, "undo"));
|
|
160
|
+
io.onMutated();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function redo() {
|
|
164
|
+
recoverStuck();
|
|
165
|
+
session = null;
|
|
166
|
+
const entry = redoStack.at(-1);
|
|
167
|
+
if (!entry || depth > 0) return;
|
|
168
|
+
redoStack = redoStack.slice(0, -1);
|
|
169
|
+
undoStack = [...undoStack, entry];
|
|
170
|
+
io.setState(applyHistoryEntry(io.getState(), entry, "redo"));
|
|
171
|
+
io.onMutated();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
get canUndo() {
|
|
176
|
+
return undoStack.length > 0;
|
|
177
|
+
},
|
|
178
|
+
get canRedo() {
|
|
179
|
+
return redoStack.length > 0;
|
|
180
|
+
},
|
|
181
|
+
/** True while a gesture holds a transaction open. A stuck one reads false. */
|
|
182
|
+
get isOpen() {
|
|
183
|
+
return depth > 0 && !isStale();
|
|
184
|
+
},
|
|
185
|
+
begin,
|
|
186
|
+
/** A long gesture calls this on every frame, so a slow drag isn't mistaken for a stuck one. */
|
|
187
|
+
touch() {
|
|
188
|
+
if (depth > 0) lastActivityAt = now();
|
|
189
|
+
},
|
|
190
|
+
commit: () => commit(),
|
|
191
|
+
run: <T>(fn: () => T) => run(fn),
|
|
192
|
+
dispatch,
|
|
193
|
+
/** End the text session, so the next keystroke starts a new undo step. Call it on blur. */
|
|
194
|
+
endCoalesce() {
|
|
195
|
+
session = null;
|
|
196
|
+
},
|
|
197
|
+
undo,
|
|
198
|
+
redo,
|
|
199
|
+
clear() {
|
|
200
|
+
undoStack = [];
|
|
201
|
+
redoStack = [];
|
|
202
|
+
session = null;
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Typed Svelte context between the editor and the node and edge components inside it,
|
|
2
|
+
// so every component edit lands on the dispatcher (undoable, autosaved).
|
|
3
|
+
|
|
4
|
+
// Components must tolerate a missing context: read-only hosts like a slide's embedded
|
|
5
|
+
// canvas provide none.
|
|
6
|
+
|
|
7
|
+
import { getContext, setContext } from "svelte";
|
|
8
|
+
|
|
9
|
+
export type DiagramEditorContext = {
|
|
10
|
+
/** The canvas zoom, so a resize handle can stay one size on screen. */
|
|
11
|
+
canvasZoom?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Merge a partial data patch into a node. Undoable. With `coalesce`, patches to one node
|
|
14
|
+
* less than a second apart are one undo step, for per-keystroke text.
|
|
15
|
+
*/
|
|
16
|
+
patchNodeData: (
|
|
17
|
+
nodeId: string,
|
|
18
|
+
data: Record<string, unknown>,
|
|
19
|
+
options?: { coalesce?: boolean },
|
|
20
|
+
) => void;
|
|
21
|
+
/** End the coalescing text session so the next patch is a new undo step. Call it on blur. */
|
|
22
|
+
endTextEdit: () => void;
|
|
23
|
+
/** Set a node's width, keeping its style twin and its board slot in step.
|
|
24
|
+
* Undoable. */
|
|
25
|
+
setNodeWidth: (nodeId: string, width: number) => void;
|
|
26
|
+
/**
|
|
27
|
+
* Open an undo transaction around a gesture and return its commit. A resize
|
|
28
|
+
* writes the flow store itself, so open it before the first frame.
|
|
29
|
+
*/
|
|
30
|
+
startGesture: () => () => void;
|
|
31
|
+
/** Patch an edge (label, animated, type, style, handles). Undoable. */
|
|
32
|
+
patchEdge: (edgeId: string, patch: Record<string, unknown>) => void;
|
|
33
|
+
/** Remove one link. Undoable. */
|
|
34
|
+
removeEdge: (edgeId: string) => void;
|
|
35
|
+
/** Duplicate a node 50px down-right. Undoable. */
|
|
36
|
+
duplicateNode: (nodeId: string) => void;
|
|
37
|
+
/** Remove a node and its edges. Undoable. */
|
|
38
|
+
removeNode: (nodeId: string) => void;
|
|
39
|
+
/** Add a typed child under a container node. Undoable. */
|
|
40
|
+
addChildNode: (parentId: string, type: string) => void;
|
|
41
|
+
/**
|
|
42
|
+
* Register the inline-edit entry point for a node. The editor calls it after
|
|
43
|
+
* creating the node so the user can type the label immediately. Returns an
|
|
44
|
+
* unregister function.
|
|
45
|
+
*/
|
|
46
|
+
registerStartEdit: (nodeId: string, start: () => void) => () => void;
|
|
47
|
+
/** Ask a registered node to enter inline editing. */
|
|
48
|
+
requestStartEdit: (nodeId: string) => void;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const KEY = Symbol("diagram-editor-context");
|
|
52
|
+
|
|
53
|
+
/** An app widens the context with its own members: pass its type as `T`. */
|
|
54
|
+
export function setDiagramEditorContext<T extends DiagramEditorContext>(context: T) {
|
|
55
|
+
setContext(KEY, context);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function getDiagramEditorContext<
|
|
59
|
+
T extends DiagramEditorContext = DiagramEditorContext,
|
|
60
|
+
>(): T | null {
|
|
61
|
+
return getContext<T | undefined>(KEY) ?? null;
|
|
62
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// A mode packs one family of interactions, so the editor never branches on node
|
|
2
|
+
// types. Each controller is built once, so its state survives a mode switch.
|
|
3
|
+
|
|
4
|
+
import type { Node, Edge, InternalNode } from "@xyflow/svelte";
|
|
5
|
+
import type { CanvasViewport } from "../types.js";
|
|
6
|
+
import type { CanvasDispatcher } from "./dispatch.svelte.js";
|
|
7
|
+
|
|
8
|
+
/** A fraction of the pane, or a pixel margin per side, so a fit can clear the bars floating over it. */
|
|
9
|
+
export type FitPadding =
|
|
10
|
+
| number
|
|
11
|
+
| { top?: `${number}px`; right?: `${number}px`; bottom?: `${number}px`; left?: `${number}px` };
|
|
12
|
+
|
|
13
|
+
/** The xyflow instance surface the editor bridges via diagram-flow-helper. */
|
|
14
|
+
export type FlowActions = {
|
|
15
|
+
screenToFlowPosition: (pos: { x: number; y: number }) => { x: number; y: number };
|
|
16
|
+
fitView: (options?: {
|
|
17
|
+
padding?: FitPadding;
|
|
18
|
+
nodes?: { id: string }[];
|
|
19
|
+
maxZoom?: number;
|
|
20
|
+
/** Overrides the glide, in ms. 0 jumps. */
|
|
21
|
+
duration?: number;
|
|
22
|
+
}) => Promise<boolean>;
|
|
23
|
+
zoomIn: () => Promise<boolean>;
|
|
24
|
+
zoomOut: () => Promise<boolean>;
|
|
25
|
+
/** Zoom about the view centre to an exact scale. */
|
|
26
|
+
zoomTo: (zoom: number) => Promise<boolean>;
|
|
27
|
+
deleteElements: (params: {
|
|
28
|
+
nodes?: { id: string }[];
|
|
29
|
+
edges?: { id: string }[];
|
|
30
|
+
}) => Promise<{ deletedNodes: Node[]; deletedEdges: Edge[] }>;
|
|
31
|
+
toObject: () => { nodes: Node[]; edges: Edge[]; viewport: CanvasViewport };
|
|
32
|
+
/** Read alone, for motion that must stay one screen pixel at any scale. */
|
|
33
|
+
getViewport: () => CanvasViewport;
|
|
34
|
+
/** Glides; a pan to bring something on screen. */
|
|
35
|
+
setViewport: (viewport: CanvasViewport) => Promise<boolean>;
|
|
36
|
+
/** Measured size and absolute position, which a nested card's own position lacks. */
|
|
37
|
+
getInternalNode: (id: string) => InternalNode | undefined;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** What the editor lends a controller. All access is lazy (getter closures). */
|
|
41
|
+
export type EditorModeDeps = {
|
|
42
|
+
dispatcher: CanvasDispatcher;
|
|
43
|
+
getNodes: () => Node[];
|
|
44
|
+
setNodes: (nodes: Node[]) => void;
|
|
45
|
+
getEdges: () => Edge[];
|
|
46
|
+
setEdges: (edges: Edge[]) => void;
|
|
47
|
+
getFlowActions: () => FlowActions | null;
|
|
48
|
+
scheduleAutoSave: () => void;
|
|
49
|
+
/** Ask a mounted node component to enter inline editing. */
|
|
50
|
+
requestStartEdit: (nodeId: string) => void;
|
|
51
|
+
/** Select a fresh node, then open its inline editor once the layout settles. */
|
|
52
|
+
focusNewNode: (nodeId: string) => void;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The delegation surface. Every hook is optional, called on each active mode in
|
|
57
|
+
* the order the app lists them. `placeNode` returns true when it claimed the creation.
|
|
58
|
+
*/
|
|
59
|
+
export type EditorMode<Type extends string = string> = {
|
|
60
|
+
placeNode?: (type: Type, position: { x: number; y: number }) => boolean;
|
|
61
|
+
onNodeDragStart?: (targetNode: Node | null | undefined) => void;
|
|
62
|
+
onNodeDrag?: (targetNode: Node | null | undefined) => void;
|
|
63
|
+
onNodeDragStop?: (targetNode: Node | null | undefined) => void;
|
|
64
|
+
handleKeydown?: (event: KeyboardEvent) => void;
|
|
65
|
+
/** Runs inside the same dispatcher transaction that added the child. */
|
|
66
|
+
onChildAdded?: (parent: Node) => void;
|
|
67
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// A frame from the frame tool: a titled box that adopts the cards it covers.
|
|
2
|
+
// A group (Cmd+G) is the same box untitled.
|
|
3
|
+
|
|
4
|
+
import type { CanvasOp } from "../ops/ops.js";
|
|
5
|
+
|
|
6
|
+
export interface FrameRect {
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type Boxed = {
|
|
14
|
+
id: string;
|
|
15
|
+
position: { x: number; y: number };
|
|
16
|
+
measured?: { width?: number; height?: number };
|
|
17
|
+
width?: number | null;
|
|
18
|
+
height?: number | null;
|
|
19
|
+
parentId?: string;
|
|
20
|
+
type?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** A drag shorter than this on both sides was a click. */
|
|
24
|
+
const CLICK_DRAG = 8;
|
|
25
|
+
const DEFAULT_FRAME = { width: 480, height: 300 } as const;
|
|
26
|
+
const MIN_FRAME = { width: 80, height: 60 } as const;
|
|
27
|
+
|
|
28
|
+
/** The box a drag from one corner to the other covers, or the default size for a click. */
|
|
29
|
+
export function frameRect(from: { x: number; y: number }, to: { x: number; y: number }): FrameRect {
|
|
30
|
+
const width = Math.abs(to.x - from.x);
|
|
31
|
+
const height = Math.abs(to.y - from.y);
|
|
32
|
+
if (width < CLICK_DRAG && height < CLICK_DRAG) {
|
|
33
|
+
return { x: Math.round(from.x), y: Math.round(from.y), ...DEFAULT_FRAME };
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
x: Math.round(Math.min(from.x, to.x)),
|
|
37
|
+
y: Math.round(Math.min(from.y, to.y)),
|
|
38
|
+
width: Math.round(Math.max(width, MIN_FRAME.width)),
|
|
39
|
+
height: Math.round(Math.max(height, MIN_FRAME.height)),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sizeOf(node: Boxed): { width: number; height: number } | null {
|
|
44
|
+
const width = node.measured?.width ?? node.width;
|
|
45
|
+
const height = node.measured?.height ?? node.height;
|
|
46
|
+
return width && height ? { width, height } : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface FrameOptions {
|
|
50
|
+
/** The node type of the frame, the host's group type. */
|
|
51
|
+
type: string;
|
|
52
|
+
title: string;
|
|
53
|
+
/** Extra `data` on the frame beside its title. */
|
|
54
|
+
data?: Record<string, unknown>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The op list for one frame, and the id it was given. Top-level cards wholly inside the box become its children. */
|
|
58
|
+
export function frameOps(
|
|
59
|
+
rect: FrameRect,
|
|
60
|
+
nodes: readonly Boxed[],
|
|
61
|
+
{ type, title, data = {} }: FrameOptions,
|
|
62
|
+
): { id: string; ops: CanvasOp[] } {
|
|
63
|
+
const id = crypto.randomUUID();
|
|
64
|
+
const inside = nodes.filter((node) => {
|
|
65
|
+
const size = sizeOf(node);
|
|
66
|
+
if (!size || node.parentId || node.type === type) return false;
|
|
67
|
+
const { x, y } = node.position;
|
|
68
|
+
return (
|
|
69
|
+
x >= rect.x &&
|
|
70
|
+
y >= rect.y &&
|
|
71
|
+
x + size.width <= rect.x + rect.width &&
|
|
72
|
+
y + size.height <= rect.y + rect.height
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
const ops: CanvasOp[] = [
|
|
76
|
+
{
|
|
77
|
+
kind: "addNode",
|
|
78
|
+
node: {
|
|
79
|
+
id,
|
|
80
|
+
type,
|
|
81
|
+
position: { x: rect.x, y: rect.y },
|
|
82
|
+
data: { ...data, label: title, frame: true },
|
|
83
|
+
width: rect.width,
|
|
84
|
+
height: rect.height,
|
|
85
|
+
style: { width: rect.width, height: rect.height },
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
...inside.flatMap((node): CanvasOp[] => [
|
|
89
|
+
{ kind: "setParent", nodeId: node.id, parentId: id, extent: "parent" },
|
|
90
|
+
{
|
|
91
|
+
kind: "moveNode",
|
|
92
|
+
nodeId: node.id,
|
|
93
|
+
position: { x: node.position.x - rect.x, y: node.position.y - rect.y },
|
|
94
|
+
},
|
|
95
|
+
]),
|
|
96
|
+
];
|
|
97
|
+
return { id, ops };
|
|
98
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Whether the host page is drawn by hand. The host sets it once and the pen's
|
|
2
|
+
// text and shapes read it. With no host, they keep the app's look.
|
|
3
|
+
|
|
4
|
+
import { getContext, setContext } from "svelte";
|
|
5
|
+
|
|
6
|
+
const KEY = Symbol("canvas-hand-drawn");
|
|
7
|
+
|
|
8
|
+
/** Call during component init. `read` is a getter, so a toggle reaches mounted nodes. */
|
|
9
|
+
export function setHandDrawn(read: () => boolean): void {
|
|
10
|
+
setContext(KEY, read);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function useHandDrawn(): () => boolean {
|
|
14
|
+
return getContext<(() => boolean) | undefined>(KEY) ?? (() => false);
|
|
15
|
+
}
|