@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,99 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
2
|
+
import {
|
|
3
|
+
isSketchAccent,
|
|
4
|
+
roughArrow,
|
|
5
|
+
roughHead,
|
|
6
|
+
roughLine,
|
|
7
|
+
roughCard,
|
|
8
|
+
roughEllipse,
|
|
9
|
+
seedFrom,
|
|
10
|
+
sketchColors,
|
|
11
|
+
} from "./sketch.js";
|
|
12
|
+
|
|
13
|
+
describe("sketch palette", () => {
|
|
14
|
+
it("falls back to ink for an unknown or missing accent", () => {
|
|
15
|
+
expect(sketchColors("chartreuse")).toEqual(sketchColors("ink"));
|
|
16
|
+
expect(sketchColors(undefined)).toEqual(sketchColors("ink"));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("recognises only the accents it can paint", () => {
|
|
20
|
+
expect(isSketchAccent("red")).toBe(true);
|
|
21
|
+
expect(isSketchAccent("puce")).toBe(false);
|
|
22
|
+
expect(isSketchAccent(7)).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("seedFrom", () => {
|
|
27
|
+
it("is stable for the same id", () => {
|
|
28
|
+
expect(seedFrom("rung-install")).toBe(seedFrom("rung-install"));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("separates ids that differ by one character", () => {
|
|
32
|
+
expect(seedFrom("card-1")).not.toBe(seedFrom("card-2"));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("stays a non-negative 31-bit integer, including for an empty id", () => {
|
|
36
|
+
for (const id of ["", "a", "a very long node id ".repeat(20)]) {
|
|
37
|
+
const seed = seedFrom(id);
|
|
38
|
+
expect(Number.isInteger(seed)).toBe(true);
|
|
39
|
+
expect(seed).toBeGreaterThanOrEqual(0);
|
|
40
|
+
expect(seed).toBeLessThan(2147483647);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("rough geometry", () => {
|
|
46
|
+
const colors = sketchColors("red");
|
|
47
|
+
|
|
48
|
+
// The whole reason seeds exist: a card that redraws on every render visibly
|
|
49
|
+
// shimmers on a canvas that streams in from the CLI.
|
|
50
|
+
it("draws the identical card for the same seed", () => {
|
|
51
|
+
expect(roughCard(300, 120, { seed: 42, colors })).toEqual(
|
|
52
|
+
roughCard(300, 120, { seed: 42, colors }),
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("draws a different card for a different seed", () => {
|
|
57
|
+
expect(roughCard(300, 120, { seed: 42, colors })).not.toEqual(
|
|
58
|
+
roughCard(300, 120, { seed: 43, colors }),
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("fills a card and leaves an annotation hollow", () => {
|
|
63
|
+
const card = roughCard(300, 120, { seed: 1, colors });
|
|
64
|
+
const ellipse = roughEllipse(300, 120, { seed: 1, colors });
|
|
65
|
+
expect(card.some((p) => p.fill !== "none")).toBe(true);
|
|
66
|
+
expect(ellipse.every((p) => p.fill === "none")).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("never emits an undefined fill, which renders as black", () => {
|
|
70
|
+
for (const path of [
|
|
71
|
+
...roughCard(200, 80, { seed: 3, colors }),
|
|
72
|
+
...roughEllipse(200, 80, { seed: 3, colors }),
|
|
73
|
+
]) {
|
|
74
|
+
expect(typeof path.fill).toBe("string");
|
|
75
|
+
expect(path.d.length).toBeGreaterThan(0);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("survives a card smaller than its own corner radius", () => {
|
|
80
|
+
expect(() => roughCard(4, 4, { seed: 1, colors })).not.toThrow();
|
|
81
|
+
expect(roughCard(4, 4, { seed: 1, colors }).length).toBeGreaterThan(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("gives an arrow a shaft and two head strokes", () => {
|
|
85
|
+
const paths = roughArrow({ x: 0, y: 0 }, { x: 100, y: 40 }, { seed: 9, colors });
|
|
86
|
+
expect(paths.length).toBeGreaterThanOrEqual(3);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("hands back the same drawing for the same geometry, and a new one when it moves", () => {
|
|
90
|
+
const pen = { seed: 21, colors };
|
|
91
|
+
const first = roughLine({ x: 0, y: 0 }, { x: 80, y: 30 }, pen);
|
|
92
|
+
expect(roughLine({ x: 0, y: 0 }, { x: 80, y: 30 }, pen)).toBe(first);
|
|
93
|
+
expect(roughLine({ x: 0, y: 0 }, { x: 80, y: 31 }, pen)).not.toBe(first);
|
|
94
|
+
expect(roughLine({ x: 0, y: 0 }, { x: 80, y: 30 }, pen, { formal: true })).not.toBe(first);
|
|
95
|
+
const head = roughHead({ x: 80, y: 30 }, 0.5, pen);
|
|
96
|
+
expect(roughHead({ x: 80, y: 30 }, 0.5, pen)).toBe(head);
|
|
97
|
+
expect(roughHead({ x: 80, y: 30 }, 0.6, pen)).not.toBe(head);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// Hand-drawn layer, server-safe: palette, geometry, seeding. The seed comes from
|
|
2
|
+
// the node id, so server SVG and Svelte draw the same wobble.
|
|
3
|
+
// Look and authoring: see the package README.
|
|
4
|
+
|
|
5
|
+
import rough from "roughjs";
|
|
6
|
+
import type { Options } from "roughjs/bin/core";
|
|
7
|
+
import { createMemo } from "./edge-memo.js";
|
|
8
|
+
|
|
9
|
+
// =========================
|
|
10
|
+
// PALETTE
|
|
11
|
+
// =========================
|
|
12
|
+
|
|
13
|
+
/** Accent names a board author writes. Excalidraw's own stroke colors. */
|
|
14
|
+
export const SKETCH_ACCENTS = [
|
|
15
|
+
"ink",
|
|
16
|
+
"red",
|
|
17
|
+
"green",
|
|
18
|
+
"blue",
|
|
19
|
+
"violet",
|
|
20
|
+
"amber",
|
|
21
|
+
"teal",
|
|
22
|
+
"indigo",
|
|
23
|
+
"terra",
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
26
|
+
export type SketchAccent = (typeof SKETCH_ACCENTS)[number];
|
|
27
|
+
|
|
28
|
+
export type SketchColors = {
|
|
29
|
+
/** The rough stroke around a card, and the rule under a title. */
|
|
30
|
+
stroke: string;
|
|
31
|
+
/** The card's wash. Light enough that body text stays black on it. */
|
|
32
|
+
fill: string;
|
|
33
|
+
/** Section headings and card titles. */
|
|
34
|
+
heading: string;
|
|
35
|
+
/** Body lines inside a card. Never pure black: it reads as printed, not drawn. */
|
|
36
|
+
body: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Excalidraw's strokes plus its lightest tint per hue; brand colors would read as app, not whiteboard. */
|
|
40
|
+
const PALETTE: Record<SketchAccent, SketchColors> = {
|
|
41
|
+
ink: { stroke: "#1e1e1e", fill: "#ffffff", heading: "#1e1e1e", body: "#343a40" },
|
|
42
|
+
red: { stroke: "#c92a2a", fill: "#fff5f5", heading: "#a61e4d", body: "#3b3b46" },
|
|
43
|
+
green: { stroke: "#2f9e44", fill: "#f4fce3", heading: "#2b8a3e", body: "#3b3b46" },
|
|
44
|
+
blue: { stroke: "#1971c2", fill: "#e7f5ff", heading: "#1864ab", body: "#3b3b46" },
|
|
45
|
+
violet: { stroke: "#6741d9", fill: "#f3f0ff", heading: "#5f3dc4", body: "#3b3b46" },
|
|
46
|
+
amber: { stroke: "#e8a33d", fill: "#fff9db", heading: "#b8860b", body: "#3b3b46" },
|
|
47
|
+
teal: { stroke: "#0c8599", fill: "#e3fafc", heading: "#0b7285", body: "#3b3b46" },
|
|
48
|
+
// The brand pair the email figures are drawn in: tools/whiteboard/src/scene.ts INK and HIGHLIGHT.
|
|
49
|
+
indigo: { stroke: "#414fd2", fill: "#ffffff", heading: "#414fd2", body: "#414fd2" },
|
|
50
|
+
terra: { stroke: "#f0503d", fill: "#ffffff", heading: "#f0503d", body: "#f0503d" },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** The same hues lifted for the Ink board: light strokes, a dark wash, and text that stays readable on it. */
|
|
54
|
+
const DARK_PALETTE: Record<SketchAccent, SketchColors> = {
|
|
55
|
+
ink: { stroke: "#e9ecef", fill: "#151b30", heading: "#f1f3f5", body: "#ced4da" },
|
|
56
|
+
red: { stroke: "#ff8787", fill: "#2a1822", heading: "#ffa8c5", body: "#ced4da" },
|
|
57
|
+
green: { stroke: "#69db7c", fill: "#132a20", heading: "#8ce99a", body: "#ced4da" },
|
|
58
|
+
blue: { stroke: "#74c0fc", fill: "#12253d", heading: "#a5d8ff", body: "#ced4da" },
|
|
59
|
+
violet: { stroke: "#b197fc", fill: "#211c3d", heading: "#d0bfff", body: "#ced4da" },
|
|
60
|
+
amber: { stroke: "#ffd43b", fill: "#2b2612", heading: "#ffe066", body: "#ced4da" },
|
|
61
|
+
teal: { stroke: "#66d9e8", fill: "#112a30", heading: "#99e9f2", body: "#ced4da" },
|
|
62
|
+
indigo: { stroke: "#8b96ff", fill: "#151b30", heading: "#a5adff", body: "#a5adff" },
|
|
63
|
+
terra: { stroke: "#ff8a78", fill: "#151b30", heading: "#ff9d8d", body: "#ff9d8d" },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Server renders (exports, emails) stay on the light palette; the editor passes its theme. */
|
|
67
|
+
export function sketchColors(
|
|
68
|
+
accent: SketchAccent | string | undefined,
|
|
69
|
+
dark = false,
|
|
70
|
+
): SketchColors {
|
|
71
|
+
const palette = dark ? DARK_PALETTE : PALETTE;
|
|
72
|
+
return palette[(accent ?? "ink") as SketchAccent] ?? palette.ink;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function isSketchAccent(value: unknown): value is SketchAccent {
|
|
76
|
+
return typeof value === "string" && (SKETCH_ACCENTS as readonly string[]).includes(value);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// =========================
|
|
80
|
+
// SEEDING
|
|
81
|
+
// =========================
|
|
82
|
+
|
|
83
|
+
/** A stable 31-bit seed (FNV-1a): unseeded calls reshuffle the wobble, so cards would redraw each render. */
|
|
84
|
+
export function seedFrom(id: string): number {
|
|
85
|
+
let hash = 2166136261;
|
|
86
|
+
for (let i = 0; i < id.length; i += 1) {
|
|
87
|
+
hash ^= id.charCodeAt(i);
|
|
88
|
+
hash = Math.imul(hash, 16777619);
|
|
89
|
+
}
|
|
90
|
+
return Math.abs(hash) % 2147483647;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// =========================
|
|
94
|
+
// GEOMETRY
|
|
95
|
+
// =========================
|
|
96
|
+
|
|
97
|
+
export type SketchPath = { d: string; stroke: string; fill: string; strokeWidth: number };
|
|
98
|
+
|
|
99
|
+
const generator = rough.generator();
|
|
100
|
+
|
|
101
|
+
/** Arrows are redrawn whenever the page repaints them, so a line or head already drawn is handed back. */
|
|
102
|
+
const MEMO_LIMIT = 4000;
|
|
103
|
+
const lines = createMemo<SketchPath[]>(MEMO_LIMIT);
|
|
104
|
+
const heads = createMemo<SketchPath[]>(MEMO_LIMIT);
|
|
105
|
+
|
|
106
|
+
/** Shared feel. Low roughness reads as a careful hand; high reads as a scribble. */
|
|
107
|
+
function baseOptions(seed: number, colors: SketchColors, overrides?: Options): Options {
|
|
108
|
+
return {
|
|
109
|
+
seed,
|
|
110
|
+
roughness: 1.1,
|
|
111
|
+
bowing: 1.4,
|
|
112
|
+
stroke: colors.stroke,
|
|
113
|
+
strokeWidth: 1.6,
|
|
114
|
+
// WHY solid, not hachure: a hatched fill behind three lines of body copy
|
|
115
|
+
// makes the text unreadable at the zoom a card is actually read at.
|
|
116
|
+
fillStyle: "solid",
|
|
117
|
+
...overrides,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Every node has two skins: drawn (the default here) or formal, the same outline with the hand taken out. */
|
|
122
|
+
export type SketchStyle = { formal?: boolean };
|
|
123
|
+
const FORMAL: Options = { roughness: 0, bowing: 0, disableMultiStroke: true, strokeWidth: 1.4 };
|
|
124
|
+
|
|
125
|
+
function toPaths(drawable: ReturnType<typeof generator.rectangle>): SketchPath[] {
|
|
126
|
+
return generator.toPaths(drawable).map((p) => ({
|
|
127
|
+
d: p.d,
|
|
128
|
+
stroke: p.stroke,
|
|
129
|
+
// rough leaves `fill` off a path it did not fill, and an unfilled path is
|
|
130
|
+
// the same thing as one filled with nothing.
|
|
131
|
+
fill: p.fill ?? "none",
|
|
132
|
+
strokeWidth: p.strokeWidth,
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The seed and colors every rough shape draws with. */
|
|
137
|
+
export type SketchPen = { seed: number; colors: SketchColors };
|
|
138
|
+
|
|
139
|
+
/** A card outline: rough.js has no rounded rectangle, so a rounded path instead (square reads as a box). */
|
|
140
|
+
export function roughCard(
|
|
141
|
+
width: number,
|
|
142
|
+
height: number,
|
|
143
|
+
{ seed, colors }: SketchPen,
|
|
144
|
+
{ radius = 14, formal = false }: SketchStyle & { radius?: number } = {},
|
|
145
|
+
): SketchPath[] {
|
|
146
|
+
const w = Math.max(width, radius * 2 + 2);
|
|
147
|
+
const h = Math.max(height, radius * 2 + 2);
|
|
148
|
+
const r = Math.min(radius, w / 2 - 1, h / 2 - 1);
|
|
149
|
+
const d = [
|
|
150
|
+
`M ${r} 0`,
|
|
151
|
+
`L ${w - r} 0`,
|
|
152
|
+
`Q ${w} 0 ${w} ${r}`,
|
|
153
|
+
`L ${w} ${h - r}`,
|
|
154
|
+
`Q ${w} ${h} ${w - r} ${h}`,
|
|
155
|
+
`L ${r} ${h}`,
|
|
156
|
+
`Q 0 ${h} 0 ${h - r}`,
|
|
157
|
+
`L 0 ${r}`,
|
|
158
|
+
`Q 0 0 ${r} 0`,
|
|
159
|
+
"Z",
|
|
160
|
+
].join(" ");
|
|
161
|
+
return toPaths(
|
|
162
|
+
generator.path(d, baseOptions(seed, colors, { fill: colors.fill, ...(formal ? FORMAL : {}) })),
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** An ellipse card is drawn round its text, not through it: this much wider and taller than the box. */
|
|
167
|
+
export const ELLIPSE_CARD_SCALE = { x: 1.32, y: 1.42 };
|
|
168
|
+
|
|
169
|
+
/** The circle-this-bit annotation, drawn as one loose pass. */
|
|
170
|
+
export function roughEllipse(
|
|
171
|
+
width: number,
|
|
172
|
+
height: number,
|
|
173
|
+
{ seed, colors }: SketchPen,
|
|
174
|
+
{ formal = false }: SketchStyle = {},
|
|
175
|
+
): SketchPath[] {
|
|
176
|
+
return toPaths(
|
|
177
|
+
generator.ellipse(
|
|
178
|
+
width / 2,
|
|
179
|
+
height / 2,
|
|
180
|
+
width,
|
|
181
|
+
height,
|
|
182
|
+
baseOptions(seed, colors, {
|
|
183
|
+
roughness: 1.6,
|
|
184
|
+
strokeWidth: 2.2,
|
|
185
|
+
fill: undefined,
|
|
186
|
+
...(formal ? FORMAL : {}),
|
|
187
|
+
}),
|
|
188
|
+
),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** A box with no rounding: the frame you draw around a whole column. */
|
|
193
|
+
export function roughRect(
|
|
194
|
+
width: number,
|
|
195
|
+
height: number,
|
|
196
|
+
{ seed, colors }: SketchPen,
|
|
197
|
+
{ filled = false, formal = false }: SketchStyle & { filled?: boolean } = {},
|
|
198
|
+
): SketchPath[] {
|
|
199
|
+
return toPaths(
|
|
200
|
+
generator.rectangle(
|
|
201
|
+
1,
|
|
202
|
+
1,
|
|
203
|
+
Math.max(width - 2, 2),
|
|
204
|
+
Math.max(height - 2, 2),
|
|
205
|
+
baseOptions(seed, colors, {
|
|
206
|
+
fill: filled ? colors.fill : undefined,
|
|
207
|
+
...(formal ? FORMAL : {}),
|
|
208
|
+
}),
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** A closed outline through the given points: the diamond mark, drawn like the box beside it. */
|
|
214
|
+
export function roughPolygon(
|
|
215
|
+
points: readonly [number, number][],
|
|
216
|
+
{ seed, colors }: SketchPen,
|
|
217
|
+
{ formal = false }: SketchStyle = {},
|
|
218
|
+
): SketchPath[] {
|
|
219
|
+
return toPaths(
|
|
220
|
+
generator.polygon(
|
|
221
|
+
points.map(([x, y]) => [x, y] as [number, number]),
|
|
222
|
+
baseOptions(seed, colors, { fill: undefined, ...(formal ? FORMAL : {}) }),
|
|
223
|
+
),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The hand-drawn rule under a title, `y` down from the top. */
|
|
228
|
+
export function roughUnderline(
|
|
229
|
+
width: number,
|
|
230
|
+
{ seed, colors }: SketchPen,
|
|
231
|
+
{ y = 4, formal = false }: SketchStyle & { y?: number } = {},
|
|
232
|
+
): SketchPath[] {
|
|
233
|
+
return toPaths(
|
|
234
|
+
generator.line(
|
|
235
|
+
0,
|
|
236
|
+
y,
|
|
237
|
+
width,
|
|
238
|
+
y,
|
|
239
|
+
baseOptions(seed, colors, { strokeWidth: 2, bowing: 2.4, ...(formal ? FORMAL : {}) }),
|
|
240
|
+
),
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The shaft of a hand-ruled connector, with no head. */
|
|
245
|
+
export function roughLine(
|
|
246
|
+
from: { x: number; y: number },
|
|
247
|
+
to: { x: number; y: number },
|
|
248
|
+
{ seed, colors }: SketchPen,
|
|
249
|
+
{ formal = false }: SketchStyle = {},
|
|
250
|
+
): SketchPath[] {
|
|
251
|
+
const hand = formal ? FORMAL : {};
|
|
252
|
+
// The same line is asked for on every redraw of its arrow, and rough.js is the slow part.
|
|
253
|
+
return lines(`${seed}|${colors.stroke}|${formal}|${from.x},${from.y}|${to.x},${to.y}`, () =>
|
|
254
|
+
toPaths(
|
|
255
|
+
generator.line(
|
|
256
|
+
from.x,
|
|
257
|
+
from.y,
|
|
258
|
+
to.x,
|
|
259
|
+
to.y,
|
|
260
|
+
baseOptions(seed, colors, { strokeWidth: 1.8, ...hand }),
|
|
261
|
+
),
|
|
262
|
+
),
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Two hand-drawn wings with their tip at `tip`, pointing along `angle` (radians). */
|
|
267
|
+
export function roughHead(
|
|
268
|
+
tip: { x: number; y: number },
|
|
269
|
+
angle: number,
|
|
270
|
+
{ seed, colors }: SketchPen,
|
|
271
|
+
{ formal = false }: SketchStyle = {},
|
|
272
|
+
): SketchPath[] {
|
|
273
|
+
const hand = formal ? FORMAL : {};
|
|
274
|
+
const head = 11;
|
|
275
|
+
const spread = 0.42;
|
|
276
|
+
return heads(`${seed}|${colors.stroke}|${formal}|${tip.x},${tip.y}|${angle}`, () =>
|
|
277
|
+
[angle + Math.PI - spread, angle + Math.PI + spread].flatMap((a, i) =>
|
|
278
|
+
toPaths(
|
|
279
|
+
generator.line(
|
|
280
|
+
tip.x,
|
|
281
|
+
tip.y,
|
|
282
|
+
tip.x + Math.cos(a) * head,
|
|
283
|
+
tip.y + Math.sin(a) * head,
|
|
284
|
+
baseOptions(seed + i + 1, colors, { strokeWidth: 1.8, roughness: 0.8, ...hand }),
|
|
285
|
+
),
|
|
286
|
+
),
|
|
287
|
+
),
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** A straight-ish connector between two canvas points, plus its arrow head. */
|
|
292
|
+
export function roughArrow(
|
|
293
|
+
from: { x: number; y: number },
|
|
294
|
+
to: { x: number; y: number },
|
|
295
|
+
pen: SketchPen,
|
|
296
|
+
style: SketchStyle = {},
|
|
297
|
+
): SketchPath[] {
|
|
298
|
+
return [
|
|
299
|
+
...roughLine(from, to, pen, style),
|
|
300
|
+
...roughHead(to, Math.atan2(to.y - from.y, to.x - from.x), pen, style),
|
|
301
|
+
];
|
|
302
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
2
|
+
import { computeSnap, SNAP_THRESHOLD, type SnapRect } from "./snap-guides.js";
|
|
3
|
+
|
|
4
|
+
const CARD: SnapRect = { x: 0, y: 0, width: 100, height: 40 };
|
|
5
|
+
|
|
6
|
+
describe("computeSnap", () => {
|
|
7
|
+
it("leaves a lone card where it is", () => {
|
|
8
|
+
const result = computeSnap({ ...CARD, x: 33, y: 71 }, []);
|
|
9
|
+
expect(result).toEqual({ x: 33, y: 71, guides: [] });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("pulls a left edge onto another card's left edge", () => {
|
|
13
|
+
const other: SnapRect = { x: 200, y: 300, width: 100, height: 40 };
|
|
14
|
+
const result = computeSnap({ ...CARD, x: 204, y: 0 }, [other]);
|
|
15
|
+
expect(result.x).toBe(200);
|
|
16
|
+
expect(result.guides.some((g) => g.axis === "x" && g.position === 200)).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("pulls a centre onto another card's centre", () => {
|
|
20
|
+
const other: SnapRect = { x: 200, y: 300, width: 100, height: 40 };
|
|
21
|
+
// Centre at 253 wants 250: the dragged card's own centre is x + 50.
|
|
22
|
+
const result = computeSnap({ ...CARD, x: 203, y: 0 }, [other]);
|
|
23
|
+
expect(result.x + 50).toBe(250);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("snaps both axes independently", () => {
|
|
27
|
+
const other: SnapRect = { x: 200, y: 300, width: 100, height: 40 };
|
|
28
|
+
const result = computeSnap({ ...CARD, x: 202, y: 298 }, [other]);
|
|
29
|
+
expect(result).toMatchObject({ x: 200, y: 300 });
|
|
30
|
+
expect(result.guides).toHaveLength(2);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("ignores a candidate further away than the threshold", () => {
|
|
34
|
+
const other: SnapRect = { x: 200, y: 300, width: 100, height: 40 };
|
|
35
|
+
const away = SNAP_THRESHOLD + 1;
|
|
36
|
+
const result = computeSnap({ ...CARD, x: 200 + away, y: 0 }, [other]);
|
|
37
|
+
expect(result.x).toBe(200 + away);
|
|
38
|
+
expect(result.guides.filter((g) => g.axis === "x")).toHaveLength(0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("takes the nearest line when several are in range", () => {
|
|
42
|
+
const near: SnapRect = { x: 202, y: 300, width: 100, height: 40 };
|
|
43
|
+
const far: SnapRect = { x: 205, y: 300, width: 100, height: 40 };
|
|
44
|
+
const result = computeSnap({ ...CARD, x: 203, y: 0 }, [near, far]);
|
|
45
|
+
expect(result.x).toBe(202);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("spans the guide over every card on the same line", () => {
|
|
49
|
+
const top: SnapRect = { x: 200, y: 0, width: 100, height: 40 };
|
|
50
|
+
const bottom: SnapRect = { x: 200, y: 500, width: 100, height: 40 };
|
|
51
|
+
const result = computeSnap({ ...CARD, x: 202, y: 200 }, [top, bottom]);
|
|
52
|
+
const guide = result.guides.find((g) => g.axis === "x");
|
|
53
|
+
expect(guide).toMatchObject({ start: 0, end: 540 });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("honours a caller's threshold, so the pull is constant on screen at any zoom", () => {
|
|
57
|
+
const other: SnapRect = { x: 200, y: 0, width: 100, height: 40 };
|
|
58
|
+
const zoomedOut = computeSnap({ ...CARD, x: 220, y: 0 }, [other], 6 / 0.25);
|
|
59
|
+
expect(zoomedOut.x).toBe(200);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Figma-style alignment magnetism: snap a dragged rect onto the nearest edge or
|
|
2
|
+
// centre of another card, and report the lines. Drawn by snap-guides.svelte.
|
|
3
|
+
|
|
4
|
+
export type SnapRect = { x: number; y: number; width: number; height: number };
|
|
5
|
+
|
|
6
|
+
/** A line to draw, in flow coordinates. `axis: "x"` is a vertical line. */
|
|
7
|
+
export type SnapGuide = {
|
|
8
|
+
axis: "x" | "y";
|
|
9
|
+
/** The coordinate the line sits on: x for a vertical line, y for a horizontal one. */
|
|
10
|
+
position: number;
|
|
11
|
+
/** The extent the line is drawn over, so it visibly joins the cards it aligns. */
|
|
12
|
+
start: number;
|
|
13
|
+
end: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type SnapResult = {
|
|
17
|
+
x: number;
|
|
18
|
+
y: number;
|
|
19
|
+
guides: SnapGuide[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Default pull, in flow units. Callers divide by zoom to hold it on screen. */
|
|
23
|
+
export const SNAP_THRESHOLD = 6;
|
|
24
|
+
|
|
25
|
+
type Axis = "x" | "y";
|
|
26
|
+
|
|
27
|
+
const SIZE_KEY: Record<Axis, "width" | "height"> = { x: "width", y: "height" };
|
|
28
|
+
const CROSS: Record<Axis, Axis> = { x: "y", y: "x" };
|
|
29
|
+
|
|
30
|
+
/** The three lines a rect offers on one axis: its two edges and its centre. */
|
|
31
|
+
function linesOf(rect: SnapRect, axis: Axis): [number, number, number] {
|
|
32
|
+
const start = rect[axis];
|
|
33
|
+
const size = rect[SIZE_KEY[axis]];
|
|
34
|
+
return [start, start + size / 2, start + size];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function snapAxis(
|
|
38
|
+
dragged: SnapRect,
|
|
39
|
+
others: SnapRect[],
|
|
40
|
+
axis: Axis,
|
|
41
|
+
threshold: number,
|
|
42
|
+
): { delta: number; position: number | null } {
|
|
43
|
+
const mine = linesOf(dragged, axis);
|
|
44
|
+
// Flat rather than nested: every pairing of one of our lines with one of
|
|
45
|
+
// theirs, then the nearest pairing inside the threshold.
|
|
46
|
+
const pairings = others.flatMap((other) =>
|
|
47
|
+
linesOf(other, axis).flatMap((theirs) =>
|
|
48
|
+
mine.map((own) => ({ delta: theirs - own, position: theirs })),
|
|
49
|
+
),
|
|
50
|
+
);
|
|
51
|
+
const inRange = pairings.filter((pair) => Math.abs(pair.delta) <= threshold);
|
|
52
|
+
if (inRange.length === 0) return { delta: 0, position: null };
|
|
53
|
+
return inRange.reduce((a, b) => (Math.abs(b.delta) < Math.abs(a.delta) ? b : a));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Every other rect that sits on `position`, so the guide spans all of them. */
|
|
57
|
+
function guideFor(snapped: SnapRect, others: SnapRect[], axis: Axis, position: number): SnapGuide {
|
|
58
|
+
const cross = CROSS[axis];
|
|
59
|
+
const size = SIZE_KEY[cross];
|
|
60
|
+
const onLine = others.filter((other) =>
|
|
61
|
+
linesOf(other, axis).some((line) => Math.abs(line - position) < 0.5),
|
|
62
|
+
);
|
|
63
|
+
return {
|
|
64
|
+
axis,
|
|
65
|
+
position,
|
|
66
|
+
start: Math.min(snapped[cross], ...onLine.map((o) => o[cross])),
|
|
67
|
+
end: Math.max(snapped[cross] + snapped[size], ...onLine.map((o) => o[cross] + o[size])),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Pull `dragged` onto the nearest line of `others`, one axis at a time. */
|
|
72
|
+
export function computeSnap(
|
|
73
|
+
dragged: SnapRect,
|
|
74
|
+
others: SnapRect[],
|
|
75
|
+
threshold: number = SNAP_THRESHOLD,
|
|
76
|
+
): SnapResult {
|
|
77
|
+
if (others.length === 0) return { x: dragged.x, y: dragged.y, guides: [] };
|
|
78
|
+
|
|
79
|
+
const horizontal = snapAxis(dragged, others, "x", threshold);
|
|
80
|
+
const vertical = snapAxis(dragged, others, "y", threshold);
|
|
81
|
+
|
|
82
|
+
const snapped: SnapRect = {
|
|
83
|
+
...dragged,
|
|
84
|
+
x: dragged.x + horizontal.delta,
|
|
85
|
+
y: dragged.y + vertical.delta,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const guides: SnapGuide[] = [];
|
|
89
|
+
if (horizontal.position !== null) {
|
|
90
|
+
guides.push(guideFor(snapped, others, "x", horizontal.position));
|
|
91
|
+
}
|
|
92
|
+
if (vertical.position !== null) {
|
|
93
|
+
guides.push(guideFor(snapped, others, "y", vertical.position));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { x: snapped.x, y: snapped.y, guides };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The editor's seam. Pull a held free node onto the others and report the
|
|
101
|
+
* lines. A node in a container is not a candidate.
|
|
102
|
+
*/
|
|
103
|
+
// The correction goes back into the returned nodes, which is what makes it
|
|
104
|
+
// magnetism and not a hint. xyflow recomputes from the drag origin, so it
|
|
105
|
+
// never stacks.
|
|
106
|
+
export function snapDraggedNode<
|
|
107
|
+
T extends {
|
|
108
|
+
id: string;
|
|
109
|
+
position: { x: number; y: number };
|
|
110
|
+
parentId?: string;
|
|
111
|
+
hidden?: boolean;
|
|
112
|
+
measured?: { width?: number; height?: number };
|
|
113
|
+
},
|
|
114
|
+
>(nodes: T[], held: T | null, zoom: number): { nodes: T[]; guides: SnapGuide[] } {
|
|
115
|
+
const rect = freeNodeRect(held);
|
|
116
|
+
if (!held || !rect) return { nodes, guides: [] };
|
|
117
|
+
|
|
118
|
+
const others = nodes
|
|
119
|
+
.filter((n) => n.id !== held.id)
|
|
120
|
+
.map(freeNodeRect)
|
|
121
|
+
.filter((r): r is SnapRect => r !== null);
|
|
122
|
+
|
|
123
|
+
// The threshold is in flow units, so the pull is the same few pixels on
|
|
124
|
+
// screen whether the board is zoomed to a wall or to one card.
|
|
125
|
+
const snap = computeSnap(rect, others, SNAP_THRESHOLD / (zoom || 1));
|
|
126
|
+
const moved = snap.x !== held.position.x || snap.y !== held.position.y;
|
|
127
|
+
const next = moved
|
|
128
|
+
? nodes.map((n) => (n.id === held.id ? { ...n, position: { x: snap.x, y: snap.y } } : n))
|
|
129
|
+
: nodes;
|
|
130
|
+
return { nodes: next, guides: snap.guides };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A candidate rect, or null for a node a container positions or hides. */
|
|
134
|
+
function freeNodeRect(
|
|
135
|
+
node: {
|
|
136
|
+
position: { x: number; y: number };
|
|
137
|
+
parentId?: string;
|
|
138
|
+
hidden?: boolean;
|
|
139
|
+
measured?: { width?: number; height?: number };
|
|
140
|
+
} | null,
|
|
141
|
+
): SnapRect | null {
|
|
142
|
+
if (!node || node.parentId || node.hidden) return null;
|
|
143
|
+
const { width, height } = node.measured ?? {};
|
|
144
|
+
if (!width || !height) return null;
|
|
145
|
+
return { x: node.position.x, y: node.position.y, width, height };
|
|
146
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
2
|
+
import { figureBoxHeight, figureRatio, sanitizeFigureSvg } from "./svg-figure.js";
|
|
3
|
+
|
|
4
|
+
describe("sanitizeFigureSvg", () => {
|
|
5
|
+
it("keeps the shapes, the text and the geometry", () => {
|
|
6
|
+
const svg = sanitizeFigureSvg(
|
|
7
|
+
'<svg viewBox="0 0 100 40"><rect x="0" y="0" width="60" height="40" fill="#e7f5ff" /><text x="4" y="20" font-size="12">Used</text></svg>',
|
|
8
|
+
);
|
|
9
|
+
expect(svg).toContain('<rect x="0" y="0" width="60" height="40" fill="#e7f5ff" />');
|
|
10
|
+
expect(svg).toContain(">Used</text>");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("drops a script with everything inside it", () => {
|
|
14
|
+
const svg = sanitizeFigureSvg('<svg viewBox="0 0 10 10"><script>alert(1)</script></svg>');
|
|
15
|
+
expect(svg).toBe('<svg viewBox="0 0 10 10"></svg>');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("drops an event handler and keeps the shape it rode in on", () => {
|
|
19
|
+
const svg = sanitizeFigureSvg('<svg><circle cx="5" cy="5" r="4" onclick="steal()" /></svg>');
|
|
20
|
+
expect(svg).not.toContain("onclick");
|
|
21
|
+
expect(svg).toContain('<circle cx="5" cy="5" r="4" />');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("drops a link out of the document but keeps a local reference", () => {
|
|
25
|
+
const svg = sanitizeFigureSvg(
|
|
26
|
+
'<svg><a href="https://evil.test"><rect fill="url(#g)" clip-path="url(https://evil.test)" /></a></svg>',
|
|
27
|
+
);
|
|
28
|
+
expect(svg).not.toContain("evil.test");
|
|
29
|
+
expect(svg).toContain('fill="url(#g)"');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("refuses markup with no svg in it, and anything oversized", () => {
|
|
33
|
+
expect(sanitizeFigureSvg("<div>hello</div>")).toBeNull();
|
|
34
|
+
expect(sanitizeFigureSvg(`<svg>${"x".repeat(20000)}</svg>`)).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("figureRatio", () => {
|
|
39
|
+
it("reads the viewBox first and the width/height pair after it", () => {
|
|
40
|
+
expect(figureRatio('<svg viewBox="0 0 200 50">')).toBe(0.25);
|
|
41
|
+
expect(figureRatio('<svg width="100" height="50">')).toBe(0.5);
|
|
42
|
+
expect(figureRatio("<svg>")).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("falls back to a banner when the author drew no box", () => {
|
|
46
|
+
expect(figureBoxHeight('<svg viewBox="0 0 400 100" />', 800)).toBe(200);
|
|
47
|
+
expect(figureBoxHeight("<svg />", 800)).toBe(320);
|
|
48
|
+
});
|
|
49
|
+
});
|