@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,208 @@
|
|
|
1
|
+
import { expectDefined } from "../test-utils.js";
|
|
2
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
3
|
+
import { applyOps } from "../ops/ops.js";
|
|
4
|
+
import { diffToOps, rebaseDiagram, type DiagramDoc } from "./rebase.js";
|
|
5
|
+
|
|
6
|
+
function doc(nodes: DiagramDoc["nodes"], edges: DiagramDoc["edges"] = []): DiagramDoc {
|
|
7
|
+
return { nodes, edges };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function n(id: string, overrides: Record<string, unknown> = {}): DiagramDoc["nodes"][number] {
|
|
11
|
+
return { id, type: "node", position: { x: 0, y: 0 }, data: { label: id }, ...overrides };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("diffToOps", () => {
|
|
15
|
+
it("returns no ops for identical docs", () => {
|
|
16
|
+
expect(diffToOps(doc([n("a")]), doc([n("a")]))).toEqual([]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("replaying the diff on the base reproduces the current doc", () => {
|
|
20
|
+
const base = doc(
|
|
21
|
+
[n("stay"), n("move"), n("gone"), n("container", { type: "space" })],
|
|
22
|
+
[{ id: "e1", source: "stay", target: "move" }],
|
|
23
|
+
);
|
|
24
|
+
const current = doc(
|
|
25
|
+
[
|
|
26
|
+
n("stay"),
|
|
27
|
+
n("move", { position: { x: 120, y: 80 }, data: { label: "renamed" } }),
|
|
28
|
+
n("container", { type: "space", style: { width: 400, height: 300 } }),
|
|
29
|
+
n("child", { parentId: "container", position: { x: 20, y: 60 } }),
|
|
30
|
+
],
|
|
31
|
+
[
|
|
32
|
+
{ id: "e1", source: "stay", target: "move", label: "flows" },
|
|
33
|
+
{ id: "e2", source: "stay", target: "child" },
|
|
34
|
+
],
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const ops = diffToOps(base, current);
|
|
38
|
+
const replayed = applyOps({ nodes: base.nodes, edges: base.edges }, ops);
|
|
39
|
+
|
|
40
|
+
const byId = new Map(replayed.nodes.map((node) => [node.id, node]));
|
|
41
|
+
expect(byId.has("gone")).toBe(false);
|
|
42
|
+
expect(byId.get("move")?.position).toEqual({ x: 120, y: 80 });
|
|
43
|
+
expect(
|
|
44
|
+
(expectDefined(byId.get("move"), "entry move").data as Record<string, unknown>).label,
|
|
45
|
+
).toBe("renamed");
|
|
46
|
+
expect(byId.get("container")?.style).toEqual({ width: 400, height: 300 });
|
|
47
|
+
expect(byId.get("child")?.parentId).toBe("container");
|
|
48
|
+
// parentId implies extent at the op seam
|
|
49
|
+
expect(byId.get("child")?.extent).toBe("parent");
|
|
50
|
+
const edgeById = new Map(replayed.edges.map((edge) => [edge.id, edge]));
|
|
51
|
+
expect(edgeById.get("e1")?.label).toBe("flows");
|
|
52
|
+
expect(edgeById.has("e2")).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("orders added parents before added children", () => {
|
|
56
|
+
const base = doc([]);
|
|
57
|
+
const current = doc([
|
|
58
|
+
n("child", { parentId: "parent", position: { x: 10, y: 10 } }),
|
|
59
|
+
n("parent", { type: "space" }),
|
|
60
|
+
]);
|
|
61
|
+
// Would throw "Unknown parentId" if the child op ran first.
|
|
62
|
+
const replayed = applyOps({ nodes: [], edges: [] }, diffToOps(base, current));
|
|
63
|
+
expect(replayed.nodes).toHaveLength(2);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("rebaseDiagram", () => {
|
|
68
|
+
const lastSaved = doc([n("a"), n("b")]);
|
|
69
|
+
|
|
70
|
+
it("replays local changes on top of the server doc", () => {
|
|
71
|
+
// Server: an agent added "agent-node". Local: the user renamed "a".
|
|
72
|
+
const serverDoc = doc([n("a"), n("b"), n("agent-node")]);
|
|
73
|
+
const local = doc([n("a", { data: { label: "renamed" } }), n("b")]);
|
|
74
|
+
|
|
75
|
+
const rebased = expectDefined(rebaseDiagram(serverDoc, lastSaved, local), "rebased doc");
|
|
76
|
+
const byId = new Map(rebased.nodes.map((node) => [node.id, node]));
|
|
77
|
+
expect(byId.has("agent-node")).toBe(true);
|
|
78
|
+
expect((expectDefined(byId.get("a"), "entry a").data as Record<string, unknown>).label).toBe(
|
|
79
|
+
"renamed",
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("returns the server doc unchanged when there are no local changes", () => {
|
|
84
|
+
const serverDoc = doc([n("a"), n("b"), n("agent-node")]);
|
|
85
|
+
const rebased = expectDefined(rebaseDiagram(serverDoc, lastSaved, lastSaved), "rebased doc");
|
|
86
|
+
expect(rebased.nodes.map((node) => node.id)).toEqual(["a", "b", "agent-node"]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("keeps a locally re-pointed edge endpoint and handles", () => {
|
|
90
|
+
const base = doc([n("a"), n("b"), n("c")], [{ id: "e1", source: "a", target: "b" }]);
|
|
91
|
+
const server = doc(
|
|
92
|
+
[n("a"), n("b"), n("c"), n("agent")],
|
|
93
|
+
[{ id: "e1", source: "a", target: "b" }],
|
|
94
|
+
);
|
|
95
|
+
const local = doc(
|
|
96
|
+
[n("a"), n("b"), n("c")],
|
|
97
|
+
[{ id: "e1", source: "a", target: "c", targetHandle: "left" }],
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const rebased = expectDefined(rebaseDiagram(server, base, local), "rebased doc");
|
|
101
|
+
expect(rebased.edges).toEqual([{ id: "e1", source: "a", target: "c", targetHandle: "left" }]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("returns null when a re-pointed edge's new endpoint was deleted on the server", () => {
|
|
105
|
+
const base = doc([n("a"), n("b"), n("c")], [{ id: "e1", source: "a", target: "b" }]);
|
|
106
|
+
const server = doc([n("a"), n("b")], [{ id: "e1", source: "a", target: "b" }]);
|
|
107
|
+
const local = doc([n("a"), n("b"), n("c")], [{ id: "e1", source: "a", target: "c" }]);
|
|
108
|
+
|
|
109
|
+
expect(rebaseDiagram(server, base, local)).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("patches zIndex and type changes, and a removed zIndex", () => {
|
|
113
|
+
const base = doc([n("a", { zIndex: 1 }), n("b"), n("c", { zIndex: 4 })]);
|
|
114
|
+
const server = doc([n("a", { zIndex: 1 }), n("b"), n("c", { zIndex: 4 }), n("agent")]);
|
|
115
|
+
const local = doc([n("a", { zIndex: 9 }), n("b", { type: "sticky" }), n("c")]);
|
|
116
|
+
|
|
117
|
+
const rebased = expectDefined(rebaseDiagram(server, base, local), "rebased doc");
|
|
118
|
+
const byId = new Map(rebased.nodes.map((node) => [node.id, node]));
|
|
119
|
+
expect(byId.get("a")?.zIndex).toBe(9);
|
|
120
|
+
expect(byId.get("b")?.type).toBe("sticky");
|
|
121
|
+
expect(byId.get("c")).not.toHaveProperty("zIndex");
|
|
122
|
+
expect(byId.has("agent")).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("keeps both sides' edits to different data keys of the same node", () => {
|
|
126
|
+
const base = doc([n("a", { data: { label: "a", note: "old" } })]);
|
|
127
|
+
const server = doc([n("a", { data: { label: "a", note: "server note" } })]);
|
|
128
|
+
const local = doc([n("a", { data: { label: "local label", note: "old" } })]);
|
|
129
|
+
|
|
130
|
+
const rebased = expectDefined(rebaseDiagram(server, base, local), "rebased doc");
|
|
131
|
+
expect(rebased.nodes[0].data).toEqual({ label: "local label", note: "server note" });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("lets local win when both sides edit the same data key", () => {
|
|
135
|
+
const base = doc([n("a", { data: { label: "a" } })]);
|
|
136
|
+
const server = doc([n("a", { data: { label: "server" } })]);
|
|
137
|
+
const local = doc([n("a", { data: { label: "local" } })]);
|
|
138
|
+
|
|
139
|
+
const rebased = expectDefined(rebaseDiagram(server, base, local), "rebased doc");
|
|
140
|
+
expect(rebased.nodes[0].data).toEqual({ label: "local" });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("replaces a changed top-level data key whole, without merging inside it", () => {
|
|
144
|
+
const base = doc([n("a", { data: { shape: { kind: "box", fill: "red" } } })]);
|
|
145
|
+
const server = doc([n("a", { data: { shape: { kind: "box", fill: "red", ring: 2 } } })]);
|
|
146
|
+
const local = doc([n("a", { data: { shape: { kind: "box", fill: "blue" } } })]);
|
|
147
|
+
|
|
148
|
+
const rebased = expectDefined(rebaseDiagram(server, base, local), "rebased doc");
|
|
149
|
+
expect(rebased.nodes[0].data).toEqual({ shape: { kind: "box", fill: "blue" } });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("removes a data key the local side deleted, and keeps the server's other keys", () => {
|
|
153
|
+
const base = doc([n("a", { data: { label: "a", icon: "x", note: "old" } })]);
|
|
154
|
+
const server = doc([n("a", { data: { label: "a", icon: "x", note: "server note" } })]);
|
|
155
|
+
const local = doc([n("a", { data: { label: "a", note: "old" } })]);
|
|
156
|
+
|
|
157
|
+
const rebased = expectDefined(rebaseDiagram(server, base, local), "rebased doc");
|
|
158
|
+
expect(rebased.nodes[0].data).toEqual({ label: "a", note: "server note" });
|
|
159
|
+
expect(rebased.nodes[0].data).not.toHaveProperty("icon");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("returns null when a field-only change hits a node the server deleted", () => {
|
|
163
|
+
const base = doc([n("a"), n("b")]);
|
|
164
|
+
const server = doc([n("a")]);
|
|
165
|
+
const local = doc([n("a"), n("b", { zIndex: 3 })]);
|
|
166
|
+
|
|
167
|
+
expect(rebaseDiagram(server, base, local)).toBeNull();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("keeps parents before children when a local reparent targets a later server node", () => {
|
|
171
|
+
const base = doc([n("child"), n("box", { type: "space" })]);
|
|
172
|
+
const server = doc([n("child"), n("box", { type: "space" })]);
|
|
173
|
+
const local = doc([
|
|
174
|
+
n("box", { type: "space" }),
|
|
175
|
+
n("child", { parentId: "box", position: { x: 10, y: 10 } }),
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
const rebased = expectDefined(rebaseDiagram(server, base, local), "rebased doc");
|
|
179
|
+
const ids = rebased.nodes.map((node) => node.id);
|
|
180
|
+
expect(ids.indexOf("box")).toBeLessThan(ids.indexOf("child"));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("returns null when the replay is impossible", () => {
|
|
184
|
+
// Server deleted "b"; local patched it. patchNode on a missing node throws.
|
|
185
|
+
const serverDoc = doc([n("a")]);
|
|
186
|
+
const local = doc([n("a"), n("b", { data: { label: "edited" } })]);
|
|
187
|
+
expect(rebaseDiagram(serverDoc, lastSaved, local)).toBeNull();
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("a new edge keeps everything it was drawn with", () => {
|
|
192
|
+
it("carries label, animated, style and data through a rebase", () => {
|
|
193
|
+
const base = doc([n("a"), n("b")]);
|
|
194
|
+
const styled = {
|
|
195
|
+
id: "e9",
|
|
196
|
+
source: "a",
|
|
197
|
+
target: "b",
|
|
198
|
+
type: "smoothstep",
|
|
199
|
+
label: "pays",
|
|
200
|
+
animated: true,
|
|
201
|
+
style: { stroke: "#f00", strokeWidth: 3 },
|
|
202
|
+
data: { dash: "dotted", arrow: "both" },
|
|
203
|
+
};
|
|
204
|
+
const local = doc([n("a"), n("b")], [styled]);
|
|
205
|
+
const rebased = rebaseDiagram(doc([n("a"), n("b"), n("c")]), base, local);
|
|
206
|
+
expect(rebased?.edges).toEqual([styled]);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// Conflict rebase for the autosave PUT. On a 409, local changes since the last
|
|
2
|
+
// save replay as ops on the server document, instead of overwriting the other writer.
|
|
3
|
+
|
|
4
|
+
import { tryCatch } from "../../lib/result.js";
|
|
5
|
+
import { applyOps, type CanvasOp } from "../ops/ops.js";
|
|
6
|
+
import type { RawCanvasData } from "../types.js";
|
|
7
|
+
import { sortParentsFirst } from "../headless/canvas-cleanup.js";
|
|
8
|
+
|
|
9
|
+
type DocNode = Record<string, unknown> & {
|
|
10
|
+
id: string;
|
|
11
|
+
position?: { x: number; y: number };
|
|
12
|
+
parentId?: string;
|
|
13
|
+
data?: Record<string, unknown>;
|
|
14
|
+
style?: unknown;
|
|
15
|
+
width?: unknown;
|
|
16
|
+
height?: unknown;
|
|
17
|
+
type?: unknown;
|
|
18
|
+
};
|
|
19
|
+
type DocEdge = Record<string, unknown> & { id: string };
|
|
20
|
+
export type DiagramDoc = { nodes: DocNode[]; edges: DocEdge[] };
|
|
21
|
+
|
|
22
|
+
const EDGE_PATCH_FIELDS = [
|
|
23
|
+
"source",
|
|
24
|
+
"target",
|
|
25
|
+
"label",
|
|
26
|
+
"animated",
|
|
27
|
+
"type",
|
|
28
|
+
"style",
|
|
29
|
+
"sourceHandle",
|
|
30
|
+
"targetHandle",
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
function json(value: unknown): string {
|
|
34
|
+
return JSON.stringify(value ?? null);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type SizePatch = { style?: Record<string, unknown>; width?: number; height?: number };
|
|
38
|
+
|
|
39
|
+
function objectStyle(node: DocNode): Record<string, unknown> | undefined {
|
|
40
|
+
return node.style && typeof node.style === "object"
|
|
41
|
+
? (node.style as Record<string, unknown>)
|
|
42
|
+
: undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function numberOrUndefined(value: unknown): number | undefined {
|
|
46
|
+
return typeof value === "number" ? value : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function addNodeOp(node: DocNode): CanvasOp {
|
|
50
|
+
const style = objectStyle(node);
|
|
51
|
+
const width = numberOrUndefined(node.width);
|
|
52
|
+
const height = numberOrUndefined(node.height);
|
|
53
|
+
return {
|
|
54
|
+
kind: "addNode",
|
|
55
|
+
node: {
|
|
56
|
+
id: node.id,
|
|
57
|
+
type: String(node.type ?? "node"),
|
|
58
|
+
position: node.position ?? { x: 0, y: 0 },
|
|
59
|
+
data: node.data ?? {},
|
|
60
|
+
...(style ? { style } : {}),
|
|
61
|
+
...(width !== undefined ? { width } : {}),
|
|
62
|
+
...(height !== undefined ? { height } : {}),
|
|
63
|
+
...(node.parentId ? { parentId: node.parentId } : {}),
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sizePatchOf(before: DocNode, node: DocNode): SizePatch {
|
|
69
|
+
const patch: SizePatch = {};
|
|
70
|
+
const style = objectStyle(node);
|
|
71
|
+
if (style && json(before.style) !== json(node.style)) patch.style = style;
|
|
72
|
+
const width = numberOrUndefined(node.width);
|
|
73
|
+
if (width !== undefined && before.width !== node.width) patch.width = width;
|
|
74
|
+
const height = numberOrUndefined(node.height);
|
|
75
|
+
if (height !== undefined && before.height !== node.height) patch.height = height;
|
|
76
|
+
return patch;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Only the top-level keys local changed, so a key the server edited alone keeps its value. */
|
|
80
|
+
function changedData(
|
|
81
|
+
before: Record<string, unknown> | undefined,
|
|
82
|
+
data: Record<string, unknown> | undefined,
|
|
83
|
+
): Record<string, unknown> | undefined {
|
|
84
|
+
if (!data) return undefined;
|
|
85
|
+
const changed = Object.entries(data).filter(
|
|
86
|
+
([key, value]) => json(before?.[key]) !== json(value),
|
|
87
|
+
);
|
|
88
|
+
return changed.length > 0 ? Object.fromEntries(changed) : undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function changedNodeOps(before: DocNode, node: DocNode): CanvasOp[] {
|
|
92
|
+
const ops: CanvasOp[] = [];
|
|
93
|
+
if (node.position && json(before.position) !== json(node.position)) {
|
|
94
|
+
ops.push({ kind: "moveNode", nodeId: node.id, position: node.position });
|
|
95
|
+
}
|
|
96
|
+
const parentId = node.parentId ?? null;
|
|
97
|
+
if ((before.parentId ?? null) !== parentId) {
|
|
98
|
+
ops.push({ kind: "setParent", nodeId: node.id, parentId });
|
|
99
|
+
}
|
|
100
|
+
const data = changedData(before.data, node.data);
|
|
101
|
+
if (data) ops.push({ kind: "patchNode", nodeId: node.id, data });
|
|
102
|
+
const sizePatch = sizePatchOf(before, node);
|
|
103
|
+
if (Object.keys(sizePatch).length > 0) {
|
|
104
|
+
ops.push({ kind: "patchNode", nodeId: node.id, ...sizePatch });
|
|
105
|
+
}
|
|
106
|
+
return ops;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function nodeOps(base: DiagramDoc, current: DiagramDoc): CanvasOp[] {
|
|
110
|
+
const baseNodes = new Map(base.nodes.map((n) => [n.id, n]));
|
|
111
|
+
const currentIds = new Set(current.nodes.map((n) => n.id));
|
|
112
|
+
const removed: CanvasOp[] = base.nodes
|
|
113
|
+
.filter((n) => !currentIds.has(n.id))
|
|
114
|
+
.map((n) => ({ kind: "removeNode", nodeId: n.id }));
|
|
115
|
+
|
|
116
|
+
// WHY parents first: an added child references its added parent in the same
|
|
117
|
+
// batch, and applyOps validates parent existence at each step.
|
|
118
|
+
const added = sortParentsFirst(current.nodes.filter((n) => !baseNodes.has(n.id))).map(addNodeOp);
|
|
119
|
+
|
|
120
|
+
const changed = current.nodes.flatMap((node) => {
|
|
121
|
+
const before = baseNodes.get(node.id);
|
|
122
|
+
return before ? changedNodeOps(before, node) : [];
|
|
123
|
+
});
|
|
124
|
+
return [...removed, ...added, ...changed];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function addEdgeOp(edge: DocEdge): CanvasOp {
|
|
128
|
+
const optional = (key: "sourceHandle" | "targetHandle" | "type" | "label") =>
|
|
129
|
+
typeof edge[key] === "string" ? { [key]: edge[key] } : {};
|
|
130
|
+
const record = (key: "style" | "data") => {
|
|
131
|
+
const value = edge[key];
|
|
132
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
133
|
+
? { [key]: value as Record<string, unknown> }
|
|
134
|
+
: {};
|
|
135
|
+
};
|
|
136
|
+
return {
|
|
137
|
+
kind: "addEdge",
|
|
138
|
+
edge: {
|
|
139
|
+
id: edge.id,
|
|
140
|
+
source: String(edge.source),
|
|
141
|
+
target: String(edge.target),
|
|
142
|
+
...optional("sourceHandle"),
|
|
143
|
+
...optional("targetHandle"),
|
|
144
|
+
...optional("type"),
|
|
145
|
+
...optional("label"),
|
|
146
|
+
...(typeof edge.animated === "boolean" ? { animated: edge.animated } : {}),
|
|
147
|
+
...record("style"),
|
|
148
|
+
...record("data"),
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function patchEdgeOps(before: DocEdge, edge: DocEdge): CanvasOp[] {
|
|
154
|
+
const patch: Record<string, unknown> = {};
|
|
155
|
+
for (const field of EDGE_PATCH_FIELDS) {
|
|
156
|
+
if (edge[field] !== undefined && json(before[field]) !== json(edge[field])) {
|
|
157
|
+
patch[field] = edge[field];
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (Object.keys(patch).length === 0) return [];
|
|
161
|
+
return [{ kind: "patchEdge", edgeId: edge.id, patch } as CanvasOp];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function edgeOps(base: DiagramDoc, current: DiagramDoc): CanvasOp[] {
|
|
165
|
+
const baseEdges = new Map(base.edges.map((e) => [e.id, e]));
|
|
166
|
+
const currentIds = new Set(current.edges.map((e) => e.id));
|
|
167
|
+
const removed: CanvasOp[] = base.edges
|
|
168
|
+
.filter((e) => !currentIds.has(e.id))
|
|
169
|
+
.map((e) => ({ kind: "removeEdge", edgeId: e.id }));
|
|
170
|
+
const upserted = current.edges.flatMap((edge) => {
|
|
171
|
+
const before = baseEdges.get(edge.id);
|
|
172
|
+
return before ? patchEdgeOps(before, edge) : [addEdgeOp(edge)];
|
|
173
|
+
});
|
|
174
|
+
return [...removed, ...upserted];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Diff two persisted documents into ops. Data patches carry only changed keys. A deleted
|
|
179
|
+
* data key, and a node's `type` or `zIndex`, have no op: `nodeFieldPatches` carries those.
|
|
180
|
+
*/
|
|
181
|
+
export function diffToOps(base: DiagramDoc, current: DiagramDoc): CanvasOp[] {
|
|
182
|
+
return [...nodeOps(base, current), ...edgeOps(base, current)];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
type NodeFieldPatch = {
|
|
186
|
+
nodeId: string;
|
|
187
|
+
set: Record<string, unknown>;
|
|
188
|
+
unset: string[];
|
|
189
|
+
unsetData: string[];
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const NODE_FIELDS = ["type", "zIndex"] as const;
|
|
193
|
+
|
|
194
|
+
/** What changed on a node that `patchNode` can't say: `type`, `zIndex` and deleted data keys. */
|
|
195
|
+
function nodeFieldPatches(base: DiagramDoc, current: DiagramDoc): NodeFieldPatch[] {
|
|
196
|
+
const baseNodes = new Map(base.nodes.map((n) => [n.id, n]));
|
|
197
|
+
const patches: NodeFieldPatch[] = [];
|
|
198
|
+
for (const node of current.nodes) {
|
|
199
|
+
const before = baseNodes.get(node.id);
|
|
200
|
+
if (!before) continue;
|
|
201
|
+
const set: Record<string, unknown> = {};
|
|
202
|
+
const unset: string[] = [];
|
|
203
|
+
for (const field of NODE_FIELDS) {
|
|
204
|
+
if (json(before[field]) === json(node[field])) continue;
|
|
205
|
+
if (node[field] === undefined) unset.push(field);
|
|
206
|
+
else set[field] = node[field];
|
|
207
|
+
}
|
|
208
|
+
const unsetData = Object.keys(before.data ?? {}).filter((key) => !(key in (node.data ?? {})));
|
|
209
|
+
if (unset.length + unsetData.length + Object.keys(set).length > 0) {
|
|
210
|
+
patches.push({ nodeId: node.id, set, unset, unsetData });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return patches;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function withoutKeys(record: Record<string, unknown>, keys: string[]): Record<string, unknown> {
|
|
217
|
+
return Object.fromEntries(Object.entries(record).filter(([key]) => !keys.includes(key)));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Null when a patched node is gone, the same answer `applyOps` gives for an op on a missing node. */
|
|
221
|
+
function applyNodeFieldPatches(
|
|
222
|
+
nodes: Record<string, unknown>[],
|
|
223
|
+
patches: NodeFieldPatch[],
|
|
224
|
+
): Record<string, unknown>[] | null {
|
|
225
|
+
const byId = new Map(patches.map((patch) => [patch.nodeId, patch]));
|
|
226
|
+
const seen = new Set<string>();
|
|
227
|
+
const next = nodes.map((node) => {
|
|
228
|
+
const patch = typeof node.id === "string" ? byId.get(node.id) : undefined;
|
|
229
|
+
if (!patch) return node;
|
|
230
|
+
seen.add(patch.nodeId);
|
|
231
|
+
const patched = withoutKeys({ ...node, ...patch.set }, patch.unset);
|
|
232
|
+
if (patch.unsetData.length === 0 || typeof node.data !== "object" || node.data === null) {
|
|
233
|
+
return patched;
|
|
234
|
+
}
|
|
235
|
+
return { ...patched, data: withoutKeys(node.data as Record<string, unknown>, patch.unsetData) };
|
|
236
|
+
});
|
|
237
|
+
return seen.size === byId.size ? next : null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** A local reparent can name a container that sits later in the server's order. */
|
|
241
|
+
function parentsFirst(nodes: Record<string, unknown>[]): Record<string, unknown>[] {
|
|
242
|
+
const ids = new Set(nodes.map((node) => node.id));
|
|
243
|
+
const seen = new Set<unknown>();
|
|
244
|
+
const misordered = nodes.some((node) => {
|
|
245
|
+
const early = ids.has(node.parentId) && !seen.has(node.parentId);
|
|
246
|
+
seen.add(node.id);
|
|
247
|
+
return early;
|
|
248
|
+
});
|
|
249
|
+
if (!misordered) return nodes;
|
|
250
|
+
const wrapped = nodes.map((node) => ({
|
|
251
|
+
id: String(node.id),
|
|
252
|
+
parentId: typeof node.parentId === "string" ? node.parentId : undefined,
|
|
253
|
+
node,
|
|
254
|
+
}));
|
|
255
|
+
return sortParentsFirst(wrapped).map(({ node }) => node);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Replay local changes (lastSaved -> local) on the server document. Returns
|
|
260
|
+
* null when impossible, like a patched node deleted, so the save error shows.
|
|
261
|
+
*/
|
|
262
|
+
export function rebaseDiagram(
|
|
263
|
+
serverDoc: DiagramDoc,
|
|
264
|
+
lastSaved: DiagramDoc,
|
|
265
|
+
local: DiagramDoc,
|
|
266
|
+
): RawCanvasData | null {
|
|
267
|
+
const ops = diffToOps(lastSaved, local);
|
|
268
|
+
const res = tryCatch(() => applyOps({ nodes: serverDoc.nodes, edges: serverDoc.edges }, ops));
|
|
269
|
+
if (!res.ok) return null;
|
|
270
|
+
const nodes = applyNodeFieldPatches(res.value.nodes, nodeFieldPatches(lastSaved, local));
|
|
271
|
+
return nodes ? { ...res.value, nodes: parentsFirst(nodes) } : null;
|
|
272
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// The style panel model: what a selection takes, and the ops that restyle it.
|
|
2
|
+
// A change is one patchNode per card, so it is one undo step.
|
|
3
|
+
|
|
4
|
+
import type { CanvasOp } from "../ops/ops.js";
|
|
5
|
+
import { PEN_COLORS, SHAPE_NODE, STROKE_NODE, TEXT_NODE, type PenColor } from "../pen/types.js";
|
|
6
|
+
|
|
7
|
+
export type FillValue = PenColor | "none";
|
|
8
|
+
export type FontWeight = "regular" | "bold";
|
|
9
|
+
/** Thin, regular, thick. A level maps to a different width on a stroke, a shape and a frame. */
|
|
10
|
+
export type WidthLevel = 0 | 1 | 2;
|
|
11
|
+
|
|
12
|
+
export interface StyleValues {
|
|
13
|
+
fill: FillValue;
|
|
14
|
+
stroke: PenColor;
|
|
15
|
+
width: WidthLevel;
|
|
16
|
+
fontSize: number;
|
|
17
|
+
fontWeight: FontWeight;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type StyleControl = keyof StyleValues;
|
|
21
|
+
|
|
22
|
+
export const FONT_SIZES = [16, 20, 28, 40] as const;
|
|
23
|
+
|
|
24
|
+
export interface StyleNode {
|
|
25
|
+
id: string;
|
|
26
|
+
type?: string;
|
|
27
|
+
data: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const SHAPE_WIDTHS = [2, 3, 5] as const;
|
|
31
|
+
const PEN_WIDTHS = [2, 4, 8] as const;
|
|
32
|
+
const HIGHLIGHTER_WIDTHS = [12, 20, 32] as const;
|
|
33
|
+
const FRAME_WIDTHS = [1, 2, 4] as const;
|
|
34
|
+
|
|
35
|
+
type Kind = "shape" | "text" | "stroke" | "frame";
|
|
36
|
+
|
|
37
|
+
const CONTROLS: Record<Kind, readonly StyleControl[]> = {
|
|
38
|
+
shape: ["fill", "stroke", "width"],
|
|
39
|
+
text: ["stroke", "fontSize", "fontWeight"],
|
|
40
|
+
stroke: ["stroke", "width"],
|
|
41
|
+
frame: ["fill", "stroke", "width"],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function kindOf(node: StyleNode, frameType: string): Kind | null {
|
|
45
|
+
if (node.type === SHAPE_NODE) return "shape";
|
|
46
|
+
if (node.type === TEXT_NODE) return "text";
|
|
47
|
+
if (node.type === STROKE_NODE) return "stroke";
|
|
48
|
+
// Only a frame from the F tool wears a fill and a line; a group draws its own colour.
|
|
49
|
+
return node.type === frameType && node.data.frame === true ? "frame" : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function widthsOf(kind: Kind, node: StyleNode): readonly number[] {
|
|
53
|
+
if (kind === "shape") return SHAPE_WIDTHS;
|
|
54
|
+
if (kind === "frame") return FRAME_WIDTHS;
|
|
55
|
+
return node.data.tool === "highlighter" ? HIGHLIGHTER_WIDTHS : PEN_WIDTHS;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function widthKey(kind: Kind): string {
|
|
59
|
+
if (kind === "stroke") return "size";
|
|
60
|
+
return "strokeWidth";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function colorOf(value: unknown): PenColor | undefined {
|
|
64
|
+
return PEN_COLORS.find((color) => color === value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function levelOf(kind: Kind, node: StyleNode): WidthLevel {
|
|
68
|
+
const widths = widthsOf(kind, node);
|
|
69
|
+
// A frame nobody styled draws the thinnest line, and a pen mark its middle one.
|
|
70
|
+
const stored = Number(node.data[widthKey(kind)]);
|
|
71
|
+
if (!Number.isFinite(stored)) return kind === "frame" ? 0 : 1;
|
|
72
|
+
let nearest = 0;
|
|
73
|
+
for (let at = 1; at < widths.length; at += 1) {
|
|
74
|
+
if (Math.abs(widths[at] - stored) < Math.abs(widths[nearest] - stored)) nearest = at;
|
|
75
|
+
}
|
|
76
|
+
return nearest as WidthLevel;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** What a card of each kind shows, given the colour it draws in. */
|
|
80
|
+
const VALUES: Record<Kind, (node: StyleNode, color: PenColor) => Partial<StyleValues>> = {
|
|
81
|
+
shape: (node, color) => ({
|
|
82
|
+
fill: node.data.filled ? (colorOf(node.data.fill) ?? color) : "none",
|
|
83
|
+
stroke: color,
|
|
84
|
+
width: levelOf("shape", node),
|
|
85
|
+
}),
|
|
86
|
+
text: (node, color) => ({
|
|
87
|
+
stroke: color,
|
|
88
|
+
fontSize: Number(node.data.fontSize) || 20,
|
|
89
|
+
fontWeight: node.data.fontWeight === "bold" ? "bold" : "regular",
|
|
90
|
+
}),
|
|
91
|
+
stroke: (node, color) => ({ stroke: color, width: levelOf("stroke", node) }),
|
|
92
|
+
frame: (node) => ({
|
|
93
|
+
fill: colorOf(node.data.fill) ?? "none",
|
|
94
|
+
stroke: colorOf(node.data.stroke) ?? "ink",
|
|
95
|
+
width: levelOf("frame", node),
|
|
96
|
+
}),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
function valuesOf(kind: Kind, node: StyleNode): Partial<StyleValues> {
|
|
100
|
+
return VALUES[kind](node, colorOf(node.data.color) ?? "ink");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface StyleSummary {
|
|
104
|
+
/** Every control some selected card answers to. */
|
|
105
|
+
controls: ReadonlySet<StyleControl>;
|
|
106
|
+
/** A value every card that has the control agrees on. Missing where they differ. */
|
|
107
|
+
values: Partial<StyleValues>;
|
|
108
|
+
/** The widths a control shows, in px, for the first card that has one. */
|
|
109
|
+
widths: readonly number[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** What the panel shows for a selection, or null when nothing in it takes a style. */
|
|
113
|
+
export function summarizeStyle(
|
|
114
|
+
nodes: readonly StyleNode[],
|
|
115
|
+
frameType: string,
|
|
116
|
+
): StyleSummary | null {
|
|
117
|
+
const controls = new Set<StyleControl>();
|
|
118
|
+
const seen = new Map<StyleControl, Set<unknown>>();
|
|
119
|
+
let widths: readonly number[] = [];
|
|
120
|
+
for (const node of nodes) {
|
|
121
|
+
const kind = kindOf(node, frameType);
|
|
122
|
+
if (!kind) continue;
|
|
123
|
+
const values = valuesOf(kind, node);
|
|
124
|
+
for (const control of CONTROLS[kind]) {
|
|
125
|
+
controls.add(control);
|
|
126
|
+
const set = seen.get(control) ?? new Set();
|
|
127
|
+
set.add(values[control]);
|
|
128
|
+
seen.set(control, set);
|
|
129
|
+
}
|
|
130
|
+
if (widths.length === 0 && CONTROLS[kind].includes("width")) widths = widthsOf(kind, node);
|
|
131
|
+
}
|
|
132
|
+
if (controls.size === 0) return null;
|
|
133
|
+
const values: Partial<StyleValues> = {};
|
|
134
|
+
for (const [control, set] of seen) {
|
|
135
|
+
if (set.size === 1) Object.assign(values, { [control]: [...set][0] });
|
|
136
|
+
}
|
|
137
|
+
return { controls, values, widths };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A frame keeps its fill as is; a shape's fill also says whether it is filled. */
|
|
141
|
+
function fillData(kind: Kind, fill: FillValue): Record<string, unknown> {
|
|
142
|
+
if (kind === "frame") return { fill };
|
|
143
|
+
return fill === "none" ? { filled: false } : { filled: true, fill };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function textData(change: Partial<StyleValues>): Record<string, unknown> {
|
|
147
|
+
const data: Record<string, unknown> = {};
|
|
148
|
+
if (change.fontSize !== undefined) data.fontSize = change.fontSize;
|
|
149
|
+
if (change.fontWeight !== undefined) data.fontWeight = change.fontWeight;
|
|
150
|
+
return data;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function dataFor(
|
|
154
|
+
kind: Kind,
|
|
155
|
+
node: StyleNode,
|
|
156
|
+
change: Partial<StyleValues>,
|
|
157
|
+
): Record<string, unknown> {
|
|
158
|
+
const data: Record<string, unknown> = {};
|
|
159
|
+
if (change.stroke) data[kind === "frame" ? "stroke" : "color"] = change.stroke;
|
|
160
|
+
if (change.fill !== undefined && (kind === "shape" || kind === "frame")) {
|
|
161
|
+
Object.assign(data, fillData(kind, change.fill));
|
|
162
|
+
}
|
|
163
|
+
if (change.width !== undefined && kind !== "text") {
|
|
164
|
+
data[widthKey(kind)] = widthsOf(kind, node)[change.width];
|
|
165
|
+
}
|
|
166
|
+
if (kind === "text") Object.assign(data, textData(change));
|
|
167
|
+
return data;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** One patch per card the change applies to. All of them are one undo step when dispatched together. */
|
|
171
|
+
export function styleOps(
|
|
172
|
+
nodes: readonly StyleNode[],
|
|
173
|
+
change: Partial<StyleValues>,
|
|
174
|
+
frameType: string,
|
|
175
|
+
): CanvasOp[] {
|
|
176
|
+
const ops: CanvasOp[] = [];
|
|
177
|
+
for (const node of nodes) {
|
|
178
|
+
const kind = kindOf(node, frameType);
|
|
179
|
+
if (!kind) continue;
|
|
180
|
+
const data = dataFor(kind, node, change);
|
|
181
|
+
if (Object.keys(data).length > 0) ops.push({ kind: "patchNode", nodeId: node.id, data });
|
|
182
|
+
}
|
|
183
|
+
return ops;
|
|
184
|
+
}
|