@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,177 @@
|
|
|
1
|
+
// One pen gesture, pointer down to up, in flow coordinates. It talks to the
|
|
2
|
+
// editor through a host, so the overlay stays thin.
|
|
3
|
+
|
|
4
|
+
import { strokesAlong, type ErasableNode } from "./eraser.js";
|
|
5
|
+
import { buildStrokeNode, type Box, type Pt } from "./geometry.js";
|
|
6
|
+
import { addPenNode, removePenNode, type PenOp } from "./ops.js";
|
|
7
|
+
import { buildShapeNode, buildTextNode, dragBox } from "./shapes.js";
|
|
8
|
+
import { strokePath } from "./stroke-path.js";
|
|
9
|
+
import {
|
|
10
|
+
PEN_DEFAULTS,
|
|
11
|
+
type PenColor,
|
|
12
|
+
type PenTool,
|
|
13
|
+
type ShapeKind,
|
|
14
|
+
type StrokePoint,
|
|
15
|
+
} from "./types.js";
|
|
16
|
+
|
|
17
|
+
export interface PenSettings {
|
|
18
|
+
tool: PenTool;
|
|
19
|
+
color: PenColor;
|
|
20
|
+
/** Brush width in flow units. Falls back to the tool's default. */
|
|
21
|
+
size?: number;
|
|
22
|
+
zoom: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PenHost {
|
|
26
|
+
getNodes: () => readonly ErasableNode[];
|
|
27
|
+
/** Apply ops to the board. Called inside an open gesture. */
|
|
28
|
+
commit: (ops: PenOp[]) => void;
|
|
29
|
+
/** Open an undo transaction and hand back its commit. */
|
|
30
|
+
startGesture: () => () => void;
|
|
31
|
+
/** A text node was dropped. The editor opens it for typing. */
|
|
32
|
+
onTextPlaced?: (nodeId: string) => void;
|
|
33
|
+
onPreview?: (preview: PenPreview | null) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What the overlay draws while the pointer is down, in flow coordinates. */
|
|
37
|
+
export type PenPreview =
|
|
38
|
+
| { kind: "stroke"; d: string; color: PenColor; tool: "pen" | "highlighter" }
|
|
39
|
+
| { kind: "shape"; shape: ShapeKind; box: Box; color: PenColor }
|
|
40
|
+
| { kind: "eraser"; at: Pt; radius: number };
|
|
41
|
+
|
|
42
|
+
const ERASER_RADIUS = 10;
|
|
43
|
+
/** Samples closer than this, in screen pixels, add nothing to a stroke. */
|
|
44
|
+
const MIN_SAMPLE = 1;
|
|
45
|
+
|
|
46
|
+
function isShapeTool(tool: PenTool): tool is ShapeKind {
|
|
47
|
+
return tool === "rectangle" || tool === "ellipse" || tool === "diamond";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createPenSession(host: PenHost, getSettings: () => PenSettings) {
|
|
51
|
+
let endGesture: (() => void) | null = null;
|
|
52
|
+
let start: Pt | null = null;
|
|
53
|
+
let last: Pt | null = null;
|
|
54
|
+
let samples: StrokePoint[] = [];
|
|
55
|
+
const erased = new Set<string>();
|
|
56
|
+
|
|
57
|
+
function settingsNow() {
|
|
58
|
+
const settings = getSettings();
|
|
59
|
+
const fallback =
|
|
60
|
+
settings.tool === "highlighter" ? PEN_DEFAULTS.highlighter.size : PEN_DEFAULTS.pen.size;
|
|
61
|
+
return { ...settings, size: settings.size ?? fallback };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function reset() {
|
|
65
|
+
endGesture = null;
|
|
66
|
+
start = null;
|
|
67
|
+
last = null;
|
|
68
|
+
samples = [];
|
|
69
|
+
erased.clear();
|
|
70
|
+
host.onPreview?.(null);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function erase(from: Pt, to: Pt, radius: number) {
|
|
74
|
+
const remaining = host.getNodes().filter((node) => !erased.has(node.id));
|
|
75
|
+
const ids = strokesAlong(remaining, from, to, radius);
|
|
76
|
+
if (ids.length === 0) return;
|
|
77
|
+
for (const id of ids) erased.add(id);
|
|
78
|
+
host.commit(ids.map(removePenNode));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function placeText(at: Pt, color: PenColor) {
|
|
82
|
+
const op = addPenNode(buildTextNode(at, { color }));
|
|
83
|
+
host.commit([op]);
|
|
84
|
+
if (op.kind === "addNode" && op.node.id) host.onTextPlaced?.(op.node.id);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sampleOf(at: Pt, pressure?: number): StrokePoint {
|
|
88
|
+
return pressure === undefined ? [at.x, at.y] : [at.x, at.y, pressure];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function previewStroke(pointerDown: boolean) {
|
|
92
|
+
const { tool, color, size } = settingsNow();
|
|
93
|
+
if (tool !== "pen" && tool !== "highlighter") return;
|
|
94
|
+
const hasPressure = samples.some((point) => point[2] !== undefined);
|
|
95
|
+
const d = strokePath(samples, { size, tool, simulatePressure: !hasPressure }, !pointerDown);
|
|
96
|
+
host.onPreview?.({ kind: "stroke", d, color, tool });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
/** True while a gesture is open, so the overlay knows to keep capturing. */
|
|
101
|
+
get active() {
|
|
102
|
+
return start !== null;
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
down(at: Pt, pressure?: number) {
|
|
106
|
+
if (start) return;
|
|
107
|
+
const { tool, color, zoom } = settingsNow();
|
|
108
|
+
|
|
109
|
+
if (tool === "text") {
|
|
110
|
+
placeText(at, color);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
endGesture = host.startGesture();
|
|
115
|
+
start = at;
|
|
116
|
+
last = at;
|
|
117
|
+
if (tool === "eraser") {
|
|
118
|
+
const radius = ERASER_RADIUS / zoom;
|
|
119
|
+
host.onPreview?.({ kind: "eraser", at, radius });
|
|
120
|
+
erase(at, at, radius);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (tool === "pen" || tool === "highlighter") {
|
|
124
|
+
samples = [sampleOf(at, pressure)];
|
|
125
|
+
previewStroke(true);
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
move(at: Pt, pressure?: number) {
|
|
130
|
+
if (!start || !last) return;
|
|
131
|
+
const { tool, color, zoom } = settingsNow();
|
|
132
|
+
if (tool === "eraser") {
|
|
133
|
+
const radius = ERASER_RADIUS / zoom;
|
|
134
|
+
host.onPreview?.({ kind: "eraser", at, radius });
|
|
135
|
+
erase(last, at, radius);
|
|
136
|
+
last = at;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (isShapeTool(tool)) {
|
|
140
|
+
host.onPreview?.({ kind: "shape", shape: tool, box: dragBox(start, at), color });
|
|
141
|
+
last = at;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (Math.hypot(at.x - last.x, at.y - last.y) * zoom < MIN_SAMPLE) return;
|
|
145
|
+
samples.push(sampleOf(at, pressure));
|
|
146
|
+
last = at;
|
|
147
|
+
previewStroke(true);
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
/** Pointer up. Writes the node, then closes the gesture so it's one undo step. */
|
|
151
|
+
up(at: Pt, options: { square?: boolean } = {}) {
|
|
152
|
+
if (!start || !endGesture) return;
|
|
153
|
+
const { tool, color, size, zoom } = settingsNow();
|
|
154
|
+
const close = endGesture;
|
|
155
|
+
try {
|
|
156
|
+
if (tool === "pen" || tool === "highlighter") {
|
|
157
|
+
const node = buildStrokeNode(samples, { color, size, tool, zoom });
|
|
158
|
+
if (node) host.commit([addPenNode(node)]);
|
|
159
|
+
} else if (isShapeTool(tool)) {
|
|
160
|
+
const box = dragBox(start, at, options.square);
|
|
161
|
+
host.commit([addPenNode(buildShapeNode(tool, box, { color }))]);
|
|
162
|
+
}
|
|
163
|
+
} finally {
|
|
164
|
+
close();
|
|
165
|
+
reset();
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
/** Escape or a lost pointer. Whatever the eraser already removed stays removed. */
|
|
170
|
+
cancel() {
|
|
171
|
+
if (!endGesture) return;
|
|
172
|
+
const close = endGesture;
|
|
173
|
+
close();
|
|
174
|
+
reset();
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/* Pen colour tokens, light by default and lifted on a dark canvas. Values mirror colors.ts. */
|
|
2
|
+
.svelte-flow,
|
|
3
|
+
.pen-overlay {
|
|
4
|
+
--pen-ink: #1e1e1e;
|
|
5
|
+
--pen-red: #e03131;
|
|
6
|
+
--pen-blue: #1971c2;
|
|
7
|
+
--pen-green: #2f9e44;
|
|
8
|
+
--pen-amber: #e8a33d;
|
|
9
|
+
--pen-violet: #6741d9;
|
|
10
|
+
--pen-indigo: #414fd2;
|
|
11
|
+
--pen-terra: #f0503d;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.dark .svelte-flow,
|
|
15
|
+
.svelte-flow.dark,
|
|
16
|
+
.dark .pen-overlay {
|
|
17
|
+
--pen-ink: #e9ecef;
|
|
18
|
+
--pen-red: #ff8787;
|
|
19
|
+
--pen-blue: #74c0fc;
|
|
20
|
+
--pen-green: #69db7c;
|
|
21
|
+
--pen-amber: #ffd43b;
|
|
22
|
+
--pen-violet: #b197fc;
|
|
23
|
+
--pen-indigo: #8b96ff;
|
|
24
|
+
--pen-terra: #ff8a78;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* Ink is an annotation layer, above every card, selected or framed, and in the order it was drawn.
|
|
28
|
+
The three classes beat a dragging card's own z-index. */
|
|
29
|
+
.svelte-flow .svelte-flow__node.svelte-flow__node-stroke,
|
|
30
|
+
.svelte-flow .svelte-flow__node.svelte-flow__node-text,
|
|
31
|
+
.svelte-flow .svelte-flow__node.svelte-flow__node-shape {
|
|
32
|
+
z-index: 2000 !important;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* The node box of a stroke is its bounding rectangle, so only the ink catches the pointer. */
|
|
36
|
+
.svelte-flow__node-stroke {
|
|
37
|
+
pointer-events: none !important;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* A text takes arrows on its top and bottom, dots that show on hover like a card's. */
|
|
41
|
+
.svelte-flow__handle.pen-handle {
|
|
42
|
+
width: 8px;
|
|
43
|
+
height: 8px;
|
|
44
|
+
border: 2px solid var(--color-background, #fff);
|
|
45
|
+
border-radius: 9999px;
|
|
46
|
+
background: var(--color-muted-foreground, #868e96);
|
|
47
|
+
opacity: 0;
|
|
48
|
+
transition: opacity 120ms ease-out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@media (hover: hover) and (pointer: fine) {
|
|
52
|
+
.svelte-flow__node-text:hover .pen-handle {
|
|
53
|
+
opacity: 1;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.diagram-connecting .pen-handle {
|
|
58
|
+
opacity: 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* A shape is an outline, so a click inside an empty one reaches the cards beneath it. */
|
|
62
|
+
.svelte-flow__node-shape {
|
|
63
|
+
pointer-events: none !important;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.svelte-flow__node-shape .svelte-flow__resize-control {
|
|
67
|
+
pointer-events: auto;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* Selected ink thickens its own line; no ring or box. `--sw` is the line's own width,
|
|
71
|
+
and a host may set `--select-stroke`. */
|
|
72
|
+
.svelte-flow__node.selected .pen-line {
|
|
73
|
+
stroke-width: calc(var(--sw) * var(--select-stroke, 1.7));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* The resizer's lines are hit areas, not a drawn box; its handles stay small. */
|
|
77
|
+
.svelte-flow__node-shape .svelte-flow__resize-control.line {
|
|
78
|
+
border-color: transparent;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.svelte-flow__node-shape .svelte-flow__resize-control.handle {
|
|
82
|
+
width: 8px;
|
|
83
|
+
height: 8px;
|
|
84
|
+
border: 1.5px solid var(--pen-blue);
|
|
85
|
+
border-radius: 9999px;
|
|
86
|
+
background: var(--color-background, #fff);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* The words of a pen text and a shape label, plain textarea or the host's formatted field. */
|
|
90
|
+
.pen-field {
|
|
91
|
+
display: block;
|
|
92
|
+
margin: 0;
|
|
93
|
+
padding: 2px 4px;
|
|
94
|
+
border: 0;
|
|
95
|
+
border-radius: 4px;
|
|
96
|
+
outline: none;
|
|
97
|
+
background: transparent;
|
|
98
|
+
font: inherit;
|
|
99
|
+
font-weight: 500;
|
|
100
|
+
line-height: 1.4;
|
|
101
|
+
min-width: 2ch;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
textarea.pen-field {
|
|
105
|
+
field-sizing: content;
|
|
106
|
+
resize: none;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* A drawn page writes in the hand; the host may name the face, else Excalifont. */
|
|
110
|
+
.pen-field.hand {
|
|
111
|
+
font-family: var(--pen-hand-font, "Excalifont", "Segoe Print", "Bradley Hand", cursive);
|
|
112
|
+
font-weight: 400;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.pen-field.is-text {
|
|
116
|
+
max-width: 480px;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.pen-field.is-text.column {
|
|
120
|
+
width: 100%;
|
|
121
|
+
max-width: none;
|
|
122
|
+
box-sizing: border-box;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* Selected, the glyphs themselves get heavier in their own ink; a box round
|
|
126
|
+
free text reads as a second shape. */
|
|
127
|
+
.pen-field.is-text.selected {
|
|
128
|
+
-webkit-text-stroke: 0.045em currentColor;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.pen-field.is-label {
|
|
132
|
+
position: absolute;
|
|
133
|
+
top: 50%;
|
|
134
|
+
left: 50%;
|
|
135
|
+
transform: translate(-50%, -50%);
|
|
136
|
+
max-width: 80%;
|
|
137
|
+
text-align: center;
|
|
138
|
+
pointer-events: auto;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* The host's field is as wide as its words, like the textarea it stands in for. */
|
|
142
|
+
.pen-field.is-rich {
|
|
143
|
+
min-height: 1.4em;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.pen-field.is-rich.is-label,
|
|
147
|
+
.pen-field.is-rich.is-text:not(.column) {
|
|
148
|
+
width: max-content;
|
|
149
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// The pen's node kinds and ops, with no DOM, so the CLI can add strokes.
|
|
2
|
+
// ui.ts adds the renderers.
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { CanvasOpError } from "../ops/ops.js";
|
|
6
|
+
import { definePlugin, type OpState } from "../plugin.js";
|
|
7
|
+
import { buildStrokeNode } from "./geometry.js";
|
|
8
|
+
import { buildShapeNode, buildTextNode } from "./shapes.js";
|
|
9
|
+
import {
|
|
10
|
+
PEN_COLORS,
|
|
11
|
+
SHAPE_KINDS,
|
|
12
|
+
SHAPE_NODE,
|
|
13
|
+
STROKE_NODE,
|
|
14
|
+
TEXT_NODE,
|
|
15
|
+
type PenNodeInput,
|
|
16
|
+
} from "./types.js";
|
|
17
|
+
|
|
18
|
+
// =========================
|
|
19
|
+
// SCHEMAS
|
|
20
|
+
// =========================
|
|
21
|
+
|
|
22
|
+
const colorSchema = z.enum(PEN_COLORS);
|
|
23
|
+
const pointSchema = z.object({ x: z.number(), y: z.number() });
|
|
24
|
+
|
|
25
|
+
/** A board holds a few thousand strokes at most, and one stroke past this is a scribble pasted from a file. */
|
|
26
|
+
const MAX_POINTS = 5000;
|
|
27
|
+
|
|
28
|
+
const addStrokeOp = z.object({
|
|
29
|
+
kind: z.literal("addStroke"),
|
|
30
|
+
id: z.string().min(1).optional(),
|
|
31
|
+
/** Flow coordinates, x then y then an optional 0 to 1 pressure. Simplified on the way in. */
|
|
32
|
+
points: z
|
|
33
|
+
.array(z.tuple([z.number(), z.number()]).rest(z.number()))
|
|
34
|
+
.min(1)
|
|
35
|
+
.max(MAX_POINTS),
|
|
36
|
+
color: colorSchema.optional(),
|
|
37
|
+
size: z.number().positive().optional(),
|
|
38
|
+
tool: z.enum(["pen", "highlighter"]).optional(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const addTextOp = z.object({
|
|
42
|
+
kind: z.literal("addText"),
|
|
43
|
+
id: z.string().min(1).optional(),
|
|
44
|
+
at: pointSchema,
|
|
45
|
+
text: z.string(),
|
|
46
|
+
color: colorSchema.optional(),
|
|
47
|
+
fontSize: z.number().positive().optional(),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const addShapeOp = z.object({
|
|
51
|
+
kind: z.literal("addShape"),
|
|
52
|
+
id: z.string().min(1).optional(),
|
|
53
|
+
shape: z.enum(SHAPE_KINDS),
|
|
54
|
+
box: z.object({
|
|
55
|
+
x: z.number(),
|
|
56
|
+
y: z.number(),
|
|
57
|
+
width: z.number().nonnegative(),
|
|
58
|
+
height: z.number().nonnegative(),
|
|
59
|
+
}),
|
|
60
|
+
color: colorSchema.optional(),
|
|
61
|
+
filled: z.boolean().optional(),
|
|
62
|
+
label: z.string().optional(),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
type AddStroke = z.infer<typeof addStrokeOp>;
|
|
66
|
+
type AddText = z.infer<typeof addTextOp>;
|
|
67
|
+
type AddShape = z.infer<typeof addShapeOp>;
|
|
68
|
+
|
|
69
|
+
// =========================
|
|
70
|
+
// HANDLERS
|
|
71
|
+
// =========================
|
|
72
|
+
|
|
73
|
+
function appendNode(
|
|
74
|
+
state: OpState,
|
|
75
|
+
node: PenNodeInput<Record<string, unknown>>,
|
|
76
|
+
opIndex: number,
|
|
77
|
+
): void {
|
|
78
|
+
const id = node.id ?? crypto.randomUUID();
|
|
79
|
+
if (state.nodes.some((existing) => existing.id === id)) {
|
|
80
|
+
throw new CanvasOpError(`Node id already exists: ${id}`, opIndex);
|
|
81
|
+
}
|
|
82
|
+
state.nodes = [...state.nodes, { ...node, id }];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function applyAddStroke(state: OpState, op: AddStroke, opIndex: number): void {
|
|
86
|
+
const node = buildStrokeNode(op.points, {
|
|
87
|
+
id: op.id,
|
|
88
|
+
color: op.color,
|
|
89
|
+
size: op.size,
|
|
90
|
+
tool: op.tool,
|
|
91
|
+
});
|
|
92
|
+
if (!node) throw new CanvasOpError("addStroke needs at least one point", opIndex);
|
|
93
|
+
appendNode(state, node, opIndex);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function applyAddText(state: OpState, op: AddText, opIndex: number): void {
|
|
97
|
+
appendNode(
|
|
98
|
+
state,
|
|
99
|
+
buildTextNode(op.at, { id: op.id, text: op.text, color: op.color, fontSize: op.fontSize }),
|
|
100
|
+
opIndex,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function applyAddShape(state: OpState, op: AddShape, opIndex: number): void {
|
|
105
|
+
appendNode(
|
|
106
|
+
state,
|
|
107
|
+
buildShapeNode(op.shape, op.box, {
|
|
108
|
+
id: op.id,
|
|
109
|
+
color: op.color,
|
|
110
|
+
filled: op.filled,
|
|
111
|
+
label: op.label,
|
|
112
|
+
}),
|
|
113
|
+
opIndex,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// =========================
|
|
118
|
+
// PLUGIN
|
|
119
|
+
// =========================
|
|
120
|
+
|
|
121
|
+
/** No `palette` on any kind: the tool bar's pen tools create these, not the card palette. */
|
|
122
|
+
export const penPlugin = definePlugin({
|
|
123
|
+
id: "pen",
|
|
124
|
+
nodes: [
|
|
125
|
+
{ type: STROKE_NODE, label: "Stroke" },
|
|
126
|
+
{
|
|
127
|
+
type: TEXT_NODE,
|
|
128
|
+
label: "Text",
|
|
129
|
+
sizing: "content-sized",
|
|
130
|
+
size: { width: 120 },
|
|
131
|
+
},
|
|
132
|
+
{ type: SHAPE_NODE, label: "Shape", size: { width: 160, height: 100 } },
|
|
133
|
+
],
|
|
134
|
+
ops: [
|
|
135
|
+
{ kind: "addStroke", schema: addStrokeOp, apply: applyAddStroke },
|
|
136
|
+
{ kind: "addText", schema: addTextOp, apply: applyAddText },
|
|
137
|
+
{ kind: "addShape", schema: addShapeOp, apply: applyAddShape },
|
|
138
|
+
],
|
|
139
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Shape and text nodes from a drag or a click. Pure, so the CLI can reuse it.
|
|
2
|
+
|
|
3
|
+
import type { Box, Pt } from "./geometry.js";
|
|
4
|
+
import type { PenColor, PenNodeInput, ShapeData, ShapeKind, TextData } from "./types.js";
|
|
5
|
+
import { PEN_DEFAULTS, SHAPE_NODE, TEXT_NODE } from "./types.js";
|
|
6
|
+
|
|
7
|
+
/** A shape smaller than this on both sides was a click, so it gets the default size. */
|
|
8
|
+
const MIN_DRAG = 6;
|
|
9
|
+
const DEFAULT_SHAPE = { width: 160, height: 100 } as const;
|
|
10
|
+
|
|
11
|
+
/** The box a drag from `from` to `to` covers. Shift keeps it square. */
|
|
12
|
+
export function dragBox(from: Pt, to: Pt, square = false): Box {
|
|
13
|
+
let dx = to.x - from.x;
|
|
14
|
+
let dy = to.y - from.y;
|
|
15
|
+
if (square) {
|
|
16
|
+
const side = Math.max(Math.abs(dx), Math.abs(dy));
|
|
17
|
+
dx = Math.sign(dx || 1) * side;
|
|
18
|
+
dy = Math.sign(dy || 1) * side;
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
x: Math.min(from.x, from.x + dx),
|
|
22
|
+
y: Math.min(from.y, from.y + dy),
|
|
23
|
+
width: Math.abs(dx),
|
|
24
|
+
height: Math.abs(dy),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface BuildShapeOptions {
|
|
29
|
+
color?: PenColor;
|
|
30
|
+
filled?: boolean;
|
|
31
|
+
label?: string;
|
|
32
|
+
id?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function buildShapeNode(
|
|
36
|
+
shape: ShapeKind,
|
|
37
|
+
box: Box,
|
|
38
|
+
options: BuildShapeOptions = {},
|
|
39
|
+
): PenNodeInput<ShapeData> {
|
|
40
|
+
const click = box.width < MIN_DRAG && box.height < MIN_DRAG;
|
|
41
|
+
const width = Math.round(click ? DEFAULT_SHAPE.width : Math.max(box.width, MIN_DRAG));
|
|
42
|
+
const height = Math.round(click ? DEFAULT_SHAPE.height : Math.max(box.height, MIN_DRAG));
|
|
43
|
+
// A click centres the default shape on the cursor.
|
|
44
|
+
const x = click ? box.x - width / 2 : box.x;
|
|
45
|
+
const y = click ? box.y - height / 2 : box.y;
|
|
46
|
+
return {
|
|
47
|
+
id: options.id,
|
|
48
|
+
type: SHAPE_NODE,
|
|
49
|
+
position: { x: Math.round(x), y: Math.round(y) },
|
|
50
|
+
data: {
|
|
51
|
+
shape,
|
|
52
|
+
color: options.color ?? PEN_DEFAULTS.color,
|
|
53
|
+
strokeWidth: PEN_DEFAULTS.strokeWidth,
|
|
54
|
+
filled: options.filled ?? false,
|
|
55
|
+
label: options.label ?? "",
|
|
56
|
+
},
|
|
57
|
+
width,
|
|
58
|
+
height,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function buildTextNode(
|
|
63
|
+
at: Pt,
|
|
64
|
+
options: { text?: string; color?: PenColor; fontSize?: number; id?: string } = {},
|
|
65
|
+
): PenNodeInput<TextData> {
|
|
66
|
+
return {
|
|
67
|
+
id: options.id,
|
|
68
|
+
type: TEXT_NODE,
|
|
69
|
+
position: { x: Math.round(at.x), y: Math.round(at.y) },
|
|
70
|
+
data: {
|
|
71
|
+
text: options.text ?? "",
|
|
72
|
+
color: options.color ?? PEN_DEFAULTS.color,
|
|
73
|
+
fontSize: options.fontSize ?? PEN_DEFAULTS.fontSize,
|
|
74
|
+
},
|
|
75
|
+
// Text sizes itself to the words once it renders, so this is only the empty box.
|
|
76
|
+
width: 120,
|
|
77
|
+
height: Math.round((options.fontSize ?? PEN_DEFAULTS.fontSize) * 1.4),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The outline of a shape inside a width by height box, as an SVG path. */
|
|
82
|
+
export function shapeOutline(
|
|
83
|
+
shape: ShapeKind,
|
|
84
|
+
width: number,
|
|
85
|
+
height: number,
|
|
86
|
+
inset: number,
|
|
87
|
+
): string {
|
|
88
|
+
const left = inset;
|
|
89
|
+
const top = inset;
|
|
90
|
+
const right = Math.max(width - inset, left);
|
|
91
|
+
const bottom = Math.max(height - inset, top);
|
|
92
|
+
if (shape === "diamond") {
|
|
93
|
+
const midX = (left + right) / 2;
|
|
94
|
+
const midY = (top + bottom) / 2;
|
|
95
|
+
return `M${midX},${top} L${right},${midY} L${midX},${bottom} L${left},${midY} Z`;
|
|
96
|
+
}
|
|
97
|
+
if (shape === "ellipse") {
|
|
98
|
+
const rx = (right - left) / 2;
|
|
99
|
+
const ry = (bottom - top) / 2;
|
|
100
|
+
const cy = (top + bottom) / 2;
|
|
101
|
+
return `M${left},${cy} a${rx},${ry} 0 1,0 ${rx * 2},0 a${rx},${ry} 0 1,0 ${-rx * 2},0 Z`;
|
|
102
|
+
}
|
|
103
|
+
return `M${left},${top} H${right} V${bottom} H${left} Z`;
|
|
104
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// A stroke's points become one SVG path through perfect-freehand. The editor,
|
|
2
|
+
// the headless export and the tests all call this, so they draw the same shape.
|
|
3
|
+
|
|
4
|
+
import { getStroke } from "perfect-freehand";
|
|
5
|
+
import type { StrokeData, StrokePoint } from "./types.js";
|
|
6
|
+
|
|
7
|
+
function average(a: number, b: number): number {
|
|
8
|
+
return (a + b) / 2;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Quadratic smoothing through the outline's midpoints, the way perfect-freehand's README does it. */
|
|
12
|
+
export function outlineToPath(outline: ReadonlyArray<readonly number[]>): string {
|
|
13
|
+
if (outline.length < 4) return "";
|
|
14
|
+
const first = outline[0];
|
|
15
|
+
const second = outline[1];
|
|
16
|
+
const third = outline[2];
|
|
17
|
+
let path =
|
|
18
|
+
`M${first[0].toFixed(2)},${first[1].toFixed(2)} ` +
|
|
19
|
+
`Q${second[0].toFixed(2)},${second[1].toFixed(2)} ` +
|
|
20
|
+
`${average(second[0], third[0]).toFixed(2)},${average(second[1], third[1]).toFixed(2)} T`;
|
|
21
|
+
for (let i = 2; i < outline.length - 1; i++) {
|
|
22
|
+
const a = outline[i];
|
|
23
|
+
const b = outline[i + 1];
|
|
24
|
+
path += `${average(a[0], b[0]).toFixed(2)},${average(a[1], b[1]).toFixed(2)} `;
|
|
25
|
+
}
|
|
26
|
+
return `${path}Z`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type StrokeShape = Pick<StrokeData, "size" | "tool" | "simulatePressure">;
|
|
30
|
+
|
|
31
|
+
/** `last` is false while the pen is still down, so the live tip isn't tapered to a point. */
|
|
32
|
+
export function strokePath(
|
|
33
|
+
points: readonly StrokePoint[],
|
|
34
|
+
shape: StrokeShape,
|
|
35
|
+
last = true,
|
|
36
|
+
): string {
|
|
37
|
+
if (points.length === 0) return "";
|
|
38
|
+
const highlighter = shape.tool === "highlighter";
|
|
39
|
+
const outline = getStroke(
|
|
40
|
+
points.map((point) =>
|
|
41
|
+
point[2] === undefined ? [point[0], point[1]] : [point[0], point[1], point[2]],
|
|
42
|
+
),
|
|
43
|
+
{
|
|
44
|
+
size: shape.size,
|
|
45
|
+
// Flat ink for the highlighter: no pressure taper, a straight sweep.
|
|
46
|
+
thinning: highlighter ? 0 : 0.55,
|
|
47
|
+
smoothing: 0.6,
|
|
48
|
+
streamline: highlighter ? 0.7 : 0.5,
|
|
49
|
+
simulatePressure: shape.simulatePressure && !highlighter,
|
|
50
|
+
last,
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
return outlineToPath(outline);
|
|
54
|
+
}
|