@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,239 @@
|
|
|
1
|
+
import { expectDefined } from "../test-utils.js";
|
|
2
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
3
|
+
import type { Node, Edge } from "@xyflow/svelte";
|
|
4
|
+
import {
|
|
5
|
+
applyHistoryEntry,
|
|
6
|
+
diffFlowState,
|
|
7
|
+
mergeHistoryEntries,
|
|
8
|
+
type FlowState,
|
|
9
|
+
} from "./history-core.js";
|
|
10
|
+
|
|
11
|
+
function assertNever(): never {
|
|
12
|
+
throw new Error("an unchanged card was serialised");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function node(id: string, overrides: Partial<Node> = {}): Node {
|
|
16
|
+
return {
|
|
17
|
+
id,
|
|
18
|
+
type: "node",
|
|
19
|
+
position: { x: 0, y: 0 },
|
|
20
|
+
data: { label: id },
|
|
21
|
+
...overrides,
|
|
22
|
+
} as Node;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function edge(id: string, source: string, target: string, overrides: Partial<Edge> = {}): Edge {
|
|
26
|
+
return { id, source, target, ...overrides } as Edge;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function state(nodes: Node[], edges: Edge[] = []): FlowState {
|
|
30
|
+
return { nodes, edges };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("diffFlowState", () => {
|
|
34
|
+
it("returns null when nothing changed", () => {
|
|
35
|
+
const a = state([node("a")], [edge("e", "a", "a")]);
|
|
36
|
+
const b = state([node("a")], [edge("e", "a", "a")]);
|
|
37
|
+
expect(diffFlowState(a, b)).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("ignores volatile fields: selection is not an undo step", () => {
|
|
41
|
+
const a = state([node("a")]);
|
|
42
|
+
const b = state([node("a", { selected: true, dragging: true } as Partial<Node>)]);
|
|
43
|
+
expect(diffFlowState(a, b)).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("captures only affected items, not the whole document", () => {
|
|
47
|
+
const untouched = node("keep");
|
|
48
|
+
const a = state([untouched, node("move")]);
|
|
49
|
+
const b = state([untouched, node("move", { position: { x: 100, y: 50 } })]);
|
|
50
|
+
const entry = expectDefined(diffFlowState(a, b), "diff entry");
|
|
51
|
+
expect(entry.nodesBefore.map((n) => n.id)).toEqual(["move"]);
|
|
52
|
+
expect(entry.nodesAfter.map((n) => n.id)).toEqual(["move"]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("never reads a card that is the same object on both sides", () => {
|
|
56
|
+
const untouched = { ...node("big"), toJSON: () => assertNever() };
|
|
57
|
+
const base = state([untouched, node("a")]);
|
|
58
|
+
const current = state([untouched, node("a", { position: { x: 5, y: 0 } })]);
|
|
59
|
+
const entry = expectDefined(diffFlowState(base, current));
|
|
60
|
+
expect(entry.nodesBefore.map((n) => n.id)).toEqual(["a"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("still sees a card that was replaced by an equal-looking copy as unchanged", () => {
|
|
64
|
+
const base = state([node("a")]);
|
|
65
|
+
const current = state([{ ...node("a"), selected: true }]);
|
|
66
|
+
expect(diffFlowState(base, current)).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("captures additions and removals", () => {
|
|
70
|
+
const a = state([node("a")], [edge("e1", "a", "a")]);
|
|
71
|
+
const b = state([node("b")], []);
|
|
72
|
+
const entry = expectDefined(diffFlowState(a, b), "diff entry");
|
|
73
|
+
expect(entry.nodesBefore.map((n) => n.id)).toEqual(["a"]);
|
|
74
|
+
expect(entry.nodesAfter.map((n) => n.id)).toEqual(["b"]);
|
|
75
|
+
expect(entry.edgesBefore.map((e) => e.id)).toEqual(["e1"]);
|
|
76
|
+
expect(entry.edgesAfter).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("applyHistoryEntry", () => {
|
|
81
|
+
it("undo restores a data edit, redo reapplies it", () => {
|
|
82
|
+
const before = state([node("a", { data: { label: "old" } })]);
|
|
83
|
+
const after = state([node("a", { data: { label: "new" } })]);
|
|
84
|
+
const entry = expectDefined(diffFlowState(before, after), "diff entry");
|
|
85
|
+
|
|
86
|
+
const undone = applyHistoryEntry(after, entry, "undo");
|
|
87
|
+
expect(undone.nodes[0].data.label).toBe("old");
|
|
88
|
+
|
|
89
|
+
const redone = applyHistoryEntry(undone, entry, "redo");
|
|
90
|
+
expect(redone.nodes[0].data.label).toBe("new");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("undo of a delete restores the node and its edges", () => {
|
|
94
|
+
const before = state([node("a"), node("b")], [edge("e", "a", "b")]);
|
|
95
|
+
const after = state([node("a")], []);
|
|
96
|
+
const entry = expectDefined(diffFlowState(before, after), "diff entry");
|
|
97
|
+
|
|
98
|
+
const undone = applyHistoryEntry(after, entry, "undo");
|
|
99
|
+
expect(undone.nodes.map((n) => n.id).sort()).toEqual(["a", "b"]);
|
|
100
|
+
expect(undone.edges.map((e) => e.id)).toEqual(["e"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("undo of an add removes the added node", () => {
|
|
104
|
+
const before = state([node("a")]);
|
|
105
|
+
const after = state([node("a"), node("b")]);
|
|
106
|
+
const entry = expectDefined(diffFlowState(before, after), "diff entry");
|
|
107
|
+
|
|
108
|
+
const undone = applyHistoryEntry(after, entry, "undo");
|
|
109
|
+
expect(undone.nodes.map((n) => n.id)).toEqual(["a"]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("restores parents before children for SvelteFlow ordering", () => {
|
|
113
|
+
const parent = node("parent");
|
|
114
|
+
const child = node("child", { parentId: "parent" } as Partial<Node>);
|
|
115
|
+
const before = state([parent, child]);
|
|
116
|
+
const after = state([]);
|
|
117
|
+
const entry = expectDefined(diffFlowState(before, after), "diff entry");
|
|
118
|
+
|
|
119
|
+
const undone = applyHistoryEntry(after, entry, "undo");
|
|
120
|
+
const ids = undone.nodes.map((n) => n.id);
|
|
121
|
+
expect(ids.indexOf("parent")).toBeLessThan(ids.indexOf("child"));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// A mind map reads child order from edge order: on 12 Oct 2026 undoing a reparent
|
|
125
|
+
// appended the restored edge, and the next relayout swapped Alpha's children.
|
|
126
|
+
it("undo of a reparent puts the restored edge back at its index, redo too", () => {
|
|
127
|
+
const nodes = [node("alpha"), node("gamma"), node("a1"), node("a2"), node("g1")];
|
|
128
|
+
const before = state(nodes, [
|
|
129
|
+
edge("e-alpha-a1", "alpha", "a1"),
|
|
130
|
+
edge("e-alpha-a2", "alpha", "a2"),
|
|
131
|
+
edge("e-gamma-g1", "gamma", "g1"),
|
|
132
|
+
]);
|
|
133
|
+
const after = state(nodes, [
|
|
134
|
+
edge("e-alpha-a2", "alpha", "a2"),
|
|
135
|
+
edge("e-gamma-a1", "gamma", "a1"),
|
|
136
|
+
edge("e-gamma-g1", "gamma", "g1"),
|
|
137
|
+
]);
|
|
138
|
+
const entry = expectDefined(diffFlowState(before, after), "diff entry");
|
|
139
|
+
|
|
140
|
+
const undone = applyHistoryEntry(after, entry, "undo");
|
|
141
|
+
expect(undone.edges.map((e) => e.id)).toEqual(["e-alpha-a1", "e-alpha-a2", "e-gamma-g1"]);
|
|
142
|
+
const redone = applyHistoryEntry(undone, entry, "redo");
|
|
143
|
+
expect(redone.edges.map((e) => e.id)).toEqual(["e-alpha-a2", "e-gamma-a1", "e-gamma-g1"]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// A reorder under the same parent keeps the edge's id and content, so only its index moved.
|
|
147
|
+
it("undo of a sibling reorder restores the edge order", () => {
|
|
148
|
+
const before = state(
|
|
149
|
+
[node("a1"), node("a2", { position: { x: 0, y: 60 } })],
|
|
150
|
+
[edge("e-alpha-a1", "alpha", "a1"), edge("e-alpha-a2", "alpha", "a2")],
|
|
151
|
+
);
|
|
152
|
+
const after = state(
|
|
153
|
+
[node("a1", { position: { x: 0, y: 60 } }), node("a2")],
|
|
154
|
+
[edge("e-alpha-a2", "alpha", "a2"), edge("e-alpha-a1", "alpha", "a1")],
|
|
155
|
+
);
|
|
156
|
+
const entry = expectDefined(diffFlowState(before, after), "diff entry");
|
|
157
|
+
|
|
158
|
+
const undone = applyHistoryEntry(after, entry, "undo");
|
|
159
|
+
expect(undone.edges.map((e) => e.id)).toEqual(["e-alpha-a1", "e-alpha-a2"]);
|
|
160
|
+
const redone = applyHistoryEntry(undone, entry, "redo");
|
|
161
|
+
expect(redone.edges.map((e) => e.id)).toEqual(["e-alpha-a2", "e-alpha-a1"]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("undo of a delete puts nodes back at their index, without regrouping a valid order", () => {
|
|
165
|
+
const parent = node("parent");
|
|
166
|
+
const before = state([parent, node("free"), node("child", { parentId: "parent" })]);
|
|
167
|
+
const after = state([parent]);
|
|
168
|
+
const entry = expectDefined(diffFlowState(before, after), "diff entry");
|
|
169
|
+
|
|
170
|
+
const undone = applyHistoryEntry(after, entry, "undo");
|
|
171
|
+
expect(undone.nodes.map((n) => n.id)).toEqual(["parent", "free", "child"]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("round-trips a compound change (move + edit + add + remove)", () => {
|
|
175
|
+
const before = state(
|
|
176
|
+
[node("stay"), node("move"), node("gone", { data: { label: "bye" } })],
|
|
177
|
+
[edge("e1", "stay", "move")],
|
|
178
|
+
);
|
|
179
|
+
const after = state(
|
|
180
|
+
[
|
|
181
|
+
node("stay"),
|
|
182
|
+
node("move", { position: { x: 40, y: 40 }, data: { label: "moved" } }),
|
|
183
|
+
node("fresh"),
|
|
184
|
+
],
|
|
185
|
+
[edge("e1", "stay", "move", { label: "renamed" } as Partial<Edge>)],
|
|
186
|
+
);
|
|
187
|
+
const entry = expectDefined(diffFlowState(before, after), "diff entry");
|
|
188
|
+
|
|
189
|
+
const undone = applyHistoryEntry(after, entry, "undo");
|
|
190
|
+
expect(diffFlowState(before, undone)).toBeNull();
|
|
191
|
+
|
|
192
|
+
const redone = applyHistoryEntry(undone, entry, "redo");
|
|
193
|
+
expect(diffFlowState(after, redone)).toBeNull();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("mergeHistoryEntries", () => {
|
|
198
|
+
it("keeps the earliest before and the latest after for a node both touched", () => {
|
|
199
|
+
const s0 = state([node("a", { data: { label: "0" } })]);
|
|
200
|
+
const s1 = state([node("a", { data: { label: "1" } })]);
|
|
201
|
+
const s2 = state([node("a", { data: { label: "2" } })]);
|
|
202
|
+
const merged = mergeHistoryEntries(
|
|
203
|
+
expectDefined(diffFlowState(s0, s1), "first"),
|
|
204
|
+
expectDefined(diffFlowState(s1, s2), "second"),
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
expect(applyHistoryEntry(s2, merged, "undo").nodes[0].data.label).toBe("0");
|
|
208
|
+
expect(applyHistoryEntry(s0, merged, "redo").nodes[0].data.label).toBe("2");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("merges different nodes, and a node added then edited has no before", () => {
|
|
212
|
+
const s0 = state([node("a")]);
|
|
213
|
+
const s1 = state([node("a"), node("b", { data: { label: "new" } })]);
|
|
214
|
+
const s2 = state([
|
|
215
|
+
node("a", { position: { x: 9, y: 9 } }),
|
|
216
|
+
node("b", { data: { label: "newer" } }),
|
|
217
|
+
]);
|
|
218
|
+
const merged = mergeHistoryEntries(
|
|
219
|
+
expectDefined(diffFlowState(s0, s1), "first"),
|
|
220
|
+
expectDefined(diffFlowState(s1, s2), "second"),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
expect(merged.nodesBefore.map((n) => n.id)).toEqual(["a"]);
|
|
224
|
+
expect(merged.nodesAfter.map((n) => n.id).sort()).toEqual(["a", "b"]);
|
|
225
|
+
expect(applyHistoryEntry(s2, merged, "undo").nodes.map((n) => n.id)).toEqual(["a"]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("carries node order from the earliest before to the latest after", () => {
|
|
229
|
+
const s0 = state([node("a"), node("b"), node("c")]);
|
|
230
|
+
const s1 = state([node("b"), node("a", { position: { x: 5, y: 5 } }), node("c")]);
|
|
231
|
+
const s2 = state([node("b"), node("c"), node("a", { position: { x: 1, y: 1 } })]);
|
|
232
|
+
const merged = mergeHistoryEntries(
|
|
233
|
+
expectDefined(diffFlowState(s0, s1), "first"),
|
|
234
|
+
expectDefined(diffFlowState(s1, s2), "second"),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
expect(merged.nodeOrder).toEqual({ before: ["a", "b", "c"], after: ["b", "c", "a"] });
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Pure diff/apply core for diagram history. Each entry holds only the touched
|
|
2
|
+
// nodes and edges, so ops, layouts and xyflow's own writes share one mechanism.
|
|
3
|
+
|
|
4
|
+
import type { Node, Edge } from "@xyflow/svelte";
|
|
5
|
+
import { sortParentsFirst } from "../headless/canvas-cleanup.js";
|
|
6
|
+
|
|
7
|
+
export type FlowState = { nodes: Node[]; edges: Edge[] };
|
|
8
|
+
|
|
9
|
+
/** Each side's id sequence, kept only when the step changed it. */
|
|
10
|
+
export type IdOrder = { before: string[]; after: string[] };
|
|
11
|
+
|
|
12
|
+
export type HistoryEntry = {
|
|
13
|
+
nodesBefore: Node[];
|
|
14
|
+
nodesAfter: Node[];
|
|
15
|
+
edgesBefore: Edge[];
|
|
16
|
+
edgesAfter: Edge[];
|
|
17
|
+
// WHY: array order is data. Node order is stacking, edge order is mind map child order.
|
|
18
|
+
nodeOrder?: IdOrder;
|
|
19
|
+
edgeOrder?: IdOrder;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Fields that churn without changing the document. Stripped, so selecting isn't
|
|
23
|
+
// an undo step and undo doesn't restore a stale selection.
|
|
24
|
+
const VOLATILE_NODE_FIELDS = new Set(["selected", "dragging", "measured", "resizing"]);
|
|
25
|
+
const VOLATILE_EDGE_FIELDS = new Set(["selected"]);
|
|
26
|
+
|
|
27
|
+
function stripVolatile<T extends Record<string, unknown>>(record: T, volatile: Set<string>): T {
|
|
28
|
+
const out: Record<string, unknown> = {};
|
|
29
|
+
for (const [key, value] of Object.entries(record)) {
|
|
30
|
+
if (volatile.has(key) || value === undefined) continue;
|
|
31
|
+
out[key] = value;
|
|
32
|
+
}
|
|
33
|
+
return out as T;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function clone<T>(value: T): T {
|
|
37
|
+
return JSON.parse(JSON.stringify(value)) as T;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function signature(record: Record<string, unknown>, volatile: Set<string>): string {
|
|
41
|
+
return JSON.stringify(stripVolatile(record, volatile));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** True when two versions of a card differ only in fields that churn without changing the document. */
|
|
45
|
+
export function isSameNode(a: Node, b: Node): boolean {
|
|
46
|
+
return (
|
|
47
|
+
a === b ||
|
|
48
|
+
signature(a as Record<string, unknown>, VOLATILE_NODE_FIELDS) ===
|
|
49
|
+
signature(b as Record<string, unknown>, VOLATILE_NODE_FIELDS)
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isSameEdge(a: Edge, b: Edge): boolean {
|
|
54
|
+
return (
|
|
55
|
+
a === b ||
|
|
56
|
+
signature(a as Record<string, unknown>, VOLATILE_EDGE_FIELDS) ===
|
|
57
|
+
signature(b as Record<string, unknown>, VOLATILE_EDGE_FIELDS)
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function diffOrder(base: { id: string }[], current: { id: string }[]): IdOrder | undefined {
|
|
62
|
+
const before = base.map((item) => item.id);
|
|
63
|
+
const after = current.map((item) => item.id);
|
|
64
|
+
const same = before.length === after.length && before.every((id, i) => id === after[i]);
|
|
65
|
+
return same ? undefined : { before, after };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function diffCollection<T extends { id: string }>(
|
|
69
|
+
base: T[],
|
|
70
|
+
current: T[],
|
|
71
|
+
volatile: Set<string>,
|
|
72
|
+
): { before: T[]; after: T[] } {
|
|
73
|
+
const baseById = new Map(base.map((item) => [item.id, item]));
|
|
74
|
+
const currentById = new Map(current.map((item) => [item.id, item]));
|
|
75
|
+
const before: T[] = [];
|
|
76
|
+
const after: T[] = [];
|
|
77
|
+
|
|
78
|
+
for (const item of base) {
|
|
79
|
+
const counterpart = currentById.get(item.id);
|
|
80
|
+
if (!counterpart) {
|
|
81
|
+
before.push(clone(stripVolatile(item as Record<string, unknown>, volatile) as T));
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
// The same object is the same card: an untouched card costs nothing, however big the board.
|
|
85
|
+
if (
|
|
86
|
+
item !== counterpart &&
|
|
87
|
+
signature(item as Record<string, unknown>, volatile) !==
|
|
88
|
+
signature(counterpart as Record<string, unknown>, volatile)
|
|
89
|
+
) {
|
|
90
|
+
before.push(clone(stripVolatile(item as Record<string, unknown>, volatile) as T));
|
|
91
|
+
after.push(clone(stripVolatile(counterpart as Record<string, unknown>, volatile) as T));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const item of current) {
|
|
95
|
+
if (!baseById.has(item.id)) {
|
|
96
|
+
after.push(clone(stripVolatile(item as Record<string, unknown>, volatile) as T));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { before, after };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Diff two flow states into a history entry, or null when nothing changed. An order change
|
|
104
|
+
* alone is no step, so a bare reshuffle adds no undo entry.
|
|
105
|
+
*/
|
|
106
|
+
export function diffFlowState(base: FlowState, current: FlowState): HistoryEntry | null {
|
|
107
|
+
const nodes = diffCollection(base.nodes, current.nodes, VOLATILE_NODE_FIELDS);
|
|
108
|
+
const edges = diffCollection(base.edges, current.edges, VOLATILE_EDGE_FIELDS);
|
|
109
|
+
if (
|
|
110
|
+
nodes.before.length === 0 &&
|
|
111
|
+
nodes.after.length === 0 &&
|
|
112
|
+
edges.before.length === 0 &&
|
|
113
|
+
edges.after.length === 0
|
|
114
|
+
) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
const nodeOrder = diffOrder(base.nodes, current.nodes);
|
|
118
|
+
const edgeOrder = diffOrder(base.edges, current.edges);
|
|
119
|
+
return {
|
|
120
|
+
nodesBefore: nodes.before,
|
|
121
|
+
nodesAfter: nodes.after,
|
|
122
|
+
edgesBefore: edges.before,
|
|
123
|
+
edgesAfter: edges.after,
|
|
124
|
+
...(nodeOrder && { nodeOrder }),
|
|
125
|
+
...(edgeOrder && { edgeOrder }),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Stable sort into `order`. An id the entry never saw keeps its place after the known ones. */
|
|
130
|
+
function sortByOrder<T extends { id: string }>(items: T[], order: string[] | undefined): T[] {
|
|
131
|
+
if (!order) return items;
|
|
132
|
+
const rank = new Map(order.map((id, index) => [id, index]));
|
|
133
|
+
const at = (item: T) => rank.get(item.id) ?? order.length;
|
|
134
|
+
return items.toSorted((a, b) => at(a) - at(b));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Only a child ahead of its parent breaks SvelteFlow, so a valid order is never regrouped. */
|
|
138
|
+
function parentsFirst(nodes: Node[]): Node[] {
|
|
139
|
+
const seen = new Set<string>();
|
|
140
|
+
const ids = new Set(nodes.map((node) => node.id));
|
|
141
|
+
for (const node of nodes) {
|
|
142
|
+
if (node.parentId && ids.has(node.parentId) && !seen.has(node.parentId)) {
|
|
143
|
+
return sortParentsFirst(nodes);
|
|
144
|
+
}
|
|
145
|
+
seen.add(node.id);
|
|
146
|
+
}
|
|
147
|
+
return nodes;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function applyCollection<T extends { id: string }>(items: T[], restore: T[], discard: T[]): T[] {
|
|
151
|
+
const restoreById = new Map(restore.map((item) => [item.id, item]));
|
|
152
|
+
const discardIds = new Set(discard.map((item) => item.id));
|
|
153
|
+
// Drop pure additions of the direction being reverted.
|
|
154
|
+
let next = items.filter((item) => !(discardIds.has(item.id) && !restoreById.has(item.id)));
|
|
155
|
+
// Restore changed items in place.
|
|
156
|
+
const present = new Set(next.map((item) => item.id));
|
|
157
|
+
next = next.map((item) => {
|
|
158
|
+
const restored = restoreById.get(item.id);
|
|
159
|
+
if (!restored) return item;
|
|
160
|
+
// Selection is where you are, not what the document held, so an undo leaves it be.
|
|
161
|
+
const selected = (item as { selected?: boolean }).selected;
|
|
162
|
+
return { ...clone(restored), ...(selected !== undefined && { selected }) };
|
|
163
|
+
});
|
|
164
|
+
// Re-add items that were removed in the direction being reverted.
|
|
165
|
+
for (const item of restore) {
|
|
166
|
+
if (!present.has(item.id)) next.push(clone(item));
|
|
167
|
+
}
|
|
168
|
+
return next;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Apply a history entry to a state: "undo" restores the before side, "redo" the after side. */
|
|
172
|
+
export function applyHistoryEntry(
|
|
173
|
+
state: FlowState,
|
|
174
|
+
entry: HistoryEntry,
|
|
175
|
+
direction: "undo" | "redo",
|
|
176
|
+
): FlowState {
|
|
177
|
+
const [restoreNodes, discardNodes] =
|
|
178
|
+
direction === "undo"
|
|
179
|
+
? [entry.nodesBefore, entry.nodesAfter]
|
|
180
|
+
: [entry.nodesAfter, entry.nodesBefore];
|
|
181
|
+
const [restoreEdges, discardEdges] =
|
|
182
|
+
direction === "undo"
|
|
183
|
+
? [entry.edgesBefore, entry.edgesAfter]
|
|
184
|
+
: [entry.edgesAfter, entry.edgesBefore];
|
|
185
|
+
const side = direction === "undo" ? "before" : "after";
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
// WHY parentsFirst: SvelteFlow requires parents before children; a
|
|
189
|
+
// re-added container must land ahead of the children it re-adopts.
|
|
190
|
+
nodes: parentsFirst(
|
|
191
|
+
sortByOrder(
|
|
192
|
+
applyCollection(state.nodes, restoreNodes, discardNodes),
|
|
193
|
+
entry.nodeOrder?.[side],
|
|
194
|
+
),
|
|
195
|
+
),
|
|
196
|
+
edges: sortByOrder(
|
|
197
|
+
applyCollection(state.edges, restoreEdges, discardEdges),
|
|
198
|
+
entry.edgeOrder?.[side],
|
|
199
|
+
),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** An id's earliest before and latest after, so a node touched by both steps keeps its outer states. */
|
|
204
|
+
function mergeSides<T extends { id: string }>(
|
|
205
|
+
first: { before: T[]; after: T[] },
|
|
206
|
+
second: { before: T[]; after: T[] },
|
|
207
|
+
): { before: T[]; after: T[] } {
|
|
208
|
+
const firstTouched = new Set([...first.before, ...first.after].map((item) => item.id));
|
|
209
|
+
const secondTouched = new Set([...second.before, ...second.after].map((item) => item.id));
|
|
210
|
+
const before = [...first.before, ...second.before.filter((item) => !firstTouched.has(item.id))];
|
|
211
|
+
const after = [...first.after.filter((item) => !secondTouched.has(item.id)), ...second.after];
|
|
212
|
+
return { before, after };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function sameIds(a: string[], b: string[]): boolean {
|
|
216
|
+
return a.length === b.length && a.every((id, i) => id === b[i]);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** No order change on one side means that step left the order alone, so the other side's end stands in. */
|
|
220
|
+
function mergeOrder(first: IdOrder | undefined, second: IdOrder | undefined): IdOrder | undefined {
|
|
221
|
+
const before = first?.before ?? second?.before;
|
|
222
|
+
const after = second?.after ?? first?.after;
|
|
223
|
+
if (!before || !after) return undefined;
|
|
224
|
+
return sameIds(before, after) ? undefined : { before, after };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Fold two consecutive steps into one: undoing it restores the state before `first`. */
|
|
228
|
+
export function mergeHistoryEntries(first: HistoryEntry, second: HistoryEntry): HistoryEntry {
|
|
229
|
+
const nodes = mergeSides(
|
|
230
|
+
{ before: first.nodesBefore, after: first.nodesAfter },
|
|
231
|
+
{ before: second.nodesBefore, after: second.nodesAfter },
|
|
232
|
+
);
|
|
233
|
+
const edges = mergeSides(
|
|
234
|
+
{ before: first.edgesBefore, after: first.edgesAfter },
|
|
235
|
+
{ before: second.edgesBefore, after: second.edgesAfter },
|
|
236
|
+
);
|
|
237
|
+
const nodeOrder = mergeOrder(first.nodeOrder, second.nodeOrder);
|
|
238
|
+
const edgeOrder = mergeOrder(first.edgeOrder, second.edgeOrder);
|
|
239
|
+
return {
|
|
240
|
+
nodesBefore: nodes.before,
|
|
241
|
+
nodesAfter: nodes.after,
|
|
242
|
+
edgesBefore: edges.before,
|
|
243
|
+
edgesAfter: edges.after,
|
|
244
|
+
...(nodeOrder && { nodeOrder }),
|
|
245
|
+
...(edgeOrder && { edgeOrder }),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
2
|
+
import {
|
|
3
|
+
resolveEditorKeyAction,
|
|
4
|
+
resolveNudge,
|
|
5
|
+
isEditingTarget,
|
|
6
|
+
isPanActivationKey,
|
|
7
|
+
} from "./keymap.js";
|
|
8
|
+
|
|
9
|
+
function press(
|
|
10
|
+
key: string,
|
|
11
|
+
opts: { meta?: boolean; shift?: boolean; editing?: boolean } = {},
|
|
12
|
+
): KeyboardEvent {
|
|
13
|
+
const target = opts.editing ? { tagName: "INPUT" } : { tagName: "DIV" };
|
|
14
|
+
return {
|
|
15
|
+
key,
|
|
16
|
+
metaKey: opts.meta ?? false,
|
|
17
|
+
ctrlKey: false,
|
|
18
|
+
shiftKey: opts.shift ?? false,
|
|
19
|
+
target,
|
|
20
|
+
} as unknown as KeyboardEvent;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("resolveEditorKeyAction", () => {
|
|
24
|
+
it("maps the undo and redo chords", () => {
|
|
25
|
+
expect(resolveEditorKeyAction(press("z", { meta: true }), false)).toBe("undo");
|
|
26
|
+
expect(resolveEditorKeyAction(press("z", { meta: true, shift: true }), false)).toBe("redo");
|
|
27
|
+
expect(resolveEditorKeyAction(press("y", { meta: true }), false)).toBe("redo");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("maps the clipboard chords, and leaves them to a field being typed in", () => {
|
|
31
|
+
expect(resolveEditorKeyAction(press("c", { meta: true }), false)).toBe("copy");
|
|
32
|
+
expect(resolveEditorKeyAction(press("v", { meta: true }), false)).toBe("paste");
|
|
33
|
+
expect(resolveEditorKeyAction(press("a", { meta: true }), false)).toBe("select-all");
|
|
34
|
+
expect(resolveEditorKeyAction(press("c", { meta: true, editing: true }), false)).toBeNull();
|
|
35
|
+
expect(resolveEditorKeyAction(press("a", { meta: true, editing: true }), false)).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("saves whatever has focus", () => {
|
|
39
|
+
expect(resolveEditorKeyAction(press("s", { meta: true }), false)).toBe("save");
|
|
40
|
+
expect(resolveEditorKeyAction(press("s", { meta: true, editing: true }), false)).toBe("save");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("deletes only with a selection, and never while typing", () => {
|
|
44
|
+
expect(resolveEditorKeyAction(press("Delete"), true)).toBe("delete");
|
|
45
|
+
expect(resolveEditorKeyAction(press("Backspace"), true)).toBe("delete");
|
|
46
|
+
expect(resolveEditorKeyAction(press("Delete"), false)).toBeNull();
|
|
47
|
+
expect(resolveEditorKeyAction(press("Backspace", { editing: true }), true)).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("claims nothing else", () => {
|
|
51
|
+
expect(resolveEditorKeyAction(press("Tab"), true)).toBeNull();
|
|
52
|
+
expect(resolveEditorKeyAction(press("k"), true)).toBeNull();
|
|
53
|
+
expect(resolveEditorKeyAction(press("b", { meta: true }), true)).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("isEditingTarget", () => {
|
|
58
|
+
it("names the elements that own their own keys", () => {
|
|
59
|
+
expect(isEditingTarget({ tagName: "INPUT" } as unknown as EventTarget)).toBe(true);
|
|
60
|
+
expect(isEditingTarget({ tagName: "TEXTAREA" } as unknown as EventTarget)).toBe(true);
|
|
61
|
+
expect(
|
|
62
|
+
isEditingTarget({ tagName: "DIV", isContentEditable: true } as unknown as EventTarget),
|
|
63
|
+
).toBe(true);
|
|
64
|
+
expect(isEditingTarget({ tagName: "DIV" } as unknown as EventTarget)).toBe(false);
|
|
65
|
+
expect(isEditingTarget(null)).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("isPanActivationKey", () => {
|
|
70
|
+
function space(
|
|
71
|
+
target: Record<string, unknown> = { tagName: "DIV" },
|
|
72
|
+
mods: { meta?: boolean; ctrl?: boolean; alt?: boolean } = {},
|
|
73
|
+
): KeyboardEvent {
|
|
74
|
+
return {
|
|
75
|
+
code: "Space",
|
|
76
|
+
key: " ",
|
|
77
|
+
metaKey: mods.meta ?? false,
|
|
78
|
+
ctrlKey: mods.ctrl ?? false,
|
|
79
|
+
altKey: mods.alt ?? false,
|
|
80
|
+
target: { getAttribute: () => null, ...target },
|
|
81
|
+
} as unknown as KeyboardEvent;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
it("is the hand tool over the canvas", () => {
|
|
85
|
+
expect(isPanActivationKey(space())).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("leaves space to a field being typed in", () => {
|
|
89
|
+
expect(isPanActivationKey(space({ tagName: "TEXTAREA" }))).toBe(false);
|
|
90
|
+
expect(isPanActivationKey(space({ tagName: "DIV", isContentEditable: true }))).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("leaves space to a control that activates on it", () => {
|
|
94
|
+
expect(isPanActivationKey(space({ tagName: "BUTTON" }))).toBe(false);
|
|
95
|
+
expect(isPanActivationKey(space({ tagName: "DIV", getAttribute: () => "button" }))).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("claims no other key, and no chord", () => {
|
|
99
|
+
expect(isPanActivationKey({ code: "KeyS", key: "s" } as KeyboardEvent)).toBe(false);
|
|
100
|
+
expect(isPanActivationKey(space({ tagName: "DIV" }, { meta: true }))).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("the editor chords and zoom keys", () => {
|
|
105
|
+
const code = (key: string, codeName: string, shift = false) =>
|
|
106
|
+
({
|
|
107
|
+
key,
|
|
108
|
+
code: codeName,
|
|
109
|
+
metaKey: false,
|
|
110
|
+
ctrlKey: false,
|
|
111
|
+
altKey: false,
|
|
112
|
+
shiftKey: shift,
|
|
113
|
+
target: { tagName: "DIV" },
|
|
114
|
+
}) as unknown as KeyboardEvent;
|
|
115
|
+
|
|
116
|
+
it("maps duplicate, group and ungroup", () => {
|
|
117
|
+
expect(resolveEditorKeyAction(press("d", { meta: true }), false)).toBe("duplicate");
|
|
118
|
+
expect(resolveEditorKeyAction(press("g", { meta: true }), false)).toBe("group");
|
|
119
|
+
expect(resolveEditorKeyAction(press("g", { meta: true, shift: true }), false)).toBe("ungroup");
|
|
120
|
+
expect(resolveEditorKeyAction(press("d", { meta: true, editing: true }), false)).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("maps chord and plain zoom", () => {
|
|
124
|
+
expect(resolveEditorKeyAction(press("=", { meta: true }), false)).toBe("zoom-in");
|
|
125
|
+
expect(resolveEditorKeyAction(press("-", { meta: true }), false)).toBe("zoom-out");
|
|
126
|
+
expect(resolveEditorKeyAction(code("+", "Equal", true), false)).toBe("zoom-in");
|
|
127
|
+
expect(resolveEditorKeyAction(code("-", "Minus"), false)).toBe("zoom-out");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("maps Shift with 0, 1 and 2 by key code", () => {
|
|
131
|
+
expect(resolveEditorKeyAction(code(")", "Digit0", true), false)).toBe("zoom-100");
|
|
132
|
+
expect(resolveEditorKeyAction(code("!", "Digit1", true), false)).toBe("zoom-fit");
|
|
133
|
+
expect(resolveEditorKeyAction(code("@", "Digit2", true), false)).toBe("zoom-selection");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("stays out of a field being typed in", () => {
|
|
137
|
+
expect(resolveEditorKeyAction(press("-", { editing: true }), false)).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("resolveNudge", () => {
|
|
142
|
+
const arrow = (key: string, shift = false, editing = false) =>
|
|
143
|
+
({
|
|
144
|
+
key,
|
|
145
|
+
metaKey: false,
|
|
146
|
+
ctrlKey: false,
|
|
147
|
+
altKey: false,
|
|
148
|
+
shiftKey: shift,
|
|
149
|
+
target: { tagName: editing ? "TEXTAREA" : "DIV" },
|
|
150
|
+
}) as unknown as KeyboardEvent;
|
|
151
|
+
|
|
152
|
+
it("moves 1px, or 10px with Shift", () => {
|
|
153
|
+
expect(resolveNudge(arrow("ArrowLeft"))).toEqual({ dx: -1, dy: 0 });
|
|
154
|
+
expect(resolveNudge(arrow("ArrowDown", true))).toEqual({ dx: 0, dy: 10 });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("ignores other keys and fields", () => {
|
|
158
|
+
expect(resolveNudge(arrow("a"))).toBeNull();
|
|
159
|
+
expect(resolveNudge(arrow("ArrowUp", false, true))).toBeNull();
|
|
160
|
+
});
|
|
161
|
+
});
|