@misoto22/design 0.7.0 → 0.8.0
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/AGENTS.md +87 -0
- package/CLAUDE.md +1 -0
- package/README.md +50 -5
- package/bin/misoto22-design.mjs +59 -13
- package/dist/agent/Accordion.md +24 -1
- package/dist/agent/Alert.md +23 -1
- package/dist/agent/AppShell.md +30 -2
- package/dist/agent/ArchitectureFigure.md +25 -2
- package/dist/agent/AreaChart.md +27 -2
- package/dist/agent/Article.md +26 -3
- package/dist/agent/AspectRatio.md +57 -0
- package/dist/agent/Avatar.md +25 -5
- package/dist/agent/Badge.md +22 -1
- package/dist/agent/BarChart.md +29 -2
- package/dist/agent/BarList.md +27 -1
- package/dist/agent/BigNumber.md +26 -2
- package/dist/agent/BoxPlot.md +24 -1
- package/dist/agent/Breadcrumb.md +24 -2
- package/dist/agent/BulletChart.md +23 -1
- package/dist/agent/Button.md +25 -1
- package/dist/agent/Calendar.md +37 -3
- package/dist/agent/Card.md +24 -2
- package/dist/agent/Checkbox.md +23 -2
- package/dist/agent/Code.md +50 -0
- package/dist/agent/CodeBlock.md +74 -0
- package/dist/agent/Collapsible.md +22 -1
- package/dist/agent/Combobox.md +29 -3
- package/dist/agent/Command.md +25 -2
- package/dist/agent/ComposedChart.md +26 -2
- package/dist/agent/ContextMenu.md +37 -4
- package/dist/agent/DataflowFigure.md +22 -1
- package/dist/agent/DatePicker.md +34 -6
- package/dist/agent/DescriptionList.md +68 -0
- package/dist/agent/Diagram.md +27 -3
- package/dist/agent/DiagramCanvas.md +24 -1
- package/dist/agent/DiagramExportMenu.md +29 -3
- package/dist/agent/DiagramInspector.md +23 -1
- package/dist/agent/DiagramLegend.md +22 -1
- package/dist/agent/DiagramMinimap.md +24 -4
- package/dist/agent/DiagramToolbar.md +23 -1
- package/dist/agent/Dialog.md +26 -3
- package/dist/agent/DropdownMenu.md +45 -6
- package/dist/agent/EmptyState.md +26 -1
- package/dist/agent/ErrorState.md +27 -2
- package/dist/agent/Facet.md +25 -1
- package/dist/agent/Field.md +45 -4
- package/dist/agent/FigureBand.md +24 -1
- package/dist/agent/FloatingIconButton.md +23 -1
- package/dist/agent/FunnelChart.md +23 -1
- package/dist/agent/Heading.md +64 -0
- package/dist/agent/Heatmap.md +25 -1
- package/dist/agent/Histogram.md +26 -2
- package/dist/agent/Input.md +22 -1
- package/dist/agent/Kbd.md +19 -1
- package/dist/agent/LifecycleFigure.md +22 -1
- package/dist/agent/LineChart.md +26 -2
- package/dist/agent/LinkArrow.md +20 -1
- package/dist/agent/Markdown.md +68 -0
- package/dist/agent/NativeSelect.md +23 -2
- package/dist/agent/NavItem.md +21 -1
- package/dist/agent/Pagination.md +28 -2
- package/dist/agent/PieChart.md +26 -2
- package/dist/agent/Popover.md +23 -1
- package/dist/agent/Progress.md +27 -3
- package/dist/agent/RadarChart.md +26 -2
- package/dist/agent/RadialChart.md +26 -3
- package/dist/agent/RadioGroup.md +23 -2
- package/dist/agent/SankeyChart.md +25 -2
- package/dist/agent/ScatterChart.md +27 -2
- package/dist/agent/ScrollArea.md +24 -3
- package/dist/agent/SearchableMenu.md +23 -1
- package/dist/agent/Select.md +29 -3
- package/dist/agent/Separator.md +28 -4
- package/dist/agent/SequenceFigure.md +23 -1
- package/dist/agent/Sheet.md +25 -2
- package/dist/agent/Skeleton.md +24 -2
- package/dist/agent/Slider.md +27 -4
- package/dist/agent/Sparkline.md +23 -1
- package/dist/agent/Spinner.md +24 -2
- package/dist/agent/StatusDot.md +20 -1
- package/dist/agent/StatusPill.md +28 -2
- package/dist/agent/Steps.md +24 -1
- package/dist/agent/Switch.md +21 -1
- package/dist/agent/Table.md +26 -3
- package/dist/agent/Tabs.md +24 -2
- package/dist/agent/Tag.md +40 -9
- package/dist/agent/Text.md +65 -0
- package/dist/agent/Textarea.md +20 -1
- package/dist/agent/Timestamp.md +64 -0
- package/dist/agent/Toast.md +26 -2
- package/dist/agent/ToggleGroup.md +24 -2
- package/dist/agent/Toolbar.md +71 -0
- package/dist/agent/Tooltip.md +23 -1
- package/dist/agent/TreemapChart.md +24 -1
- package/dist/agent/WaterfallChart.md +24 -1
- package/dist/agent/WorkflowFigure.md +24 -2
- package/dist/agent/catalog.json +5602 -745
- package/dist/agent/index.md +13 -4
- package/dist/article.css +16 -4
- package/dist/charts/AreaChart/AreaChart.d.ts +9 -2
- package/dist/charts/AreaChart/AreaChart.js +12 -13
- package/dist/charts/AreaChart/AreaChart.js.map +1 -1
- package/dist/charts/BarChart/BarChart.d.ts +14 -3
- package/dist/charts/BarChart/BarChart.js +20 -12
- package/dist/charts/BarChart/BarChart.js.map +1 -1
- package/dist/charts/BarList/BarList.d.ts +9 -1
- package/dist/charts/BarList/BarList.js +9 -1
- package/dist/charts/BarList/BarList.js.map +1 -1
- package/dist/charts/BigNumber/BigNumber.d.ts +19 -3
- package/dist/charts/BigNumber/BigNumber.js +25 -5
- package/dist/charts/BigNumber/BigNumber.js.map +1 -1
- package/dist/charts/BoxPlot/BoxPlot.js +4 -2
- package/dist/charts/BoxPlot/BoxPlot.js.map +1 -1
- package/dist/charts/BulletChart/BulletChart.js +28 -7
- package/dist/charts/BulletChart/BulletChart.js.map +1 -1
- package/dist/charts/ComposedChart/ComposedChart.d.ts +9 -2
- package/dist/charts/ComposedChart/ComposedChart.js +10 -10
- package/dist/charts/ComposedChart/ComposedChart.js.map +1 -1
- package/dist/charts/FunnelChart/FunnelChart.d.ts +7 -2
- package/dist/charts/FunnelChart/FunnelChart.js +6 -16
- package/dist/charts/FunnelChart/FunnelChart.js.map +1 -1
- package/dist/charts/Heatmap/Heatmap.d.ts +17 -3
- package/dist/charts/Heatmap/Heatmap.js +20 -11
- package/dist/charts/Heatmap/Heatmap.js.map +1 -1
- package/dist/charts/Histogram/Histogram.d.ts +24 -1
- package/dist/charts/Histogram/Histogram.js +43 -11
- package/dist/charts/Histogram/Histogram.js.map +1 -1
- package/dist/charts/LineChart/LineChart.d.ts +9 -2
- package/dist/charts/LineChart/LineChart.js +11 -12
- package/dist/charts/LineChart/LineChart.js.map +1 -1
- package/dist/charts/PieChart/PieChart.d.ts +15 -3
- package/dist/charts/PieChart/PieChart.js +14 -6
- package/dist/charts/PieChart/PieChart.js.map +1 -1
- package/dist/charts/RadarChart/RadarChart.d.ts +15 -3
- package/dist/charts/RadarChart/RadarChart.js +11 -9
- package/dist/charts/RadarChart/RadarChart.js.map +1 -1
- package/dist/charts/RadialChart/RadialChart.d.ts +22 -4
- package/dist/charts/RadialChart/RadialChart.js +28 -6
- package/dist/charts/RadialChart/RadialChart.js.map +1 -1
- package/dist/charts/SankeyChart/SankeyChart.d.ts +16 -4
- package/dist/charts/SankeyChart/SankeyChart.js +14 -6
- package/dist/charts/SankeyChart/SankeyChart.js.map +1 -1
- package/dist/charts/ScatterChart/ScatterChart.d.ts +24 -3
- package/dist/charts/ScatterChart/ScatterChart.js +21 -15
- package/dist/charts/ScatterChart/ScatterChart.js.map +1 -1
- package/dist/charts/Sparkline/Sparkline.js +5 -3
- package/dist/charts/Sparkline/Sparkline.js.map +1 -1
- package/dist/charts/TreemapChart/TreemapChart.d.ts +7 -2
- package/dist/charts/TreemapChart/TreemapChart.js +20 -11
- package/dist/charts/TreemapChart/TreemapChart.js.map +1 -1
- package/dist/charts/WaterfallChart/WaterfallChart.js +4 -2
- package/dist/charts/WaterfallChart/WaterfallChart.js.map +1 -1
- package/dist/charts/lib/export.d.ts +1 -0
- package/dist/charts/lib/figure.d.ts +21 -1
- package/dist/charts/lib/figure.js +5 -2
- package/dist/charts/lib/figure.js.map +1 -1
- package/dist/charts/lib/scale.d.ts +23 -0
- package/dist/charts/lib/scale.js +13 -0
- package/dist/charts/lib/scale.js.map +1 -0
- package/dist/charts/lib/selection.d.ts +20 -0
- package/dist/charts/lib/selection.js +18 -0
- package/dist/charts/lib/selection.js.map +1 -0
- package/dist/charts/lib/toolbar.d.ts +1 -0
- package/dist/components/Accordion/Accordion.js +8 -1
- package/dist/components/Accordion/Accordion.js.map +1 -1
- package/dist/components/AppShell/AppShell.d.ts +6 -0
- package/dist/components/AppShell/AppShell.js +34 -4
- package/dist/components/AppShell/AppShell.js.map +1 -1
- package/dist/components/Article/Article.d.ts +11 -4
- package/dist/components/Article/Article.js +13 -0
- package/dist/components/Article/Article.js.map +1 -1
- package/dist/components/AspectRatio/AspectRatio.d.ts +46 -0
- package/dist/components/AspectRatio/AspectRatio.js +33 -0
- package/dist/components/AspectRatio/AspectRatio.js.map +1 -0
- package/dist/components/Avatar/Avatar.d.ts +12 -3
- package/dist/components/Avatar/Avatar.js +6 -1
- package/dist/components/Avatar/Avatar.js.map +1 -1
- package/dist/components/Breadcrumb/Breadcrumb.d.ts +6 -0
- package/dist/components/Breadcrumb/Breadcrumb.js +15 -0
- package/dist/components/Breadcrumb/Breadcrumb.js.map +1 -1
- package/dist/components/Button/Button.js +14 -0
- package/dist/components/Button/Button.js.map +1 -1
- package/dist/components/Calendar/Calendar.d.ts +29 -3
- package/dist/components/Calendar/Calendar.js +60 -12
- package/dist/components/Calendar/Calendar.js.map +1 -1
- package/dist/components/Card/Card.d.ts +6 -0
- package/dist/components/Card/Card.js +1 -1
- package/dist/components/Card/Card.js.map +1 -1
- package/dist/components/Checkbox/Checkbox.d.ts +5 -2
- package/dist/components/Checkbox/Checkbox.js +12 -2
- package/dist/components/Checkbox/Checkbox.js.map +1 -1
- package/dist/components/Code/Code.d.ts +30 -0
- package/dist/components/Code/Code.js +21 -0
- package/dist/components/Code/Code.js.map +1 -0
- package/dist/components/CodeBlock/CodeBlock.d.ts +141 -0
- package/dist/components/CodeBlock/CodeBlock.js +123 -0
- package/dist/components/CodeBlock/CodeBlock.js.map +1 -0
- package/dist/components/Collapsible/Collapsible.js +10 -2
- package/dist/components/Collapsible/Collapsible.js.map +1 -1
- package/dist/components/Combobox/Combobox.d.ts +15 -1
- package/dist/components/Combobox/Combobox.js +25 -5
- package/dist/components/Combobox/Combobox.js.map +1 -1
- package/dist/components/Command/Command.d.ts +4 -2
- package/dist/components/Command/Command.js +13 -1
- package/dist/components/Command/Command.js.map +1 -1
- package/dist/components/ContextMenu/ContextMenu.d.ts +30 -5
- package/dist/components/ContextMenu/ContextMenu.js +32 -2
- package/dist/components/ContextMenu/ContextMenu.js.map +1 -1
- package/dist/components/DatePicker/DatePicker.d.ts +36 -4
- package/dist/components/DatePicker/DatePicker.js +55 -12
- package/dist/components/DatePicker/DatePicker.js.map +1 -1
- package/dist/components/DescriptionList/DescriptionList.d.ts +73 -0
- package/dist/components/DescriptionList/DescriptionList.js +35 -0
- package/dist/components/DescriptionList/DescriptionList.js.map +1 -0
- package/dist/components/Diagram/Diagram.d.ts +8 -3
- package/dist/components/Diagram/Diagram.js +82 -10
- package/dist/components/Diagram/Diagram.js.map +1 -1
- package/dist/components/Dialog/Dialog.d.ts +18 -3
- package/dist/components/Dialog/Dialog.js +20 -2
- package/dist/components/Dialog/Dialog.js.map +1 -1
- package/dist/components/DropdownMenu/DropdownMenu.d.ts +49 -11
- package/dist/components/DropdownMenu/DropdownMenu.js +32 -2
- package/dist/components/DropdownMenu/DropdownMenu.js.map +1 -1
- package/dist/components/EmptyState/EmptyState.d.ts +25 -1
- package/dist/components/EmptyState/EmptyState.js +3 -1
- package/dist/components/EmptyState/EmptyState.js.map +1 -1
- package/dist/components/ErrorState/ErrorState.d.ts +22 -1
- package/dist/components/ErrorState/ErrorState.js +11 -2
- package/dist/components/ErrorState/ErrorState.js.map +1 -1
- package/dist/components/Field/Field.d.ts +70 -3
- package/dist/components/Field/Field.js +53 -12
- package/dist/components/Field/Field.js.map +1 -1
- package/dist/components/Field/field-control.d.ts +31 -0
- package/dist/components/Field/field-control.js +12 -0
- package/dist/components/Field/field-control.js.map +1 -0
- package/dist/components/FloatingIconButton/FloatingIconButton.js +2 -0
- package/dist/components/FloatingIconButton/FloatingIconButton.js.map +1 -1
- package/dist/components/Heading/Heading.d.ts +62 -0
- package/dist/components/Heading/Heading.js +44 -0
- package/dist/components/Heading/Heading.js.map +1 -0
- package/dist/components/Markdown/Markdown.d.ts +139 -0
- package/dist/components/Markdown/Markdown.js +135 -0
- package/dist/components/Markdown/Markdown.js.map +1 -0
- package/dist/components/Markdown/inline.d.ts +29 -0
- package/dist/components/Markdown/inline.js +74 -0
- package/dist/components/Markdown/inline.js.map +1 -0
- package/dist/components/Markdown/nodes.d.ts +65 -0
- package/dist/components/Markdown/nodes.js +1 -0
- package/dist/components/Markdown/nodes.js.map +1 -0
- package/dist/components/Markdown/parse.d.ts +24 -0
- package/dist/components/Markdown/parse.js +110 -0
- package/dist/components/Markdown/parse.js.map +1 -0
- package/dist/components/Markdown/slug.d.ts +15 -0
- package/dist/components/Markdown/slug.js +7 -0
- package/dist/components/Markdown/slug.js.map +1 -0
- package/dist/components/NativeSelect/NativeSelect.d.ts +6 -0
- package/dist/components/NativeSelect/NativeSelect.js +2 -3
- package/dist/components/NativeSelect/NativeSelect.js.map +1 -1
- package/dist/components/Pagination/Pagination.d.ts +24 -1
- package/dist/components/Pagination/Pagination.js +6 -3
- package/dist/components/Pagination/Pagination.js.map +1 -1
- package/dist/components/Progress/Progress.d.ts +17 -5
- package/dist/components/Progress/Progress.js +18 -6
- package/dist/components/Progress/Progress.js.map +1 -1
- package/dist/components/RadioGroup/RadioGroup.d.ts +6 -0
- package/dist/components/RadioGroup/RadioGroup.js +11 -1
- package/dist/components/RadioGroup/RadioGroup.js.map +1 -1
- package/dist/components/ScrollArea/ScrollArea.d.ts +15 -0
- package/dist/components/ScrollArea/ScrollArea.js +2 -2
- package/dist/components/ScrollArea/ScrollArea.js.map +1 -1
- package/dist/components/SearchableMenu/SearchableMenu.d.ts +11 -2
- package/dist/components/SearchableMenu/SearchableMenu.js +24 -2
- package/dist/components/SearchableMenu/SearchableMenu.js.map +1 -1
- package/dist/components/Select/Select.d.ts +18 -2
- package/dist/components/Select/Select.js +20 -3
- package/dist/components/Select/Select.js.map +1 -1
- package/dist/components/Separator/Separator.d.ts +21 -3
- package/dist/components/Separator/Separator.js +9 -1
- package/dist/components/Separator/Separator.js.map +1 -1
- package/dist/components/Sheet/Sheet.d.ts +3 -0
- package/dist/components/Sheet/Sheet.js +11 -2
- package/dist/components/Sheet/Sheet.js.map +1 -1
- package/dist/components/Skeleton/Skeleton.d.ts +8 -1
- package/dist/components/Skeleton/Skeleton.js +1 -1
- package/dist/components/Skeleton/Skeleton.js.map +1 -1
- package/dist/components/Slider/Slider.d.ts +21 -3
- package/dist/components/Slider/Slider.js +54 -27
- package/dist/components/Slider/Slider.js.map +1 -1
- package/dist/components/Spinner/Spinner.d.ts +8 -0
- package/dist/components/Spinner/Spinner.js +3 -2
- package/dist/components/Spinner/Spinner.js.map +1 -1
- package/dist/components/StatusPill/StatusPill.d.ts +5 -0
- package/dist/components/StatusPill/StatusPill.js +6 -0
- package/dist/components/StatusPill/StatusPill.js.map +1 -1
- package/dist/components/Table/Table.d.ts +6 -0
- package/dist/components/Table/Table.js +3 -1
- package/dist/components/Table/Table.js.map +1 -1
- package/dist/components/Tag/Tag.d.ts +68 -9
- package/dist/components/Tag/Tag.js +50 -10
- package/dist/components/Tag/Tag.js.map +1 -1
- package/dist/components/Text/Text.d.ts +62 -0
- package/dist/components/Text/Text.js +30 -0
- package/dist/components/Text/Text.js.map +1 -0
- package/dist/components/Timestamp/Timestamp.d.ts +59 -0
- package/dist/components/Timestamp/Timestamp.js +56 -0
- package/dist/components/Timestamp/Timestamp.js.map +1 -0
- package/dist/components/Toast/Toast.d.ts +9 -1
- package/dist/components/Toast/Toast.js +27 -4
- package/dist/components/Toast/Toast.js.map +1 -1
- package/dist/components/ToggleGroup/ToggleGroup.d.ts +6 -1
- package/dist/components/ToggleGroup/ToggleGroup.js +11 -1
- package/dist/components/ToggleGroup/ToggleGroup.js.map +1 -1
- package/dist/components/Toolbar/Toolbar.d.ts +55 -0
- package/dist/components/Toolbar/Toolbar.js +43 -0
- package/dist/components/Toolbar/Toolbar.js.map +1 -0
- package/dist/diagrams/ArchitectureFigure/ArchitectureFigure.d.ts +6 -0
- package/dist/diagrams/ArchitectureFigure/ArchitectureFigure.js +48 -8
- package/dist/diagrams/ArchitectureFigure/ArchitectureFigure.js.map +1 -1
- package/dist/diagrams/DataflowFigure/DataflowFigure.js +21 -2
- package/dist/diagrams/DataflowFigure/DataflowFigure.js.map +1 -1
- package/dist/diagrams/DiagramCanvas/DiagramCanvas.d.ts +14 -0
- package/dist/diagrams/DiagramCanvas/DiagramCanvas.js +14 -7
- package/dist/diagrams/DiagramCanvas/DiagramCanvas.js.map +1 -1
- package/dist/diagrams/DiagramExportMenu/DiagramExportMenu.d.ts +38 -4
- package/dist/diagrams/DiagramExportMenu/DiagramExportMenu.js +14 -16
- package/dist/diagrams/DiagramExportMenu/DiagramExportMenu.js.map +1 -1
- package/dist/diagrams/DiagramInspector/DiagramInspector.js +2 -2
- package/dist/diagrams/DiagramInspector/DiagramInspector.js.map +1 -1
- package/dist/diagrams/DiagramMinimap/DiagramMinimap.d.ts +23 -7
- package/dist/diagrams/DiagramMinimap/DiagramMinimap.js +45 -18
- package/dist/diagrams/DiagramMinimap/DiagramMinimap.js.map +1 -1
- package/dist/diagrams/LifecycleFigure/LifecycleFigure.js +44 -12
- package/dist/diagrams/LifecycleFigure/LifecycleFigure.js.map +1 -1
- package/dist/diagrams/SequenceFigure/SequenceFigure.js +35 -5
- package/dist/diagrams/SequenceFigure/SequenceFigure.js.map +1 -1
- package/dist/diagrams/WorkflowFigure/WorkflowFigure.d.ts +3 -1
- package/dist/diagrams/WorkflowFigure/WorkflowFigure.js +76 -28
- package/dist/diagrams/WorkflowFigure/WorkflowFigure.js.map +1 -1
- package/dist/diagrams/index.d.ts +1 -0
- package/dist/diagrams/index.js +10 -0
- package/dist/diagrams/index.js.map +1 -1
- package/dist/diagrams/lib/dev.d.ts +61 -0
- package/dist/diagrams/lib/dev.js +96 -0
- package/dist/diagrams/lib/dev.js.map +1 -0
- package/dist/diagrams/lib/frame.d.ts +48 -7
- package/dist/diagrams/lib/frame.js +17 -2
- package/dist/diagrams/lib/frame.js.map +1 -1
- package/dist/diagrams/lib/wires.js.map +1 -1
- package/dist/diagrams/spec.d.ts +64 -12
- package/dist/index.d.ts +16 -4
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/keyframes.css +57 -3
- package/dist/lib/overlay-container.d.ts +14 -4
- package/dist/lib/overlay-container.js.map +1 -1
- package/dist/lib/warn.d.ts +73 -0
- package/dist/lib/warn.js +38 -0
- package/dist/lib/warn.js.map +1 -0
- package/dist/semantic.css +17 -6
- package/dist/styles.css +1 -1
- package/dist/tokens.css +20 -2
- package/dist/tokens.d.ts +1 -1
- package/dist/tokens.js +39 -36
- package/dist/tokens.json +39 -36
- package/llms.txt +35 -0
- package/package.json +5 -2
- package/skills/misoto22-design/evals/evals.json +84 -0
- package/skills/misoto22-design/rules/a11y.md +55 -3
- package/skills/misoto22-design/rules/forms.md +5 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/diagrams/DiagramCanvas/DiagramCanvas.tsx"],"sourcesContent":["'use client'\n\nimport {\n useCallback,\n useEffect,\n useId,\n useImperativeHandle,\n useRef,\n useState,\n type PointerEvent as ReactPointerEvent,\n type ReactNode,\n type Ref,\n} from 'react'\nimport { cn } from '../../lib/cn'\n\nconst MIN_SCALE = 0.35\nconst MAX_SCALE = 4\nconst STEP = 1.25\n\nexport interface CanvasView {\n scale: number\n x: number\n y: number\n}\n\n/** What a caller can do to the canvas from outside it. */\nexport interface DiagramCanvasHandle {\n zoomIn: () => void\n zoomOut: () => void\n reset: () => void\n /** Pans so a point in CONTENT coordinates sits in the middle of the frame. */\n centerOn: (x: number, y: number) => void\n view: CanvasView\n}\n\nexport interface DiagramCanvasProps {\n children: ReactNode\n className?: string\n /** How tall the frame is. Anything CSS accepts. */\n height?: string\n /** Told about every view change, for a minimap or a percentage readout. */\n onViewChange?: (view: CanvasView) => void\n /** Hides the built-in zoom controls, for a caller supplying its own. */\n controls?: boolean\n /** Names the region for assistive tech. */\n label?: string\n ref?: Ref<DiagramCanvasHandle>\n}\n\n/**\n * A frame that a picture larger than it can be moved around inside.\n *\n * Pan with a drag, zoom with the controls or with ⌘/Ctrl and the wheel, and\n * reset with a key. That is the whole of it — this is deliberately a VIEWPORT\n * and not a diagram editor: nothing here knows what a node is, so it works for\n * any oversized figure, an SVG, an image, a table that will not fold.\n *\n * Three decisions here are not obvious.\n *\n * **A plain wheel scrolls the page, not the diagram.** A canvas that swallows\n * the wheel is a scroll trap: a reader flicking down an article hits the figure\n * and the page stops moving for no reason they can see. Zooming needs the\n * modifier — which is also the platform gesture for zoom everywhere else, and\n * is what a trackpad pinch already sends.\n *\n * **The transform is on a wrapper, not on the content.** The child keeps its\n * own coordinate space, so a figure inside can still be measured, exported and\n * read by anything that walks it. Scaling the child directly would make every\n * `getBoundingClientRect` inside it a lie.\n *\n * **Keyboard first, and the frame is a real tab stop.** `+` / `-` / `0` and the\n * arrow keys move the view; the frame takes focus so they can be pressed at\n * all. A canvas that only answers a drag is a canvas half the readers cannot\n * operate.\n *\n * @example\n * <DiagramCanvas height=\"26rem\" label=\"Request path\">\n * <ArchitectureFigure spec={spec} heading={false} legend=\"hidden\" cards={false} />\n * </DiagramCanvas>\n */\nexport function DiagramCanvas({\n children,\n className,\n height = '24rem',\n onViewChange,\n controls = true,\n label = 'Diagram canvas',\n ref,\n}: DiagramCanvasProps) {\n const uid = useId().replace(/:/g, '')\n const frame = useRef<HTMLDivElement>(null)\n const [view, setView] = useState<CanvasView>({ scale: 1, x: 0, y: 0 })\n const drag = useRef<{ pointer: number; x: number; y: number } | null>(null)\n\n const apply = useCallback(\n (next: CanvasView) => {\n setView(next)\n onViewChange?.(next)\n },\n [onViewChange],\n )\n\n /**\n * Zooms about a fixed point in FRAME coordinates.\n *\n * The arithmetic is what makes the pointer stay on the thing it is over:\n * the content point under the cursor must land back under the cursor, so the\n * offset moves by the difference the scale change opened up. Without it, a\n * zoom drifts toward the origin and a reader chases the node they were\n * looking at.\n */\n const zoomAbout = useCallback(\n (factor: number, originX?: number, originY?: number) => {\n const box = frame.current?.getBoundingClientRect()\n const cx = originX ?? (box ? box.width / 2 : 0)\n const cy = originY ?? (box ? box.height / 2 : 0)\n\n setView((current) => {\n const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, current.scale * factor))\n const ratio = scale / current.scale\n const next = {\n scale,\n x: cx - (cx - current.x) * ratio,\n y: cy - (cy - current.y) * ratio,\n }\n onViewChange?.(next)\n return next\n })\n },\n [onViewChange],\n )\n\n useImperativeHandle(\n ref,\n () => ({\n zoomIn: () => zoomAbout(STEP),\n zoomOut: () => zoomAbout(1 / STEP),\n reset: () => apply({ scale: 1, x: 0, y: 0 }),\n centerOn: (x: number, y: number) => {\n const box = frame.current?.getBoundingClientRect()\n if (!box) return\n setView((current) => {\n const next = {\n ...current,\n x: box.width / 2 - x * current.scale,\n y: box.height / 2 - y * current.scale,\n }\n onViewChange?.(next)\n return next\n })\n },\n view,\n }),\n [apply, onViewChange, view, zoomAbout],\n )\n\n // Registered imperatively because React marks `wheel` passive, and a passive\n // listener cannot call preventDefault — so the modifier-wheel zoom would also\n // scroll the page underneath it.\n useEffect(() => {\n const node = frame.current\n if (!node) return\n\n const onWheel = (event: WheelEvent) => {\n if (!event.ctrlKey && !event.metaKey) return\n event.preventDefault()\n const box = node.getBoundingClientRect()\n zoomAbout(\n event.deltaY < 0 ? STEP : 1 / STEP,\n event.clientX - box.left,\n event.clientY - box.top,\n )\n }\n\n node.addEventListener('wheel', onWheel, { passive: false })\n return () => node.removeEventListener('wheel', onWheel)\n }, [zoomAbout])\n\n const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {\n if (event.button !== 0) return\n drag.current = { pointer: event.pointerId, x: event.clientX - view.x, y: event.clientY - view.y }\n event.currentTarget.setPointerCapture(event.pointerId)\n }\n\n const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {\n const state = drag.current\n if (!state || state.pointer !== event.pointerId) return\n apply({ ...view, x: event.clientX - state.x, y: event.clientY - state.y })\n }\n\n const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {\n if (drag.current?.pointer !== event.pointerId) return\n drag.current = null\n if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n event.currentTarget.releasePointerCapture(event.pointerId)\n }\n }\n\n return (\n <div\n className={cn(\n 'relative overflow-hidden rounded-(--radius-lg) border border-(--rule) bg-(--diagram-surface)',\n className,\n )}\n style={{ height }}\n >\n <div\n ref={frame}\n role=\"group\"\n aria-label={label}\n aria-describedby={`${uid}-hint`}\n tabIndex={0}\n className=\"size-full cursor-grab touch-none select-none outline-none focus-visible:ring-2 focus-visible:ring-(--ring) focus-visible:ring-inset active:cursor-grabbing\"\n onPointerDown={onPointerDown}\n onPointerMove={onPointerMove}\n onPointerUp={endDrag}\n onPointerCancel={endDrag}\n onKeyDown={(event) => {\n const nudge = event.shiftKey ? 80 : 24\n switch (event.key) {\n case '+':\n case '=':\n event.preventDefault()\n zoomAbout(STEP)\n break\n case '-':\n event.preventDefault()\n zoomAbout(1 / STEP)\n break\n case '0':\n event.preventDefault()\n apply({ scale: 1, x: 0, y: 0 })\n break\n case 'ArrowLeft':\n event.preventDefault()\n apply({ ...view, x: view.x + nudge })\n break\n case 'ArrowRight':\n event.preventDefault()\n apply({ ...view, x: view.x - nudge })\n break\n case 'ArrowUp':\n event.preventDefault()\n apply({ ...view, y: view.y + nudge })\n break\n case 'ArrowDown':\n event.preventDefault()\n apply({ ...view, y: view.y - nudge })\n break\n default:\n break\n }\n }}\n >\n <div\n className=\"origin-top-left will-change-transform\"\n style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})` }}\n data-diagram-stage=\"\"\n >\n {children}\n </div>\n </div>\n\n <p id={`${uid}-hint`} className=\"sr-only\">\n Drag to pan. Plus and minus zoom, zero resets, the arrow keys pan.\n </p>\n\n {controls && (\n <div className=\"absolute bottom-3 end-3 flex items-center gap-px rounded-(--radius) border border-(--rule-2) bg-(--panel-bg) p-0.5\">\n <CanvasButton label=\"Zoom out\" onClick={() => zoomAbout(1 / STEP)}>\n <span aria-hidden>−</span>\n </CanvasButton>\n <button\n type=\"button\"\n onClick={() => apply({ scale: 1, x: 0, y: 0 })}\n aria-label=\"Reset the view\"\n className=\"min-w-12 rounded-(--radius-xs) px-1.5 py-1 mono-meta text-(--ink-2) transition-colors duration-(--duration-fast) hover:bg-(--stone) hover:text-(--ink) focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-(--ring)\"\n >\n {Math.round(view.scale * 100)}%\n </button>\n <CanvasButton label=\"Zoom in\" onClick={() => zoomAbout(STEP)}>\n <span aria-hidden>+</span>\n </CanvasButton>\n </div>\n )}\n </div>\n )\n}\n\nfunction CanvasButton({\n label,\n onClick,\n children,\n}: {\n label: string\n onClick: () => void\n children: ReactNode\n}) {\n return (\n <button\n type=\"button\"\n onClick={onClick}\n aria-label={label}\n className=\"flex size-7 items-center justify-center rounded-(--radius-xs) font-mono text-[13px] leading-none text-(--ink-2) transition-colors duration-(--duration-fast) hover:bg-(--stone) hover:text-(--ink) focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-(--ring)\"\n >\n {children}\n </button>\n )\n}\n\nexport default DiagramCanvas\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../../src/diagrams/DiagramCanvas/DiagramCanvas.tsx"],"sourcesContent":["'use client'\n\nimport {\n useCallback,\n useEffect,\n useId,\n useImperativeHandle,\n useRef,\n useState,\n type PointerEvent as ReactPointerEvent,\n type ReactNode,\n type Ref,\n} from 'react'\nimport { cn } from '../../lib/cn'\n\nconst MIN_SCALE = 0.35\nconst MAX_SCALE = 4\nconst STEP = 1.25\n\nexport interface CanvasView {\n scale: number\n x: number\n y: number\n /**\n * How big the frame was, in CSS pixels, when this view was reported.\n *\n * Carried on the view because everything that consumes a view needs it and\n * nothing outside this component can measure it: a `DiagramMinimap`'s\n * viewport rectangle is the frame's size over the zoom, and a caller who had\n * to hand-declare that number was hand-declaring the one number that makes\n * the map lie. Absent until the frame has been measured — the first render\n * has no box yet — which is exactly when a map should draw nothing.\n */\n frame?: { width: number; height: number }\n}\n\n/** What a caller can do to the canvas from outside it. */\nexport interface DiagramCanvasHandle {\n zoomIn: () => void\n zoomOut: () => void\n reset: () => void\n /** Pans so a point in CONTENT coordinates sits in the middle of the frame. */\n centerOn: (x: number, y: number) => void\n view: CanvasView\n}\n\nexport interface DiagramCanvasProps {\n children: ReactNode\n className?: string\n /** How tall the frame is. Anything CSS accepts. */\n height?: string\n /** Told about every view change, for a minimap or a percentage readout. */\n onViewChange?: (view: CanvasView) => void\n /** Hides the built-in zoom controls, for a caller supplying its own. */\n controls?: boolean\n /** Names the region for assistive tech. */\n label?: string\n ref?: Ref<DiagramCanvasHandle>\n}\n\n/**\n * A frame that a picture larger than it can be moved around inside.\n *\n * Pan with a drag, zoom with the controls or with ⌘/Ctrl and the wheel, and\n * reset with a key. That is the whole of it — this is deliberately a VIEWPORT\n * and not a diagram editor: nothing here knows what a node is, so it works for\n * any oversized figure, an SVG, an image, a table that will not fold.\n *\n * Three decisions here are not obvious.\n *\n * **A plain wheel scrolls the page, not the diagram.** A canvas that swallows\n * the wheel is a scroll trap: a reader flicking down an article hits the figure\n * and the page stops moving for no reason they can see. Zooming needs the\n * modifier — which is also the platform gesture for zoom everywhere else, and\n * is what a trackpad pinch already sends.\n *\n * **The transform is on a wrapper, not on the content.** The child keeps its\n * own coordinate space, so a figure inside can still be measured, exported and\n * read by anything that walks it. Scaling the child directly would make every\n * `getBoundingClientRect` inside it a lie.\n *\n * **Keyboard first, and the frame is a real tab stop.** `+` / `-` / `0` and the\n * arrow keys move the view; the frame takes focus so they can be pressed at\n * all. A canvas that only answers a drag is a canvas half the readers cannot\n * operate.\n *\n * @example\n * <DiagramCanvas height=\"26rem\" label=\"Request path\">\n * <ArchitectureFigure spec={spec} heading={false} legend=\"hidden\" cards={false} />\n * </DiagramCanvas>\n */\nexport function DiagramCanvas({\n children,\n className,\n height = '24rem',\n onViewChange,\n controls = true,\n label = 'Diagram canvas',\n ref,\n}: DiagramCanvasProps) {\n const uid = useId().replace(/:/g, '')\n const frame = useRef<HTMLDivElement>(null)\n const [view, setView] = useState<CanvasView>({ scale: 1, x: 0, y: 0 })\n const drag = useRef<{ pointer: number; x: number; y: number } | null>(null)\n\n /** The frame's own size, or nothing when it has not been laid out yet. */\n const measure = useCallback(() => {\n const box = frame.current?.getBoundingClientRect()\n return box && box.width > 0 ? { width: box.width, height: box.height } : undefined\n }, [])\n\n const apply = useCallback(\n (next: CanvasView) => {\n const measured = { ...next, frame: measure() }\n setView(measured)\n onViewChange?.(measured)\n },\n [measure, onViewChange],\n )\n\n /**\n * Zooms about a fixed point in FRAME coordinates.\n *\n * The arithmetic is what makes the pointer stay on the thing it is over:\n * the content point under the cursor must land back under the cursor, so the\n * offset moves by the difference the scale change opened up. Without it, a\n * zoom drifts toward the origin and a reader chases the node they were\n * looking at.\n */\n const zoomAbout = useCallback(\n (factor: number, originX?: number, originY?: number) => {\n const box = frame.current?.getBoundingClientRect()\n const cx = originX ?? (box ? box.width / 2 : 0)\n const cy = originY ?? (box ? box.height / 2 : 0)\n\n setView((current) => {\n const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, current.scale * factor))\n const ratio = scale / current.scale\n const next = {\n scale,\n x: cx - (cx - current.x) * ratio,\n y: cy - (cy - current.y) * ratio,\n frame: measure(),\n }\n onViewChange?.(next)\n return next\n })\n },\n [measure, onViewChange],\n )\n\n useImperativeHandle(\n ref,\n () => ({\n zoomIn: () => zoomAbout(STEP),\n zoomOut: () => zoomAbout(1 / STEP),\n reset: () => apply({ scale: 1, x: 0, y: 0 }),\n centerOn: (x: number, y: number) => {\n const box = frame.current?.getBoundingClientRect()\n if (!box) return\n setView((current) => {\n const next = {\n ...current,\n x: box.width / 2 - x * current.scale,\n y: box.height / 2 - y * current.scale,\n frame: measure(),\n }\n onViewChange?.(next)\n return next\n })\n },\n view,\n }),\n [apply, measure, onViewChange, view, zoomAbout],\n )\n\n // Registered imperatively because React marks `wheel` passive, and a passive\n // listener cannot call preventDefault — so the modifier-wheel zoom would also\n // scroll the page underneath it.\n useEffect(() => {\n const node = frame.current\n if (!node) return\n\n const onWheel = (event: WheelEvent) => {\n if (!event.ctrlKey && !event.metaKey) return\n event.preventDefault()\n const box = node.getBoundingClientRect()\n zoomAbout(\n event.deltaY < 0 ? STEP : 1 / STEP,\n event.clientX - box.left,\n event.clientY - box.top,\n )\n }\n\n node.addEventListener('wheel', onWheel, { passive: false })\n return () => node.removeEventListener('wheel', onWheel)\n }, [zoomAbout])\n\n const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {\n if (event.button !== 0) return\n drag.current = { pointer: event.pointerId, x: event.clientX - view.x, y: event.clientY - view.y }\n event.currentTarget.setPointerCapture(event.pointerId)\n }\n\n const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {\n const state = drag.current\n if (!state || state.pointer !== event.pointerId) return\n apply({ ...view, x: event.clientX - state.x, y: event.clientY - state.y })\n }\n\n const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {\n if (drag.current?.pointer !== event.pointerId) return\n drag.current = null\n if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n event.currentTarget.releasePointerCapture(event.pointerId)\n }\n }\n\n return (\n <div\n className={cn(\n 'relative overflow-hidden rounded-(--radius-lg) border border-(--rule) bg-(--diagram-surface)',\n className,\n )}\n style={{ height }}\n >\n <div\n ref={frame}\n role=\"group\"\n aria-label={label}\n aria-describedby={`${uid}-hint`}\n tabIndex={0}\n className=\"size-full cursor-grab touch-none select-none outline-none focus-visible:ring-2 focus-visible:ring-(--ring) focus-visible:ring-inset active:cursor-grabbing\"\n onPointerDown={onPointerDown}\n onPointerMove={onPointerMove}\n onPointerUp={endDrag}\n onPointerCancel={endDrag}\n onKeyDown={(event) => {\n const nudge = event.shiftKey ? 80 : 24\n switch (event.key) {\n case '+':\n case '=':\n event.preventDefault()\n zoomAbout(STEP)\n break\n case '-':\n event.preventDefault()\n zoomAbout(1 / STEP)\n break\n case '0':\n event.preventDefault()\n apply({ scale: 1, x: 0, y: 0 })\n break\n case 'ArrowLeft':\n event.preventDefault()\n apply({ ...view, x: view.x + nudge })\n break\n case 'ArrowRight':\n event.preventDefault()\n apply({ ...view, x: view.x - nudge })\n break\n case 'ArrowUp':\n event.preventDefault()\n apply({ ...view, y: view.y + nudge })\n break\n case 'ArrowDown':\n event.preventDefault()\n apply({ ...view, y: view.y - nudge })\n break\n default:\n break\n }\n }}\n >\n <div\n className=\"origin-top-left will-change-transform\"\n style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})` }}\n data-diagram-stage=\"\"\n >\n {children}\n </div>\n </div>\n\n <p id={`${uid}-hint`} className=\"sr-only\">\n Drag to pan. Plus and minus zoom, zero resets, the arrow keys pan.\n </p>\n\n {controls && (\n <div className=\"absolute bottom-3 end-3 flex items-center gap-px rounded-(--radius) border border-(--rule-2) bg-(--panel-bg) p-0.5\">\n <CanvasButton label=\"Zoom out\" onClick={() => zoomAbout(1 / STEP)}>\n <span aria-hidden>−</span>\n </CanvasButton>\n <button\n type=\"button\"\n onClick={() => apply({ scale: 1, x: 0, y: 0 })}\n aria-label=\"Reset the view\"\n className=\"min-w-12 rounded-(--radius-xs) px-1.5 py-1 mono-meta text-(--ink-2) transition-colors duration-(--duration-fast) hover:bg-(--stone) hover:text-(--ink) focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-(--ring)\"\n >\n {Math.round(view.scale * 100)}%\n </button>\n <CanvasButton label=\"Zoom in\" onClick={() => zoomAbout(STEP)}>\n <span aria-hidden>+</span>\n </CanvasButton>\n </div>\n )}\n </div>\n )\n}\n\nfunction CanvasButton({\n label,\n onClick,\n children,\n}: {\n label: string\n onClick: () => void\n children: ReactNode\n}) {\n return (\n <button\n type=\"button\"\n onClick={onClick}\n aria-label={label}\n className=\"flex size-7 items-center justify-center rounded-(--radius-xs) font-mono text-[13px] leading-none text-(--ink-2) transition-colors duration-(--duration-fast) hover:bg-(--stone) hover:text-(--ink) focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-(--ring)\"\n >\n {children}\n </button>\n )\n}\n\nexport default DiagramCanvas\n"],"mappings":"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","names":[]}
|
|
@@ -6,8 +6,19 @@ type ExportFormat = 'png' | 'jpeg' | 'webp' | 'svg' | 'share-card';
|
|
|
6
6
|
/** What came of an export, for a page that wants to say so. */
|
|
7
7
|
interface ExportResult {
|
|
8
8
|
format: ExportFormat;
|
|
9
|
+
/**
|
|
10
|
+
* Whether the pipeline named by `source` finished without throwing.
|
|
11
|
+
*
|
|
12
|
+
* Read it with `source`, because the two sources can promise different
|
|
13
|
+
* amounts. `built-in` means a file was handed to the browser. `caller` means
|
|
14
|
+
* an `onExport` handler resolved — and a handler that did nothing resolves
|
|
15
|
+
* exactly like one that wrote a file, which is a thing this component cannot
|
|
16
|
+
* see and therefore does not claim.
|
|
17
|
+
*/
|
|
9
18
|
ok: boolean;
|
|
10
19
|
error?: Error;
|
|
20
|
+
/** Which pipeline ran: this component's, or the caller's `onExport`. */
|
|
21
|
+
source: 'built-in' | 'caller';
|
|
11
22
|
}
|
|
12
23
|
interface DiagramExportMenuProps {
|
|
13
24
|
/**
|
|
@@ -22,10 +33,24 @@ interface DiagramExportMenuProps {
|
|
|
22
33
|
/** Replaces the trigger. */
|
|
23
34
|
trigger?: ReactNode;
|
|
24
35
|
className?: string;
|
|
25
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Runs instead of the built-in export — for a caller with its own pipeline.
|
|
38
|
+
*
|
|
39
|
+
* Taking it turns everything below into a no-op, INCLUDING the part that
|
|
40
|
+
* knows whether a file was produced: a result from this path reports
|
|
41
|
+
* `source: 'caller'` and an `ok` that means only "your handler resolved".
|
|
42
|
+
*/
|
|
26
43
|
onExport?: (format: ExportFormat) => void | Promise<void>;
|
|
27
44
|
/** Told what happened, so a page can raise a toast. */
|
|
28
45
|
onResult?: (result: ExportResult) => void;
|
|
46
|
+
/**
|
|
47
|
+
* Painted behind the artwork. Defaults to the surface the reader is looking
|
|
48
|
+
* at; `null` exports on a transparent ground.
|
|
49
|
+
*
|
|
50
|
+
* JPEG has no alpha channel, so it is flattened onto the reader's own paper
|
|
51
|
+
* whatever this says — a transparent JPEG is a black one.
|
|
52
|
+
*/
|
|
53
|
+
background?: string | null;
|
|
29
54
|
}
|
|
30
55
|
/**
|
|
31
56
|
* Taking the figure off the page: five files, one menu.
|
|
@@ -41,7 +66,9 @@ interface DiagramExportMenuProps {
|
|
|
41
66
|
*
|
|
42
67
|
* `onExport` is still there for a page with its own pipeline — a server-side
|
|
43
68
|
* renderer, a different frame size — and taking it turns everything below into
|
|
44
|
-
* a no-op
|
|
69
|
+
* a no-op, which is why a result from that path says `source: 'caller'`: this
|
|
70
|
+
* component cannot see whether the handler produced a file, so it does not
|
|
71
|
+
* report that it did.
|
|
45
72
|
*
|
|
46
73
|
* What each format actually is, stated rather than implied.
|
|
47
74
|
*
|
|
@@ -49,6 +76,11 @@ interface DiagramExportMenuProps {
|
|
|
49
76
|
* only lossless one, but it carries no web fonts: a machine without the family
|
|
50
77
|
* renders it in a fallback, so type metrics will differ.
|
|
51
78
|
*
|
|
79
|
+
* **The ground** is the reader's own surface unless `background` says
|
|
80
|
+
* otherwise. `background={null}` exports on transparency — for a figure being
|
|
81
|
+
* dropped onto a coloured page — except in JPEG, which has no alpha and is
|
|
82
|
+
* flattened onto paper whatever is asked for.
|
|
83
|
+
*
|
|
52
84
|
* **PNG, JPEG and WebP** are the browser's own rasteriser re-drawing that SVG
|
|
53
85
|
* at 2×. Not a screenshot — antialiasing and any effect a page stylesheet
|
|
54
86
|
* applied from OUTSIDE the `<svg>` are not in it. **JPEG has no alpha**, so it
|
|
@@ -60,13 +92,15 @@ interface DiagramExportMenuProps {
|
|
|
60
92
|
* diagram letterboxed inside — never cropped. A card that cropped to fill the
|
|
61
93
|
* frame would be a picture of a different diagram.
|
|
62
94
|
*/
|
|
63
|
-
declare function DiagramExportMenu({ targetRef, title, trigger, className, onExport, onResult, }: DiagramExportMenuProps): react.JSX.Element;
|
|
95
|
+
declare function DiagramExportMenu({ targetRef, title, trigger, className, onExport, onResult, background, }: DiagramExportMenuProps): react.JSX.Element;
|
|
64
96
|
/**
|
|
65
97
|
* One figure, one format, one file.
|
|
66
98
|
*
|
|
67
99
|
* Exported so a page can wire a keyboard shortcut or its own button to exactly
|
|
68
100
|
* what the menu does, without reimplementing the six steps.
|
|
69
101
|
*/
|
|
70
|
-
declare function exportFigure(target: HTMLElement | SVGSVGElement | null, title: string, format: ExportFormat
|
|
102
|
+
declare function exportFigure(target: HTMLElement | SVGSVGElement | null, title: string, format: ExportFormat, options?: {
|
|
103
|
+
background?: string | null;
|
|
104
|
+
}): Promise<void>;
|
|
71
105
|
|
|
72
106
|
export { DiagramExportMenu, type DiagramExportMenuProps, type ExportFormat, type ExportResult, DiagramExportMenu as default, exportFigure };
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import {
|
|
13
13
|
DropdownMenu,
|
|
14
14
|
DropdownMenuContent,
|
|
15
|
+
DropdownMenuItem,
|
|
15
16
|
DropdownMenuLabel,
|
|
16
17
|
DropdownMenuSeparator,
|
|
17
18
|
DropdownMenuTrigger
|
|
@@ -31,20 +32,22 @@ function DiagramExportMenu({
|
|
|
31
32
|
trigger,
|
|
32
33
|
className,
|
|
33
34
|
onExport,
|
|
34
|
-
onResult
|
|
35
|
+
onResult,
|
|
36
|
+
background
|
|
35
37
|
}) {
|
|
36
38
|
const [busy, setBusy] = useState(null);
|
|
37
39
|
async function run(format) {
|
|
40
|
+
const source = onExport ? "caller" : "built-in";
|
|
38
41
|
setBusy(format);
|
|
39
42
|
try {
|
|
40
43
|
if (onExport) {
|
|
41
44
|
await onExport(format);
|
|
42
45
|
} else {
|
|
43
|
-
await exportFigure(targetRef.current, title, format);
|
|
46
|
+
await exportFigure(targetRef.current, title, format, { background });
|
|
44
47
|
}
|
|
45
|
-
onResult?.({ format, ok: true });
|
|
48
|
+
onResult?.({ format, ok: true, source });
|
|
46
49
|
} catch (cause) {
|
|
47
|
-
onResult?.({ format, ok: false, error: cause });
|
|
50
|
+
onResult?.({ format, ok: false, error: cause, source });
|
|
48
51
|
} finally {
|
|
49
52
|
setBusy(null);
|
|
50
53
|
}
|
|
@@ -56,17 +59,11 @@ function DiagramExportMenu({
|
|
|
56
59
|
index > 0 && /* @__PURE__ */ jsx(DropdownMenuSeparator, {}),
|
|
57
60
|
/* @__PURE__ */ jsx(DropdownMenuLabel, { children: group }),
|
|
58
61
|
ITEMS.filter((item) => item.group === group).map((item) => /* @__PURE__ */ jsxs(
|
|
59
|
-
|
|
62
|
+
DropdownMenuItem,
|
|
60
63
|
{
|
|
61
|
-
type: "button",
|
|
62
64
|
disabled: busy !== null,
|
|
63
|
-
|
|
64
|
-
className: cn(
|
|
65
|
-
"flex w-full flex-col items-start gap-0.5 rounded-(--radius-sm) px-2 py-1.5 text-start",
|
|
66
|
-
"transition-colors duration-(--duration-fast)",
|
|
67
|
-
"hover:bg-(--stone) focus-visible:bg-(--stone) focus-visible:outline-none",
|
|
68
|
-
"disabled:pointer-events-none disabled:opacity-(--disabled-opacity)"
|
|
69
|
-
),
|
|
65
|
+
onSelect: () => void run(item.format),
|
|
66
|
+
className: cn("flex-col items-start gap-0.5"),
|
|
70
67
|
children: [
|
|
71
68
|
/* @__PURE__ */ jsxs("span", { className: "text-[13px] leading-tight text-(--ink)", children: [
|
|
72
69
|
item.title,
|
|
@@ -80,16 +77,17 @@ function DiagramExportMenu({
|
|
|
80
77
|
] }, group)) })
|
|
81
78
|
] });
|
|
82
79
|
}
|
|
83
|
-
async function exportFigure(target, title, format) {
|
|
80
|
+
async function exportFigure(target, title, format, options = {}) {
|
|
84
81
|
const svg = findArtwork(target);
|
|
85
82
|
if (!svg) throw new Error("exportFigure: found no <svg> to export inside the given element");
|
|
86
83
|
const paper = readToken(svg, "--diagram-surface", "#ffffff");
|
|
87
84
|
const ink = readToken(svg, "--ink", "#101010");
|
|
88
85
|
const font = getComputedStyle(svg).fontFamily || "sans-serif";
|
|
86
|
+
const plate = options.background === null ? void 0 : options.background ?? paper;
|
|
89
87
|
if (format === "share-card") {
|
|
90
88
|
const markup2 = serializeSvg(svg, {
|
|
91
89
|
padding: 56,
|
|
92
|
-
background:
|
|
90
|
+
background: plate,
|
|
93
91
|
title,
|
|
94
92
|
titleColor: ink,
|
|
95
93
|
titleFont: font,
|
|
@@ -99,7 +97,7 @@ async function exportFigure(target, title, format) {
|
|
|
99
97
|
downloadBlob(blob2, exportFilename(title, "png", "share-card"));
|
|
100
98
|
return;
|
|
101
99
|
}
|
|
102
|
-
const markup = serializeSvg(svg, { padding: 20, background:
|
|
100
|
+
const markup = serializeSvg(svg, { padding: 20, background: plate });
|
|
103
101
|
if (format === "svg") {
|
|
104
102
|
downloadBlob(new Blob([markup], { type: "image/svg+xml;charset=utf-8" }), exportFilename(title, "svg"));
|
|
105
103
|
return;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/diagrams/DiagramExportMenu/DiagramExportMenu.tsx"],"sourcesContent":["'use client'\n\nimport { useState, type ReactNode, type RefObject } from 'react'\nimport { cn } from '../../lib/cn'\nimport {\n downloadBlob,\n exportFilename,\n rasterize,\n readToken,\n serializeSvg,\n} from '../../lib/svg-export'\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from '../../components/DropdownMenu/DropdownMenu'\nimport { Button } from '../../components/Button/Button'\n\n/** The files a figure can be taken away as. */\nexport type ExportFormat = 'png' | 'jpeg' | 'webp' | 'svg' | 'share-card'\n\n/** What came of an export, for a page that wants to say so. */\nexport interface ExportResult {\n format: ExportFormat\n ok: boolean\n error?: Error\n}\n\n/** A share card is a fixed frame, because the surfaces that consume one are. */\nconst SHARE_CARD = { width: 1200, height: 630 } as const\n\nconst ITEMS: { format: ExportFormat; title: string; hint: string; group: string }[] = [\n { group: 'Image', format: 'png', title: 'PNG', hint: 'Lossless, 2× for retina' },\n { group: 'Image', format: 'jpeg', title: 'JPEG', hint: 'Compact, flattened onto paper' },\n { group: 'Image', format: 'webp', title: 'WebP', hint: 'Smaller, modern browsers' },\n { group: 'Vector', format: 'svg', title: 'SVG', hint: 'Editable, colours resolved' },\n { group: 'Share', format: 'share-card', title: 'Share card', hint: '1200 × 630 PNG' },\n]\n\nexport interface DiagramExportMenuProps {\n /**\n * The `<svg>` to export, or an element containing exactly one.\n *\n * A ref rather than a selector, because a page can hold several figures and a\n * selector would export whichever the document happened to reach first.\n */\n targetRef: RefObject<HTMLElement | SVGSVGElement | null>\n /** Names the file, and is printed on the share card. */\n title: string\n /** Replaces the trigger. */\n trigger?: ReactNode\n className?: string\n /** Runs instead of the built-in export — for a caller with its own pipeline. */\n onExport?: (format: ExportFormat) => void | Promise<void>\n /** Told what happened, so a page can raise a toast. */\n onResult?: (result: ExportResult) => void\n}\n\n/**\n * Taking the figure off the page: five files, one menu.\n *\n * WHY THE MENU DOES THE WORK rather than handing back a format: every one of\n * these exports is the same six steps — find the `<svg>`, walk it with\n * `getComputedStyle` to bake the custom properties into real colours,\n * serialise, rasterise, name the file, hand it to the browser — and only the\n * last two differ between them. A menu that emitted `'png'` and left the caller\n * to do the rest would be a menu that every consumer reimplements, badly, and\n * the interesting half (a serialised SVG resolves `var(--ink)` to nothing and\n * comes out invisible) is exactly the half a caller would not know to write.\n *\n * `onExport` is still there for a page with its own pipeline — a server-side\n * renderer, a different frame size — and taking it turns everything below into\n * a no-op.\n *\n * What each format actually is, stated rather than implied.\n *\n * **SVG** is the artwork with resolved colours. It is editable and it is the\n * only lossless one, but it carries no web fonts: a machine without the family\n * renders it in a fallback, so type metrics will differ.\n *\n * **PNG, JPEG and WebP** are the browser's own rasteriser re-drawing that SVG\n * at 2×. Not a screenshot — antialiasing and any effect a page stylesheet\n * applied from OUTSIDE the `<svg>` are not in it. **JPEG has no alpha**, so it\n * is flattened onto the resolved paper colour rather than onto black, which is\n * what a transparent PNG becomes when a format with no transparency is asked\n * to hold it.\n *\n * **The share card** is a 1200 × 630 frame with the title on it and the whole\n * diagram letterboxed inside — never cropped. A card that cropped to fill the\n * frame would be a picture of a different diagram.\n */\nexport function DiagramExportMenu({\n targetRef,\n title,\n trigger,\n className,\n onExport,\n onResult,\n}: DiagramExportMenuProps) {\n const [busy, setBusy] = useState<ExportFormat | null>(null)\n\n async function run(format: ExportFormat) {\n setBusy(format)\n try {\n if (onExport) {\n await onExport(format)\n } else {\n await exportFigure(targetRef.current, title, format)\n }\n onResult?.({ format, ok: true })\n } catch (cause) {\n // Never swallowed: a download is something the reader just asked for, and\n // a click that quietly does nothing is indistinguishable from a broken\n // button. The caller gets the error to put in front of them.\n onResult?.({ format, ok: false, error: cause as Error })\n } finally {\n setBusy(null)\n }\n }\n\n const groups = [...new Set(ITEMS.map((item) => item.group))]\n\n return (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n {trigger ?? (\n <Button size=\"sm\" variant=\"secondary\" className={className}>\n Export\n </Button>\n )}\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" className=\"w-64\">\n {groups.map((group, index) => (\n <div key={group}>\n {index > 0 && <DropdownMenuSeparator />}\n <DropdownMenuLabel>{group}</DropdownMenuLabel>\n {ITEMS.filter((item) => item.group === group).map((item) => (\n <button\n key={item.format}\n type=\"button\"\n disabled={busy !== null}\n onClick={() => void run(item.format)}\n className={cn(\n 'flex w-full flex-col items-start gap-0.5 rounded-(--radius-sm) px-2 py-1.5 text-start',\n 'transition-colors duration-(--duration-fast)',\n 'hover:bg-(--stone) focus-visible:bg-(--stone) focus-visible:outline-none',\n 'disabled:pointer-events-none disabled:opacity-(--disabled-opacity)',\n )}\n >\n <span className=\"text-[13px] leading-tight text-(--ink)\">\n {item.title}\n {busy === item.format && ' …'}\n </span>\n <span className=\"mono-meta text-(--ink-3-aa)\">{item.hint}</span>\n </button>\n ))}\n </div>\n ))}\n </DropdownMenuContent>\n </DropdownMenu>\n )\n}\n\n/**\n * One figure, one format, one file.\n *\n * Exported so a page can wire a keyboard shortcut or its own button to exactly\n * what the menu does, without reimplementing the six steps.\n */\nexport async function exportFigure(\n target: HTMLElement | SVGSVGElement | null,\n title: string,\n format: ExportFormat,\n): Promise<void> {\n const svg = findArtwork(target)\n if (!svg) throw new Error('exportFigure: found no <svg> to export inside the given element')\n\n // Read against the LIVE element, so the file comes out in whichever theme the\n // reader is looking at rather than in whichever one the code assumed.\n const paper = readToken(svg, '--diagram-surface', '#ffffff')\n const ink = readToken(svg, '--ink', '#101010')\n const font = getComputedStyle(svg).fontFamily || 'sans-serif'\n\n if (format === 'share-card') {\n const markup = serializeSvg(svg, {\n padding: 56,\n background: paper,\n title,\n titleColor: ink,\n titleFont: font,\n frame: SHARE_CARD,\n })\n const blob = await rasterize(markup, 'png', { scale: 1 })\n downloadBlob(blob, exportFilename(title, 'png', 'share-card'))\n return\n }\n\n const markup = serializeSvg(svg, { padding: 20, background: paper })\n\n if (format === 'svg') {\n downloadBlob(new Blob([markup], { type: 'image/svg+xml;charset=utf-8' }), exportFilename(title, 'svg'))\n return\n }\n\n const blob = await rasterize(markup, format, {\n scale: 2,\n // JPEG has no alpha channel, so an unflattened export lands on black.\n background: format === 'jpeg' ? paper : undefined,\n })\n downloadBlob(blob, exportFilename(title, format))\n}\n\n/**\n * The figure's own `<svg>`, not the first one in the subtree.\n *\n * The distinction is load-bearing: a toolbar sits inside the same wrapper and\n * every icon in it is an `<svg>`. Looking for the artwork marker first is what\n * stops an export of a diagram from being a picture of a chevron.\n */\nfunction findArtwork(element: HTMLElement | SVGSVGElement | null): SVGSVGElement | null {\n if (!element) return null\n if (element.tagName.toLowerCase() === 'svg') return element as SVGSVGElement\n return (\n element.querySelector<SVGSVGElement>('svg[data-diagram-artwork]') ??\n element.querySelector<SVGSVGElement>('svg')\n )\n}\n\nexport default DiagramExportMenu\n"],"mappings":"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","names":["markup","blob"]}
|
|
1
|
+
{"version":3,"sources":["../../../src/diagrams/DiagramExportMenu/DiagramExportMenu.tsx"],"sourcesContent":["'use client'\n\nimport { useState, type ReactNode, type RefObject } from 'react'\nimport { cn } from '../../lib/cn'\nimport {\n downloadBlob,\n exportFilename,\n rasterize,\n readToken,\n serializeSvg,\n} from '../../lib/svg-export'\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from '../../components/DropdownMenu/DropdownMenu'\nimport { Button } from '../../components/Button/Button'\n\n/** The files a figure can be taken away as. */\nexport type ExportFormat = 'png' | 'jpeg' | 'webp' | 'svg' | 'share-card'\n\n/** What came of an export, for a page that wants to say so. */\nexport interface ExportResult {\n format: ExportFormat\n /**\n * Whether the pipeline named by `source` finished without throwing.\n *\n * Read it with `source`, because the two sources can promise different\n * amounts. `built-in` means a file was handed to the browser. `caller` means\n * an `onExport` handler resolved — and a handler that did nothing resolves\n * exactly like one that wrote a file, which is a thing this component cannot\n * see and therefore does not claim.\n */\n ok: boolean\n error?: Error\n /** Which pipeline ran: this component's, or the caller's `onExport`. */\n source: 'built-in' | 'caller'\n}\n\n/** A share card is a fixed frame, because the surfaces that consume one are. */\nconst SHARE_CARD = { width: 1200, height: 630 } as const\n\nconst ITEMS: { format: ExportFormat; title: string; hint: string; group: string }[] = [\n { group: 'Image', format: 'png', title: 'PNG', hint: 'Lossless, 2× for retina' },\n { group: 'Image', format: 'jpeg', title: 'JPEG', hint: 'Compact, flattened onto paper' },\n { group: 'Image', format: 'webp', title: 'WebP', hint: 'Smaller, modern browsers' },\n { group: 'Vector', format: 'svg', title: 'SVG', hint: 'Editable, colours resolved' },\n { group: 'Share', format: 'share-card', title: 'Share card', hint: '1200 × 630 PNG' },\n]\n\nexport interface DiagramExportMenuProps {\n /**\n * The `<svg>` to export, or an element containing exactly one.\n *\n * A ref rather than a selector, because a page can hold several figures and a\n * selector would export whichever the document happened to reach first.\n */\n targetRef: RefObject<HTMLElement | SVGSVGElement | null>\n /** Names the file, and is printed on the share card. */\n title: string\n /** Replaces the trigger. */\n trigger?: ReactNode\n className?: string\n /**\n * Runs instead of the built-in export — for a caller with its own pipeline.\n *\n * Taking it turns everything below into a no-op, INCLUDING the part that\n * knows whether a file was produced: a result from this path reports\n * `source: 'caller'` and an `ok` that means only \"your handler resolved\".\n */\n onExport?: (format: ExportFormat) => void | Promise<void>\n /** Told what happened, so a page can raise a toast. */\n onResult?: (result: ExportResult) => void\n /**\n * Painted behind the artwork. Defaults to the surface the reader is looking\n * at; `null` exports on a transparent ground.\n *\n * JPEG has no alpha channel, so it is flattened onto the reader's own paper\n * whatever this says — a transparent JPEG is a black one.\n */\n background?: string | null\n}\n\n/**\n * Taking the figure off the page: five files, one menu.\n *\n * WHY THE MENU DOES THE WORK rather than handing back a format: every one of\n * these exports is the same six steps — find the `<svg>`, walk it with\n * `getComputedStyle` to bake the custom properties into real colours,\n * serialise, rasterise, name the file, hand it to the browser — and only the\n * last two differ between them. A menu that emitted `'png'` and left the caller\n * to do the rest would be a menu that every consumer reimplements, badly, and\n * the interesting half (a serialised SVG resolves `var(--ink)` to nothing and\n * comes out invisible) is exactly the half a caller would not know to write.\n *\n * `onExport` is still there for a page with its own pipeline — a server-side\n * renderer, a different frame size — and taking it turns everything below into\n * a no-op, which is why a result from that path says `source: 'caller'`: this\n * component cannot see whether the handler produced a file, so it does not\n * report that it did.\n *\n * What each format actually is, stated rather than implied.\n *\n * **SVG** is the artwork with resolved colours. It is editable and it is the\n * only lossless one, but it carries no web fonts: a machine without the family\n * renders it in a fallback, so type metrics will differ.\n *\n * **The ground** is the reader's own surface unless `background` says\n * otherwise. `background={null}` exports on transparency — for a figure being\n * dropped onto a coloured page — except in JPEG, which has no alpha and is\n * flattened onto paper whatever is asked for.\n *\n * **PNG, JPEG and WebP** are the browser's own rasteriser re-drawing that SVG\n * at 2×. Not a screenshot — antialiasing and any effect a page stylesheet\n * applied from OUTSIDE the `<svg>` are not in it. **JPEG has no alpha**, so it\n * is flattened onto the resolved paper colour rather than onto black, which is\n * what a transparent PNG becomes when a format with no transparency is asked\n * to hold it.\n *\n * **The share card** is a 1200 × 630 frame with the title on it and the whole\n * diagram letterboxed inside — never cropped. A card that cropped to fill the\n * frame would be a picture of a different diagram.\n */\nexport function DiagramExportMenu({\n targetRef,\n title,\n trigger,\n className,\n onExport,\n onResult,\n background,\n}: DiagramExportMenuProps) {\n const [busy, setBusy] = useState<ExportFormat | null>(null)\n\n async function run(format: ExportFormat) {\n const source = onExport ? 'caller' : 'built-in'\n setBusy(format)\n try {\n if (onExport) {\n await onExport(format)\n } else {\n await exportFigure(targetRef.current, title, format, { background })\n }\n // `ok` says the pipeline finished, and `source` says whose. Reporting a\n // bare success for a handler that may have done nothing was this menu\n // claiming to know something only the caller can.\n onResult?.({ format, ok: true, source })\n } catch (cause) {\n // Never swallowed: a download is something the reader just asked for, and\n // a click that quietly does nothing is indistinguishable from a broken\n // button. The caller gets the error to put in front of them.\n onResult?.({ format, ok: false, error: cause as Error, source })\n } finally {\n setBusy(null)\n }\n }\n\n const groups = [...new Set(ITEMS.map((item) => item.group))]\n\n return (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n {trigger ?? (\n <Button size=\"sm\" variant=\"secondary\" className={className}>\n Export\n </Button>\n )}\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" className=\"w-64\">\n {groups.map((group, index) => (\n <div key={group}>\n {index > 0 && <DropdownMenuSeparator />}\n <DropdownMenuLabel>{group}</DropdownMenuLabel>\n {/* Menu ROWS, not buttons that happen to sit inside a menu. Radix\n gives an item roving focus, typeahead and close-on-pick; a\n plain <button> inside a role=\"menu\" gets none of the three and\n leaves the menu open over the file it just wrote. */}\n {ITEMS.filter((item) => item.group === group).map((item) => (\n <DropdownMenuItem\n key={item.format}\n disabled={busy !== null}\n onSelect={() => void run(item.format)}\n className={cn('flex-col items-start gap-0.5')}\n >\n <span className=\"text-[13px] leading-tight text-(--ink)\">\n {item.title}\n {busy === item.format && ' …'}\n </span>\n <span className=\"mono-meta text-(--ink-3-aa)\">{item.hint}</span>\n </DropdownMenuItem>\n ))}\n </div>\n ))}\n </DropdownMenuContent>\n </DropdownMenu>\n )\n}\n\n/**\n * One figure, one format, one file.\n *\n * Exported so a page can wire a keyboard shortcut or its own button to exactly\n * what the menu does, without reimplementing the six steps.\n */\nexport async function exportFigure(\n target: HTMLElement | SVGSVGElement | null,\n title: string,\n format: ExportFormat,\n options: { background?: string | null } = {},\n): Promise<void> {\n const svg = findArtwork(target)\n if (!svg) throw new Error('exportFigure: found no <svg> to export inside the given element')\n\n // Read against the LIVE element, so the file comes out in whichever theme the\n // reader is looking at rather than in whichever one the code assumed.\n const paper = readToken(svg, '--diagram-surface', '#ffffff')\n const ink = readToken(svg, '--ink', '#101010')\n const font = getComputedStyle(svg).fontFamily || 'sans-serif'\n\n // `null` is a request, not a missing value: a figure dropped onto a coloured\n // page wants the page's own ground behind it, and a plate painted in every\n // format meant no export could ever give it one.\n const plate = options.background === null ? undefined : (options.background ?? paper)\n\n if (format === 'share-card') {\n const markup = serializeSvg(svg, {\n padding: 56,\n background: plate,\n title,\n titleColor: ink,\n titleFont: font,\n frame: SHARE_CARD,\n })\n const blob = await rasterize(markup, 'png', { scale: 1 })\n downloadBlob(blob, exportFilename(title, 'png', 'share-card'))\n return\n }\n\n const markup = serializeSvg(svg, { padding: 20, background: plate })\n\n if (format === 'svg') {\n downloadBlob(new Blob([markup], { type: 'image/svg+xml;charset=utf-8' }), exportFilename(title, 'svg'))\n return\n }\n\n const blob = await rasterize(markup, format, {\n scale: 2,\n // JPEG has no alpha channel, so an unflattened export lands on black.\n background: format === 'jpeg' ? paper : undefined,\n })\n downloadBlob(blob, exportFilename(title, format))\n}\n\n/**\n * The figure's own `<svg>`, not the first one in the subtree.\n *\n * The distinction is load-bearing: a toolbar sits inside the same wrapper and\n * every icon in it is an `<svg>`. Looking for the artwork marker first is what\n * stops an export of a diagram from being a picture of a chevron.\n */\nfunction findArtwork(element: HTMLElement | SVGSVGElement | null): SVGSVGElement | null {\n if (!element) return null\n if (element.tagName.toLowerCase() === 'svg') return element as SVGSVGElement\n return (\n element.querySelector<SVGSVGElement>('svg[data-diagram-artwork]') ??\n element.querySelector<SVGSVGElement>('svg')\n )\n}\n\nexport default DiagramExportMenu\n"],"mappings":"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","names":["markup","blob"]}
|
|
@@ -44,7 +44,7 @@ function DiagramInspector({
|
|
|
44
44
|
facts.length > 0 && // A definition list, because that is what this is: each row is a term
|
|
45
45
|
// and its value, and a grid of divs tells a screen reader nothing about
|
|
46
46
|
// which value belongs to which label.
|
|
47
|
-
/* @__PURE__ */ jsx("dl", { className: "m-0 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5", children: facts.map((fact) => /* @__PURE__ */ jsxs("div", { className: "contents", children: [
|
|
47
|
+
/* @__PURE__ */ jsx("dl", { className: "m-0 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5", children: facts.map((fact, index) => /* @__PURE__ */ jsxs("div", { className: "contents", children: [
|
|
48
48
|
/* @__PURE__ */ jsx("dt", { className: "mono-meta text-(--ink-3-aa)", children: fact.label }),
|
|
49
49
|
/* @__PURE__ */ jsx(
|
|
50
50
|
"dd",
|
|
@@ -56,7 +56,7 @@ function DiagramInspector({
|
|
|
56
56
|
children: fact.value
|
|
57
57
|
}
|
|
58
58
|
)
|
|
59
|
-
] },
|
|
59
|
+
] }, index)) }),
|
|
60
60
|
links.length > 0 && /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1 border-t border-(--rule) pt-3", children: [
|
|
61
61
|
/* @__PURE__ */ jsx("span", { className: "eyebrow text-(--ink-3-aa)", children: "Relationships" }),
|
|
62
62
|
/* @__PURE__ */ jsx("ul", { className: "m-0 flex list-none flex-col gap-0.5 p-0", children: links.map((link, index) => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/diagrams/DiagramInspector/DiagramInspector.tsx"],"sourcesContent":["'use client'\n\nimport type { ReactNode } from 'react'\nimport { X } from 'lucide-react'\nimport { cn } from '../../lib/cn'\n\n/** One fact about the selected thing: a short label and its value. */\nexport interface InspectorFact {\n label: string\n value: ReactNode\n /** Sets the value in mono — an id, a path, a version, a port. */\n mono?: boolean\n}\n\n/** A relationship the selected thing takes part in. */\nexport interface InspectorLink {\n id?: string\n /** Which way it runs, relative to the selected node. */\n direction: 'in' | 'out'\n label: string\n /** What sits at the other end. */\n peer: string\n onSelect?: () => void\n}\n\nexport interface DiagramInspectorProps {\n /** The kicker over the title — what KIND of thing this panel is showing. */\n eyebrow?: string\n title: string\n description?: ReactNode\n facts?: InspectorFact[]\n links?: InspectorLink[]\n /** Buttons under the facts — copy a link, open the source. */\n actions?: ReactNode\n onClose?: () => void\n className?: string\n /** Renders as a floating panel over the surface rather than in the flow. */\n floating?: boolean\n}\n\n/**\n * What the reader just picked, written out.\n *\n * A diagram can hold about eight words per node before it stops being a diagram\n * and starts being a document with lines drawn on it. Everything past those\n * eight — the port, the owning team, the file it was read out of, the six\n * relationships it takes part in — belongs beside the picture rather than\n * inside it, and this is that place.\n *\n * NOT A DIALOG, and that is deliberate. An inspector is a REGION that changes\n * with the selection, not a modal. Giving it `role=\"dialog\"` would trap focus\n * and demand dismissal for something the reader never opened — they clicked a\n * node, and the panel followed. So it is a labelled region with\n * `aria-live=\"polite\"`, which is what makes a screen reader announce the new\n * selection without stealing the cursor from whatever the reader was doing.\n *\n * The relationships are real buttons when they carry `onSelect`, which is how\n * the diagram becomes navigable from the keyboard: a reader can walk the graph\n * peer by peer without ever touching the picture.\n *\n * @example\n * <DiagramInspector\n * eyebrow=\"Service\"\n * title=\"API Server\"\n * description=\"FastAPI, behind the load balancer.\"\n * facts={[\n * { label: 'Port', value: '8000', mono: true },\n * { label: 'Id', value: 'api', mono: true },\n * ]}\n * links={[{ direction: 'out', label: 'SQL', peer: 'PostgreSQL' }]}\n * onClose={() => setSelected(null)}\n * />\n */\nexport function DiagramInspector({\n eyebrow,\n title,\n description,\n facts = [],\n links = [],\n actions,\n onClose,\n className,\n floating,\n}: DiagramInspectorProps) {\n return (\n <section\n aria-label={`${title} details`}\n aria-live=\"polite\"\n className={cn(\n 'flex flex-col gap-3 rounded-(--radius-lg) border border-(--rule-2) bg-(--panel-bg) p-4',\n floating && 'absolute bottom-3 start-3 z-(--z-sticky) w-72 max-w-[calc(100%-1.5rem)]',\n className,\n )}\n >\n <div className=\"flex items-start justify-between gap-3\">\n <div className=\"flex min-w-0 flex-col gap-1\">\n {eyebrow && <span className=\"eyebrow text-(--ink-3-aa)\">{eyebrow}</span>}\n <h3 className=\"m-0 font-sans text-[15px] leading-tight font-normal text-(--ink)\">\n {title}\n </h3>\n </div>\n {onClose && (\n <button\n type=\"button\"\n onClick={onClose}\n aria-label=\"Clear the selection\"\n className=\"-me-1 -mt-1 flex size-7 shrink-0 items-center justify-center rounded-(--radius-xs) text-(--ink-3-aa) transition-colors duration-(--duration-fast) hover:bg-(--stone) hover:text-(--ink) focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-(--ring)\"\n >\n <X size={14} strokeWidth={1.5} aria-hidden />\n </button>\n )}\n </div>\n\n {description && (\n <p className=\"m-0 text-[13px] leading-[1.55] text-(--ink-2)\">{description}</p>\n )}\n\n {facts.length > 0 && (\n // A definition list, because that is what this is: each row is a term\n // and its value, and a grid of divs tells a screen reader nothing about\n // which value belongs to which label.\n <dl className=\"m-0 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5\">\n {facts.map((fact) => (\n <div key={
|
|
1
|
+
{"version":3,"sources":["../../../src/diagrams/DiagramInspector/DiagramInspector.tsx"],"sourcesContent":["'use client'\n\nimport type { ReactNode } from 'react'\nimport { X } from 'lucide-react'\nimport { cn } from '../../lib/cn'\n\n/** One fact about the selected thing: a short label and its value. */\nexport interface InspectorFact {\n label: string\n value: ReactNode\n /** Sets the value in mono — an id, a path, a version, a port. */\n mono?: boolean\n}\n\n/** A relationship the selected thing takes part in. */\nexport interface InspectorLink {\n id?: string\n /** Which way it runs, relative to the selected node. */\n direction: 'in' | 'out'\n label: string\n /** What sits at the other end. */\n peer: string\n onSelect?: () => void\n}\n\nexport interface DiagramInspectorProps {\n /** The kicker over the title — what KIND of thing this panel is showing. */\n eyebrow?: string\n title: string\n description?: ReactNode\n facts?: InspectorFact[]\n links?: InspectorLink[]\n /** Buttons under the facts — copy a link, open the source. */\n actions?: ReactNode\n onClose?: () => void\n className?: string\n /** Renders as a floating panel over the surface rather than in the flow. */\n floating?: boolean\n}\n\n/**\n * What the reader just picked, written out.\n *\n * A diagram can hold about eight words per node before it stops being a diagram\n * and starts being a document with lines drawn on it. Everything past those\n * eight — the port, the owning team, the file it was read out of, the six\n * relationships it takes part in — belongs beside the picture rather than\n * inside it, and this is that place.\n *\n * NOT A DIALOG, and that is deliberate. An inspector is a REGION that changes\n * with the selection, not a modal. Giving it `role=\"dialog\"` would trap focus\n * and demand dismissal for something the reader never opened — they clicked a\n * node, and the panel followed. So it is a labelled region with\n * `aria-live=\"polite\"`, which is what makes a screen reader announce the new\n * selection without stealing the cursor from whatever the reader was doing.\n *\n * The relationships are real buttons when they carry `onSelect`, which is how\n * the diagram becomes navigable from the keyboard: a reader can walk the graph\n * peer by peer without ever touching the picture.\n *\n * @example\n * <DiagramInspector\n * eyebrow=\"Service\"\n * title=\"API Server\"\n * description=\"FastAPI, behind the load balancer.\"\n * facts={[\n * { label: 'Port', value: '8000', mono: true },\n * { label: 'Id', value: 'api', mono: true },\n * ]}\n * links={[{ direction: 'out', label: 'SQL', peer: 'PostgreSQL' }]}\n * onClose={() => setSelected(null)}\n * />\n */\nexport function DiagramInspector({\n eyebrow,\n title,\n description,\n facts = [],\n links = [],\n actions,\n onClose,\n className,\n floating,\n}: DiagramInspectorProps) {\n return (\n <section\n aria-label={`${title} details`}\n aria-live=\"polite\"\n className={cn(\n 'flex flex-col gap-3 rounded-(--radius-lg) border border-(--rule-2) bg-(--panel-bg) p-4',\n floating && 'absolute bottom-3 start-3 z-(--z-sticky) w-72 max-w-[calc(100%-1.5rem)]',\n className,\n )}\n >\n <div className=\"flex items-start justify-between gap-3\">\n <div className=\"flex min-w-0 flex-col gap-1\">\n {eyebrow && <span className=\"eyebrow text-(--ink-3-aa)\">{eyebrow}</span>}\n <h3 className=\"m-0 font-sans text-[15px] leading-tight font-normal text-(--ink)\">\n {title}\n </h3>\n </div>\n {onClose && (\n <button\n type=\"button\"\n onClick={onClose}\n aria-label=\"Clear the selection\"\n className=\"-me-1 -mt-1 flex size-7 shrink-0 items-center justify-center rounded-(--radius-xs) text-(--ink-3-aa) transition-colors duration-(--duration-fast) hover:bg-(--stone) hover:text-(--ink) focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-(--ring)\"\n >\n <X size={14} strokeWidth={1.5} aria-hidden />\n </button>\n )}\n </div>\n\n {description && (\n <p className=\"m-0 text-[13px] leading-[1.55] text-(--ink-2)\">{description}</p>\n )}\n\n {facts.length > 0 && (\n // A definition list, because that is what this is: each row is a term\n // and its value, and a grid of divs tells a screen reader nothing about\n // which value belongs to which label.\n <dl className=\"m-0 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5\">\n {/* Keyed by position, not by the label: two rows named \"Source\" is a\n specification a caller is entitled to write — a node read out of\n two files — and keying by the label made the second one collide\n with the first rather than appear under it. */}\n {facts.map((fact, index) => (\n <div key={index} className=\"contents\">\n <dt className=\"mono-meta text-(--ink-3-aa)\">{fact.label}</dt>\n <dd\n className={cn(\n 'm-0 min-w-0 break-words text-[13px] leading-snug text-(--ink)',\n fact.mono && 'font-mono text-[12px]',\n )}\n >\n {fact.value}\n </dd>\n </div>\n ))}\n </dl>\n )}\n\n {links.length > 0 && (\n <div className=\"flex flex-col gap-1 border-t border-(--rule) pt-3\">\n <span className=\"eyebrow text-(--ink-3-aa)\">Relationships</span>\n <ul className=\"m-0 flex list-none flex-col gap-0.5 p-0\">\n {links.map((link, index) => {\n const body = (\n <>\n {/* The arrow is hidden from assistive tech and the direction\n spelled out instead: a screen reader reading \"→\" aloud is\n either silence or the word \"arrow\", and neither says which\n way the relationship runs. */}\n <span aria-hidden className=\"font-mono text-[12px] text-(--ink-3-aa)\">\n {link.direction === 'out' ? '→' : '←'}\n </span>\n <span className=\"sr-only\">\n {link.direction === 'out' ? 'to' : 'from'}\n </span>\n <span className=\"min-w-0 flex-1 truncate text-[13px] text-(--ink)\">\n {link.peer}\n </span>\n {link.label && (\n <span className=\"mono-meta shrink-0 text-(--ink-3-aa)\">{link.label}</span>\n )}\n </>\n )\n\n const row = 'flex w-full items-baseline gap-2 rounded-(--radius-xs) px-1 py-1 text-start'\n\n return (\n <li key={link.id ?? index}>\n {link.onSelect ? (\n <button\n type=\"button\"\n onClick={link.onSelect}\n className={cn(\n row,\n 'transition-colors duration-(--duration-fast) hover:bg-(--stone) focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-(--ring)',\n )}\n >\n {body}\n </button>\n ) : (\n <span className={row}>{body}</span>\n )}\n </li>\n )\n })}\n </ul>\n </div>\n )}\n\n {actions && <div className=\"flex flex-wrap items-center gap-2 pt-1\">{actions}</div>}\n </section>\n )\n}\n\nexport default DiagramInspector\n"],"mappings":";AA+FQ,SAqDQ,UApDM,KADd;AA5FR,SAAS,SAAS;AAClB,SAAS,UAAU;AAqEZ,SAAS,iBAAiB;AAAA,EAC/B;AAAA,EACA;AAAA,EACA;AAAA,EACA,QAAQ,CAAC;AAAA,EACT,QAAQ,CAAC;AAAA,EACT;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAA0B;AACxB,SACE;AAAA,IAAC;AAAA;AAAA,MACC,cAAY,GAAG,KAAK;AAAA,MACpB,aAAU;AAAA,MACV,WAAW;AAAA,QACT;AAAA,QACA,YAAY;AAAA,QACZ;AAAA,MACF;AAAA,MAEA;AAAA,6BAAC,SAAI,WAAU,0CACb;AAAA,+BAAC,SAAI,WAAU,+BACZ;AAAA,uBAAW,oBAAC,UAAK,WAAU,6BAA6B,mBAAQ;AAAA,YACjE,oBAAC,QAAG,WAAU,oEACX,iBACH;AAAA,aACF;AAAA,UACC,WACC;AAAA,YAAC;AAAA;AAAA,cACC,MAAK;AAAA,cACL,SAAS;AAAA,cACT,cAAW;AAAA,cACX,WAAU;AAAA,cAEV,8BAAC,KAAE,MAAM,IAAI,aAAa,KAAK,eAAW,MAAC;AAAA;AAAA,UAC7C;AAAA,WAEJ;AAAA,QAEC,eACC,oBAAC,OAAE,WAAU,iDAAiD,uBAAY;AAAA,QAG3E,MAAM,SAAS;AAAA;AAAA;AAAA,QAId,oBAAC,QAAG,WAAU,mDAKX,gBAAM,IAAI,CAAC,MAAM,UAChB,qBAAC,SAAgB,WAAU,YACzB;AAAA,8BAAC,QAAG,WAAU,+BAA+B,eAAK,OAAM;AAAA,UACxD;AAAA,YAAC;AAAA;AAAA,cACC,WAAW;AAAA,gBACT;AAAA,gBACA,KAAK,QAAQ;AAAA,cACf;AAAA,cAEC,eAAK;AAAA;AAAA,UACR;AAAA,aATQ,KAUV,CACD,GACH;AAAA,QAGD,MAAM,SAAS,KACd,qBAAC,SAAI,WAAU,qDACb;AAAA,8BAAC,UAAK,WAAU,6BAA4B,2BAAa;AAAA,UACzD,oBAAC,QAAG,WAAU,2CACX,gBAAM,IAAI,CAAC,MAAM,UAAU;AAC1B,kBAAM,OACJ,iCAKE;AAAA,kCAAC,UAAK,eAAW,MAAC,WAAU,2CACzB,eAAK,cAAc,QAAQ,WAAM,UACpC;AAAA,cACA,oBAAC,UAAK,WAAU,WACb,eAAK,cAAc,QAAQ,OAAO,QACrC;AAAA,cACA,oBAAC,UAAK,WAAU,oDACb,eAAK,MACR;AAAA,cACC,KAAK,SACJ,oBAAC,UAAK,WAAU,wCAAwC,eAAK,OAAM;AAAA,eAEvE;AAGF,kBAAM,MAAM;AAEZ,mBACE,oBAAC,QACE,eAAK,WACJ;AAAA,cAAC;AAAA;AAAA,gBACC,MAAK;AAAA,gBACL,SAAS,KAAK;AAAA,gBACd,WAAW;AAAA,kBACT;AAAA,kBACA;AAAA,gBACF;AAAA,gBAEC;AAAA;AAAA,YACH,IAEA,oBAAC,UAAK,WAAW,KAAM,gBAAK,KAbvB,KAAK,MAAM,KAepB;AAAA,UAEJ,CAAC,GACH;AAAA,WACF;AAAA,QAGD,WAAW,oBAAC,SAAI,WAAU,0CAA0C,mBAAQ;AAAA;AAAA;AAAA,EAC/E;AAEJ;AAEA,IAAO,2BAAQ;","names":[]}
|
|
@@ -8,8 +8,14 @@ interface DiagramMinimapProps {
|
|
|
8
8
|
width: number;
|
|
9
9
|
height: number;
|
|
10
10
|
};
|
|
11
|
-
/**
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* The frame the artwork is being looked at through, in CSS pixels.
|
|
13
|
+
*
|
|
14
|
+
* Optional because a `DiagramCanvas` now reports its own frame on every view
|
|
15
|
+
* it emits: wire `onViewChange` straight through and this is already right.
|
|
16
|
+
* Pass it only for a frame this component cannot be told about.
|
|
17
|
+
*/
|
|
18
|
+
frame?: {
|
|
13
19
|
width: number;
|
|
14
20
|
height: number;
|
|
15
21
|
};
|
|
@@ -34,14 +40,25 @@ interface DiagramMinimapProps {
|
|
|
34
40
|
*
|
|
35
41
|
* THE RECTANGLE IS DERIVED, never stored. Its position comes out of the
|
|
36
42
|
* canvas's own view — the same three numbers the canvas is already transforming
|
|
37
|
-
* by — divided by the map's scale
|
|
38
|
-
*
|
|
39
|
-
*
|
|
43
|
+
* by — divided by the map's scale, and clamped to the plate: a viewport that
|
|
44
|
+
* has been panned past the edge of the artwork is a viewport half over blank
|
|
45
|
+
* paper, and drawing the rectangle out there claims the map extends somewhere
|
|
46
|
+
* it does not. Keeping a second copy of "where the viewport is" is how a
|
|
47
|
+
* minimap comes to disagree with the thing it is a map of, and a map that
|
|
48
|
+
* disagrees is worse than none.
|
|
49
|
+
*
|
|
50
|
+
* NOTHING IS DRAWN UNTIL THE ARTWORK HAS A SIZE. `content` is usually a
|
|
51
|
+
* measurement, and a measurement's first value is zero — so the scale would be
|
|
52
|
+
* 1, the miniature would be the artwork's top-left corner at full size, and the
|
|
53
|
+
* rectangle would sit over it meaning nothing. An empty plate for one frame is
|
|
54
|
+
* the honest version of "not yet".
|
|
40
55
|
*
|
|
41
56
|
* CLICKING RECENTRES rather than jumping. `onSeek` reports a point in CONTENT
|
|
42
57
|
* coordinates, which is what a canvas's `centerOn` takes. The minimap does not
|
|
43
58
|
* move anything itself: it has no authority over the view, it only says where
|
|
44
|
-
* the reader pointed.
|
|
59
|
+
* the reader pointed. A drag keeps seeking, and only a drag that STARTED on the
|
|
60
|
+
* map does — a button held down somewhere else and dragged across is not this
|
|
61
|
+
* component's gesture to answer.
|
|
45
62
|
*
|
|
46
63
|
* @example
|
|
47
64
|
* const canvas = useRef<DiagramCanvasHandle>(null)
|
|
@@ -49,7 +66,6 @@ interface DiagramMinimapProps {
|
|
|
49
66
|
* // …
|
|
50
67
|
* <DiagramMinimap
|
|
51
68
|
* content={{ width: 1400, height: 620 }}
|
|
52
|
-
* frame={{ width: 720, height: 400 }}
|
|
53
69
|
* view={view}
|
|
54
70
|
* onSeek={(x, y) => canvas.current?.centerOn(x, y)}
|
|
55
71
|
* >
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useRef } from "react";
|
|
3
4
|
import { cn } from "../../lib/cn.js";
|
|
4
5
|
function DiagramMinimap({
|
|
5
6
|
content,
|
|
@@ -11,18 +12,16 @@ function DiagramMinimap({
|
|
|
11
12
|
className,
|
|
12
13
|
label = "Diagram overview"
|
|
13
14
|
}) {
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
h: frame.height / view.scale * scale
|
|
21
|
-
};
|
|
15
|
+
const dragging = useRef(false);
|
|
16
|
+
const measured = content.width > 0 && content.height > 0;
|
|
17
|
+
const scale = measured ? width / content.width : 0;
|
|
18
|
+
const height = measured ? Math.max(1, content.height * scale) : Math.round(width / 2);
|
|
19
|
+
const box = frame ?? view.frame;
|
|
20
|
+
const rect = measured && box ? clamp(box, view, scale, width, height) : null;
|
|
22
21
|
function seek(event) {
|
|
23
|
-
if (!onSeek) return;
|
|
24
|
-
const
|
|
25
|
-
onSeek((event.clientX -
|
|
22
|
+
if (!onSeek || !measured) return;
|
|
23
|
+
const map = event.currentTarget.getBoundingClientRect();
|
|
24
|
+
onSeek((event.clientX - map.left) / scale, (event.clientY - map.top) / scale);
|
|
26
25
|
}
|
|
27
26
|
return /* @__PURE__ */ jsxs(
|
|
28
27
|
"div",
|
|
@@ -35,7 +34,7 @@ function DiagramMinimap({
|
|
|
35
34
|
role: "group",
|
|
36
35
|
"aria-label": label,
|
|
37
36
|
children: [
|
|
38
|
-
/* @__PURE__ */ jsx(
|
|
37
|
+
measured && /* @__PURE__ */ jsx(
|
|
39
38
|
"div",
|
|
40
39
|
{
|
|
41
40
|
"aria-hidden": "true",
|
|
@@ -47,21 +46,36 @@ function DiagramMinimap({
|
|
|
47
46
|
/* @__PURE__ */ jsx(
|
|
48
47
|
"div",
|
|
49
48
|
{
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
"data-seek": "",
|
|
50
|
+
className: cn("absolute inset-0", onSeek && measured && "cursor-crosshair"),
|
|
51
|
+
onPointerDown: (event) => {
|
|
52
|
+
dragging.current = true;
|
|
53
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
54
|
+
seek(event);
|
|
55
|
+
},
|
|
52
56
|
onPointerMove: (event) => {
|
|
53
|
-
if (event.buttons === 1) seek(event);
|
|
57
|
+
if (dragging.current && event.buttons === 1) seek(event);
|
|
58
|
+
},
|
|
59
|
+
onPointerUp: (event) => {
|
|
60
|
+
dragging.current = false;
|
|
61
|
+
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
62
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
onPointerCancel: () => {
|
|
66
|
+
dragging.current = false;
|
|
54
67
|
},
|
|
55
|
-
children: /* @__PURE__ */ jsx(
|
|
68
|
+
children: rect && /* @__PURE__ */ jsx(
|
|
56
69
|
"div",
|
|
57
70
|
{
|
|
58
71
|
"aria-hidden": "true",
|
|
72
|
+
"data-viewport": "",
|
|
59
73
|
className: "absolute rounded-[2px] border border-(--accent) bg-(--accent-wash)",
|
|
60
74
|
style: {
|
|
61
75
|
left: `${rect.x}px`,
|
|
62
76
|
top: `${rect.y}px`,
|
|
63
|
-
width: `${
|
|
64
|
-
height: `${
|
|
77
|
+
width: `${rect.w}px`,
|
|
78
|
+
height: `${rect.h}px`
|
|
65
79
|
}
|
|
66
80
|
}
|
|
67
81
|
)
|
|
@@ -71,6 +85,19 @@ function DiagramMinimap({
|
|
|
71
85
|
}
|
|
72
86
|
);
|
|
73
87
|
}
|
|
88
|
+
function clamp(frame, view, scale, width, height) {
|
|
89
|
+
const x = -view.x / view.scale * scale;
|
|
90
|
+
const y = -view.y / view.scale * scale;
|
|
91
|
+
const left = Math.min(Math.max(x, 0), width);
|
|
92
|
+
const top = Math.min(Math.max(y, 0), height);
|
|
93
|
+
return {
|
|
94
|
+
x: left,
|
|
95
|
+
y: top,
|
|
96
|
+
// Floored at a few pixels so a deep zoom still leaves something to see.
|
|
97
|
+
w: Math.max(4, Math.min(x + frame.width / view.scale * scale, width) - left),
|
|
98
|
+
h: Math.max(4, Math.min(y + frame.height / view.scale * scale, height) - top)
|
|
99
|
+
};
|
|
100
|
+
}
|
|
74
101
|
var DiagramMinimap_default = DiagramMinimap;
|
|
75
102
|
export {
|
|
76
103
|
DiagramMinimap,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/diagrams/DiagramMinimap/DiagramMinimap.tsx"],"sourcesContent":["'use client'\n\nimport type
|
|
1
|
+
{"version":3,"sources":["../../../src/diagrams/DiagramMinimap/DiagramMinimap.tsx"],"sourcesContent":["'use client'\n\nimport { useRef, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'\nimport { cn } from '../../lib/cn'\nimport type { CanvasView } from '../DiagramCanvas/DiagramCanvas'\n\nexport interface DiagramMinimapProps {\n /** The whole artwork, at its natural size in CSS pixels. */\n content: { width: number; height: number }\n /**\n * The frame the artwork is being looked at through, in CSS pixels.\n *\n * Optional because a `DiagramCanvas` now reports its own frame on every view\n * it emits: wire `onViewChange` straight through and this is already right.\n * Pass it only for a frame this component cannot be told about.\n */\n frame?: { width: number; height: number }\n /** Where that frame currently sits — a `DiagramCanvas`'s `onViewChange`. */\n view: CanvasView\n /** A miniature of the artwork. Usually the same figure, rendered again. */\n children?: ReactNode\n /** Called with a point in CONTENT coordinates when the reader picks one. */\n onSeek?: (x: number, y: number) => void\n /** How wide the map is, in CSS pixels. Height follows the aspect ratio. */\n width?: number\n className?: string\n label?: string\n}\n\n/**\n * Where you are in something bigger than the window.\n *\n * Two things at once, and both are needed: a miniature of the whole artwork,\n * and a rectangle showing which part of it the frame is currently over. The\n * miniature alone answers \"what is there\"; the rectangle answers \"and where am\n * I\", which is the question a reader who has just panned twice actually has.\n *\n * THE RECTANGLE IS DERIVED, never stored. Its position comes out of the\n * canvas's own view — the same three numbers the canvas is already transforming\n * by — divided by the map's scale, and clamped to the plate: a viewport that\n * has been panned past the edge of the artwork is a viewport half over blank\n * paper, and drawing the rectangle out there claims the map extends somewhere\n * it does not. Keeping a second copy of \"where the viewport is\" is how a\n * minimap comes to disagree with the thing it is a map of, and a map that\n * disagrees is worse than none.\n *\n * NOTHING IS DRAWN UNTIL THE ARTWORK HAS A SIZE. `content` is usually a\n * measurement, and a measurement's first value is zero — so the scale would be\n * 1, the miniature would be the artwork's top-left corner at full size, and the\n * rectangle would sit over it meaning nothing. An empty plate for one frame is\n * the honest version of \"not yet\".\n *\n * CLICKING RECENTRES rather than jumping. `onSeek` reports a point in CONTENT\n * coordinates, which is what a canvas's `centerOn` takes. The minimap does not\n * move anything itself: it has no authority over the view, it only says where\n * the reader pointed. A drag keeps seeking, and only a drag that STARTED on the\n * map does — a button held down somewhere else and dragged across is not this\n * component's gesture to answer.\n *\n * @example\n * const canvas = useRef<DiagramCanvasHandle>(null)\n * const [view, setView] = useState({ scale: 1, x: 0, y: 0 })\n * // …\n * <DiagramMinimap\n * content={{ width: 1400, height: 620 }}\n * view={view}\n * onSeek={(x, y) => canvas.current?.centerOn(x, y)}\n * >\n * <ArchitectureFigure spec={spec} heading={false} legend=\"hidden\" cards={false} />\n * </DiagramMinimap>\n */\nexport function DiagramMinimap({\n content,\n frame,\n view,\n children,\n onSeek,\n width = 200,\n className,\n label = 'Diagram overview',\n}: DiagramMinimapProps) {\n const dragging = useRef(false)\n\n const measured = content.width > 0 && content.height > 0\n const scale = measured ? width / content.width : 0\n // Half the width is a neutral plate to hold the space, not a claim about the\n // artwork's shape — nothing here knows it yet.\n const height = measured ? Math.max(1, content.height * scale) : Math.round(width / 2)\n\n const box = frame ?? view.frame\n const rect = measured && box ? clamp(box, view, scale, width, height) : null\n\n function seek(event: ReactPointerEvent<HTMLDivElement>) {\n if (!onSeek || !measured) return\n const map = event.currentTarget.getBoundingClientRect()\n onSeek((event.clientX - map.left) / scale, (event.clientY - map.top) / scale)\n }\n\n return (\n <div\n className={cn(\n 'relative overflow-hidden rounded-(--radius) border border-(--rule-2) bg-(--panel-bg)',\n className,\n )}\n style={{ width, height }}\n role=\"group\"\n aria-label={label}\n >\n {/* The miniature is decorative: the figure it mirrors already publishes\n its own accessible summary, and a second copy would read the whole\n diagram out twice. */}\n {measured && (\n <div\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute start-0 top-0 origin-top-left\"\n style={{ width: content.width, transform: `scale(${scale})` }}\n >\n {children}\n </div>\n )}\n\n <div\n data-seek=\"\"\n className={cn('absolute inset-0', onSeek && measured && 'cursor-crosshair')}\n onPointerDown={(event) => {\n dragging.current = true\n event.currentTarget.setPointerCapture(event.pointerId)\n seek(event)\n }}\n onPointerMove={(event) => {\n if (dragging.current && event.buttons === 1) seek(event)\n }}\n onPointerUp={(event) => {\n dragging.current = false\n if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n event.currentTarget.releasePointerCapture(event.pointerId)\n }\n }}\n onPointerCancel={() => {\n dragging.current = false\n }}\n >\n {rect && (\n <div\n aria-hidden=\"true\"\n data-viewport=\"\"\n className=\"absolute rounded-[2px] border border-(--accent) bg-(--accent-wash)\"\n style={{\n left: `${rect.x}px`,\n top: `${rect.y}px`,\n width: `${rect.w}px`,\n height: `${rect.h}px`,\n }}\n />\n )}\n </div>\n </div>\n )\n}\n\n/**\n * The frame's rectangle in map space, cut to the map.\n *\n * `view.x` is how far the stage has been pushed, so the content coordinate at\n * the frame's top-left corner is its negation over the zoom. The intersection\n * with the plate is what keeps the rectangle a statement about the artwork: a\n * frame wider than the whole diagram used to draw a box hanging off both edges,\n * which reads as \"there is more over there\" and there is not.\n */\nfunction clamp(\n frame: { width: number; height: number },\n view: CanvasView,\n scale: number,\n width: number,\n height: number,\n): { x: number; y: number; w: number; h: number } {\n const x = (-view.x / view.scale) * scale\n const y = (-view.y / view.scale) * scale\n const left = Math.min(Math.max(x, 0), width)\n const top = Math.min(Math.max(y, 0), height)\n return {\n x: left,\n y: top,\n // Floored at a few pixels so a deep zoom still leaves something to see.\n w: Math.max(4, Math.min(x + (frame.width / view.scale) * scale, width) - left),\n h: Math.max(4, Math.min(y + (frame.height / view.scale) * scale, height) - top),\n }\n}\n\nexport default DiagramMinimap\n"],"mappings":"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","names":[]}
|