@oh-just-another/react-ui 0.61.0 → 0.63.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/CHANGELOG.md +132 -0
- package/README.md +3 -3
- package/dist/.tsbuildinfo +1 -1
- package/dist/color/color-palette.d.ts.map +1 -0
- package/dist/color/color-palette.js.map +1 -0
- package/dist/color/color-swatch-picker.d.ts.map +1 -0
- package/dist/{color-swatch-picker.js → color/color-swatch-picker.js} +4 -3
- package/dist/color/color-swatch-picker.js.map +1 -0
- package/dist/{constants.d.ts → core/constants.d.ts} +66 -14
- package/dist/core/constants.d.ts.map +1 -0
- package/dist/{constants.js → core/constants.js} +99 -14
- package/dist/core/constants.js.map +1 -0
- package/dist/core/context.d.ts.map +1 -0
- package/dist/core/context.js.map +1 -0
- package/dist/core/hooks.d.ts.map +1 -0
- package/dist/core/hooks.js.map +1 -0
- package/dist/core/portal-container.d.ts.map +1 -0
- package/dist/core/portal-container.js.map +1 -0
- package/dist/core/use-fullscreen.d.ts +14 -0
- package/dist/core/use-fullscreen.d.ts.map +1 -0
- package/dist/core/use-fullscreen.js +38 -0
- package/dist/core/use-fullscreen.js.map +1 -0
- package/dist/core/use-quiet-gesture.d.ts +11 -0
- package/dist/core/use-quiet-gesture.d.ts.map +1 -0
- package/dist/core/use-quiet-gesture.js +61 -0
- package/dist/core/use-quiet-gesture.js.map +1 -0
- package/dist/core/use-quiet-viewport.d.ts +10 -0
- package/dist/core/use-quiet-viewport.d.ts.map +1 -0
- package/dist/core/use-quiet-viewport.js +40 -0
- package/dist/core/use-quiet-viewport.js.map +1 -0
- package/dist/index.d.ts +55 -50
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +55 -50
- package/dist/index.js.map +1 -1
- package/dist/layout/diagram-bars.d.ts.map +1 -0
- package/dist/layout/diagram-bars.js.map +1 -0
- package/dist/layout/diagram-canvas.d.ts.map +1 -0
- package/dist/layout/diagram-canvas.js.map +1 -0
- package/dist/layout/diagram-root.d.ts.map +1 -0
- package/dist/{diagram-root.js → layout/diagram-root.js} +2 -2
- package/dist/layout/diagram-root.js.map +1 -0
- package/dist/{sidebar.d.ts → layout/sidebar.d.ts} +1 -0
- package/dist/layout/sidebar.d.ts.map +1 -0
- package/dist/layout/sidebar.js +38 -0
- package/dist/layout/sidebar.js.map +1 -0
- package/dist/layout/ui-layer.d.ts.map +1 -0
- package/dist/layout/ui-layer.js.map +1 -0
- package/dist/menus/command-palette.d.ts.map +1 -0
- package/dist/{command-palette.js → menus/command-palette.js} +2 -2
- package/dist/menus/command-palette.js.map +1 -0
- package/dist/menus/context-menu-controller.d.ts.map +1 -0
- package/dist/menus/context-menu-controller.js.map +1 -0
- package/dist/{context-menu.d.ts → menus/context-menu.d.ts} +12 -0
- package/dist/menus/context-menu.d.ts.map +1 -0
- package/dist/menus/context-menu.js +617 -0
- package/dist/menus/context-menu.js.map +1 -0
- package/dist/menus/link-drop-shape-menu.d.ts.map +1 -0
- package/dist/{link-drop-shape-menu.js → menus/link-drop-shape-menu.js} +4 -4
- package/dist/menus/link-drop-shape-menu.js.map +1 -0
- package/dist/{main-menu.d.ts → menus/main-menu.d.ts} +45 -2
- package/dist/menus/main-menu.d.ts.map +1 -0
- package/dist/menus/main-menu.js +260 -0
- package/dist/menus/main-menu.js.map +1 -0
- package/dist/overlays/file-drop-overlay.d.ts +15 -0
- package/dist/overlays/file-drop-overlay.d.ts.map +1 -0
- package/dist/overlays/file-drop-overlay.js +27 -0
- package/dist/overlays/file-drop-overlay.js.map +1 -0
- package/dist/overlays/frame-name-editor-overlay.d.ts.map +1 -0
- package/dist/{frame-name-editor-overlay.js → overlays/frame-name-editor-overlay.js} +1 -1
- package/dist/overlays/frame-name-editor-overlay.js.map +1 -0
- package/dist/overlays/help-button.d.ts.map +1 -0
- package/dist/{help-button.js → overlays/help-button.js} +3 -4
- package/dist/overlays/help-button.js.map +1 -0
- package/dist/overlays/help-dialog.d.ts.map +1 -0
- package/dist/{help-dialog.js → overlays/help-dialog.js} +4 -24
- package/dist/overlays/help-dialog.js.map +1 -0
- package/dist/overlays/link-badges.d.ts +14 -0
- package/dist/overlays/link-badges.d.ts.map +1 -0
- package/dist/overlays/link-badges.js +60 -0
- package/dist/overlays/link-badges.js.map +1 -0
- package/dist/overlays/link-caption-editor.d.ts.map +1 -0
- package/dist/{link-caption-editor.js → overlays/link-caption-editor.js} +3 -3
- package/dist/overlays/link-caption-editor.js.map +1 -0
- package/dist/overlays/link-hover-popup.d.ts.map +1 -0
- package/dist/{link-hover-popup.js → overlays/link-hover-popup.js} +5 -4
- package/dist/overlays/link-hover-popup.js.map +1 -0
- package/dist/overlays/merge-dialog.d.ts.map +1 -0
- package/dist/overlays/merge-dialog.js +39 -0
- package/dist/overlays/merge-dialog.js.map +1 -0
- package/dist/overlays/search-overlay.d.ts.map +1 -0
- package/dist/{search-overlay.js → overlays/search-overlay.js} +2 -2
- package/dist/overlays/search-overlay.js.map +1 -0
- package/dist/overlays/sticky-reactions.d.ts +2 -0
- package/dist/overlays/sticky-reactions.d.ts.map +1 -0
- package/dist/overlays/sticky-reactions.js +94 -0
- package/dist/overlays/sticky-reactions.js.map +1 -0
- package/dist/overlays/text-editor-overlay.d.ts.map +1 -0
- package/dist/{text-editor-overlay.js → overlays/text-editor-overlay.js} +37 -5
- package/dist/overlays/text-editor-overlay.js.map +1 -0
- package/dist/{comments-panel.d.ts → panels/comments-panel.d.ts} +4 -1
- package/dist/panels/comments-panel.d.ts.map +1 -0
- package/dist/panels/comments-panel.js +51 -0
- package/dist/panels/comments-panel.js.map +1 -0
- package/dist/panels/control-sets.d.ts +42 -0
- package/dist/panels/control-sets.d.ts.map +1 -0
- package/dist/panels/control-sets.js +63 -0
- package/dist/panels/control-sets.js.map +1 -0
- package/dist/panels/diff-panel.d.ts.map +1 -0
- package/dist/panels/diff-panel.js.map +1 -0
- package/dist/panels/drawing-panel.d.ts.map +1 -0
- package/dist/{drawing-panel.js → panels/drawing-panel.js} +3 -3
- package/dist/panels/drawing-panel.js.map +1 -0
- package/dist/panels/edge-style-panel.d.ts.map +1 -0
- package/dist/{edge-style-panel.js → panels/edge-style-panel.js} +1 -1
- package/dist/panels/edge-style-panel.js.map +1 -0
- package/dist/panels/frame-panel.d.ts.map +1 -0
- package/dist/{frame-panel.js → panels/frame-panel.js} +1 -1
- package/dist/panels/frame-panel.js.map +1 -0
- package/dist/{layer-panel.d.ts → panels/layer-panel.d.ts} +5 -1
- package/dist/panels/layer-panel.d.ts.map +1 -0
- package/dist/panels/layer-panel.js +52 -0
- package/dist/panels/layer-panel.js.map +1 -0
- package/dist/panels/library-panel.d.ts.map +1 -0
- package/dist/{library-panel.js → panels/library-panel.js} +4 -5
- package/dist/panels/library-panel.js.map +1 -0
- package/dist/panels/property-panel.d.ts.map +1 -0
- package/dist/panels/property-panel.js +1492 -0
- package/dist/panels/property-panel.js.map +1 -0
- package/dist/panels/selection-floating-panel.d.ts.map +1 -0
- package/dist/{selection-floating-panel.js → panels/selection-floating-panel.js} +24 -13
- package/dist/panels/selection-floating-panel.js.map +1 -0
- package/dist/panels/stats-panel.d.ts.map +1 -0
- package/dist/{stats-panel.js → panels/stats-panel.js} +1 -1
- package/dist/panels/stats-panel.js.map +1 -0
- package/dist/primitives/bottom-sheet.d.ts.map +1 -0
- package/dist/{bottom-sheet.js → primitives/bottom-sheet.js} +9 -22
- package/dist/primitives/bottom-sheet.js.map +1 -0
- package/dist/primitives/button-group.d.ts.map +1 -0
- package/dist/primitives/button-group.js.map +1 -0
- package/dist/primitives/css-var.d.ts +8 -0
- package/dist/primitives/css-var.d.ts.map +1 -0
- package/dist/primitives/css-var.js +11 -0
- package/dist/primitives/css-var.js.map +1 -0
- package/dist/primitives/float-panel.d.ts +35 -0
- package/dist/primitives/float-panel.d.ts.map +1 -0
- package/dist/primitives/float-panel.js +42 -0
- package/dist/primitives/float-panel.js.map +1 -0
- package/dist/primitives/icon-button.d.ts.map +1 -0
- package/dist/primitives/icon-button.js.map +1 -0
- package/dist/primitives/markdown.d.ts.map +1 -0
- package/dist/primitives/markdown.js.map +1 -0
- package/dist/primitives/modal.d.ts.map +1 -0
- package/dist/{modal.js → primitives/modal.js} +11 -9
- package/dist/primitives/modal.js.map +1 -0
- package/dist/{popover.d.ts → primitives/popover.d.ts} +1 -1
- package/dist/primitives/popover.d.ts.map +1 -0
- package/dist/{popover.js → primitives/popover.js} +7 -18
- package/dist/primitives/popover.js.map +1 -0
- package/dist/primitives/segmented-control.d.ts.map +1 -0
- package/dist/primitives/segmented-control.js.map +1 -0
- package/dist/primitives/slider.d.ts.map +1 -0
- package/dist/primitives/slider.js.map +1 -0
- package/dist/primitives/switch.d.ts +24 -0
- package/dist/primitives/switch.d.ts.map +1 -0
- package/dist/primitives/switch.js +17 -0
- package/dist/primitives/switch.js.map +1 -0
- package/dist/primitives/toast.d.ts.map +1 -0
- package/dist/{toast.js → primitives/toast.js} +12 -33
- package/dist/primitives/toast.js.map +1 -0
- package/dist/primitives/tooltip.d.ts.map +1 -0
- package/dist/{tooltip.js → primitives/tooltip.js} +3 -3
- package/dist/primitives/tooltip.js.map +1 -0
- package/dist/{palette.d.ts → toolbar/palette.d.ts} +6 -1
- package/dist/toolbar/palette.d.ts.map +1 -0
- package/dist/{palette.js → toolbar/palette.js} +39 -5
- package/dist/toolbar/palette.js.map +1 -0
- package/dist/{toolbar.d.ts → toolbar/toolbar.d.ts} +9 -0
- package/dist/toolbar/toolbar.d.ts.map +1 -0
- package/dist/{toolbar.js → toolbar/toolbar.js} +132 -62
- package/dist/toolbar/toolbar.js.map +1 -0
- package/dist/utils/format-time.d.ts.map +1 -0
- package/dist/utils/format-time.js.map +1 -0
- package/dist/utils/use-dismiss-timer.d.ts.map +1 -0
- package/dist/utils/use-dismiss-timer.js.map +1 -0
- package/dist/widgets/minimap.d.ts.map +1 -0
- package/dist/{minimap.js → widgets/minimap.js} +50 -36
- package/dist/widgets/minimap.js.map +1 -0
- package/dist/widgets/reset-to-content-button.d.ts +11 -0
- package/dist/widgets/reset-to-content-button.d.ts.map +1 -0
- package/dist/{reset-to-content-button.js → widgets/reset-to-content-button.js} +8 -9
- package/dist/widgets/reset-to-content-button.js.map +1 -0
- package/dist/widgets/versioning.d.ts.map +1 -0
- package/dist/{versioning.js → widgets/versioning.js} +3 -3
- package/dist/widgets/versioning.js.map +1 -0
- package/dist/widgets/zen-mode.d.ts.map +1 -0
- package/dist/widgets/zen-mode.js.map +1 -0
- package/package.json +9 -9
- package/src/styles.css +1596 -258
- package/dist/bottom-sheet.d.ts.map +0 -1
- package/dist/bottom-sheet.js.map +0 -1
- package/dist/button-group.d.ts.map +0 -1
- package/dist/button-group.js.map +0 -1
- package/dist/color-palette.d.ts.map +0 -1
- package/dist/color-palette.js.map +0 -1
- package/dist/color-swatch-picker.d.ts.map +0 -1
- package/dist/color-swatch-picker.js.map +0 -1
- package/dist/command-palette.d.ts.map +0 -1
- package/dist/command-palette.js.map +0 -1
- package/dist/comments-panel.d.ts.map +0 -1
- package/dist/comments-panel.js +0 -142
- package/dist/comments-panel.js.map +0 -1
- package/dist/constants.d.ts.map +0 -1
- package/dist/constants.js.map +0 -1
- package/dist/context-menu-controller.d.ts.map +0 -1
- package/dist/context-menu-controller.js.map +0 -1
- package/dist/context-menu.d.ts.map +0 -1
- package/dist/context-menu.js +0 -368
- package/dist/context-menu.js.map +0 -1
- package/dist/context.d.ts.map +0 -1
- package/dist/context.js.map +0 -1
- package/dist/diagram-bars.d.ts.map +0 -1
- package/dist/diagram-bars.js.map +0 -1
- package/dist/diagram-canvas.d.ts.map +0 -1
- package/dist/diagram-canvas.js.map +0 -1
- package/dist/diagram-root.d.ts.map +0 -1
- package/dist/diagram-root.js.map +0 -1
- package/dist/diff-panel.d.ts.map +0 -1
- package/dist/diff-panel.js.map +0 -1
- package/dist/drawing-panel.d.ts.map +0 -1
- package/dist/drawing-panel.js.map +0 -1
- package/dist/edge-style-panel.d.ts.map +0 -1
- package/dist/edge-style-panel.js.map +0 -1
- package/dist/format-time.d.ts.map +0 -1
- package/dist/format-time.js.map +0 -1
- package/dist/frame-name-editor-overlay.d.ts.map +0 -1
- package/dist/frame-name-editor-overlay.js.map +0 -1
- package/dist/frame-panel.d.ts.map +0 -1
- package/dist/frame-panel.js.map +0 -1
- package/dist/help-button.d.ts.map +0 -1
- package/dist/help-button.js.map +0 -1
- package/dist/help-dialog.d.ts.map +0 -1
- package/dist/help-dialog.js.map +0 -1
- package/dist/hooks.d.ts.map +0 -1
- package/dist/hooks.js.map +0 -1
- package/dist/icon-button.d.ts.map +0 -1
- package/dist/icon-button.js.map +0 -1
- package/dist/layer-panel.d.ts.map +0 -1
- package/dist/layer-panel.js +0 -117
- package/dist/layer-panel.js.map +0 -1
- package/dist/library-panel.d.ts.map +0 -1
- package/dist/library-panel.js.map +0 -1
- package/dist/link-caption-editor.d.ts.map +0 -1
- package/dist/link-caption-editor.js.map +0 -1
- package/dist/link-drop-shape-menu.d.ts.map +0 -1
- package/dist/link-drop-shape-menu.js.map +0 -1
- package/dist/link-hover-popup.d.ts.map +0 -1
- package/dist/link-hover-popup.js.map +0 -1
- package/dist/main-menu.d.ts.map +0 -1
- package/dist/main-menu.js +0 -249
- package/dist/main-menu.js.map +0 -1
- package/dist/markdown.d.ts.map +0 -1
- package/dist/markdown.js.map +0 -1
- package/dist/merge-dialog.d.ts.map +0 -1
- package/dist/merge-dialog.js +0 -50
- package/dist/merge-dialog.js.map +0 -1
- package/dist/minimap.d.ts.map +0 -1
- package/dist/minimap.js.map +0 -1
- package/dist/modal.d.ts.map +0 -1
- package/dist/modal.js.map +0 -1
- package/dist/palette.d.ts.map +0 -1
- package/dist/palette.js.map +0 -1
- package/dist/popover.d.ts.map +0 -1
- package/dist/popover.js.map +0 -1
- package/dist/portal-container.d.ts.map +0 -1
- package/dist/portal-container.js.map +0 -1
- package/dist/property-panel.d.ts.map +0 -1
- package/dist/property-panel.js +0 -917
- package/dist/property-panel.js.map +0 -1
- package/dist/reset-to-content-button.d.ts +0 -10
- package/dist/reset-to-content-button.d.ts.map +0 -1
- package/dist/reset-to-content-button.js.map +0 -1
- package/dist/search-overlay.d.ts.map +0 -1
- package/dist/search-overlay.js.map +0 -1
- package/dist/segmented-control.d.ts.map +0 -1
- package/dist/segmented-control.js.map +0 -1
- package/dist/selection-floating-panel.d.ts.map +0 -1
- package/dist/selection-floating-panel.js.map +0 -1
- package/dist/sidebar.d.ts.map +0 -1
- package/dist/sidebar.js +0 -68
- package/dist/sidebar.js.map +0 -1
- package/dist/slider.d.ts.map +0 -1
- package/dist/slider.js.map +0 -1
- package/dist/stats-panel.d.ts.map +0 -1
- package/dist/stats-panel.js.map +0 -1
- package/dist/text-editor-overlay.d.ts.map +0 -1
- package/dist/text-editor-overlay.js.map +0 -1
- package/dist/toast.d.ts.map +0 -1
- package/dist/toast.js.map +0 -1
- package/dist/toolbar.d.ts.map +0 -1
- package/dist/toolbar.js.map +0 -1
- package/dist/tooltip.d.ts.map +0 -1
- package/dist/tooltip.js.map +0 -1
- package/dist/ui-layer.d.ts.map +0 -1
- package/dist/ui-layer.js.map +0 -1
- package/dist/use-dismiss-timer.d.ts.map +0 -1
- package/dist/use-dismiss-timer.js.map +0 -1
- package/dist/versioning.d.ts.map +0 -1
- package/dist/versioning.js.map +0 -1
- package/dist/zen-mode.d.ts.map +0 -1
- package/dist/zen-mode.js.map +0 -1
- /package/dist/{color-palette.d.ts → color/color-palette.d.ts} +0 -0
- /package/dist/{color-palette.js → color/color-palette.js} +0 -0
- /package/dist/{color-swatch-picker.d.ts → color/color-swatch-picker.d.ts} +0 -0
- /package/dist/{context.d.ts → core/context.d.ts} +0 -0
- /package/dist/{context.js → core/context.js} +0 -0
- /package/dist/{hooks.d.ts → core/hooks.d.ts} +0 -0
- /package/dist/{hooks.js → core/hooks.js} +0 -0
- /package/dist/{portal-container.d.ts → core/portal-container.d.ts} +0 -0
- /package/dist/{portal-container.js → core/portal-container.js} +0 -0
- /package/dist/{diagram-bars.d.ts → layout/diagram-bars.d.ts} +0 -0
- /package/dist/{diagram-bars.js → layout/diagram-bars.js} +0 -0
- /package/dist/{diagram-canvas.d.ts → layout/diagram-canvas.d.ts} +0 -0
- /package/dist/{diagram-canvas.js → layout/diagram-canvas.js} +0 -0
- /package/dist/{diagram-root.d.ts → layout/diagram-root.d.ts} +0 -0
- /package/dist/{ui-layer.d.ts → layout/ui-layer.d.ts} +0 -0
- /package/dist/{ui-layer.js → layout/ui-layer.js} +0 -0
- /package/dist/{command-palette.d.ts → menus/command-palette.d.ts} +0 -0
- /package/dist/{context-menu-controller.d.ts → menus/context-menu-controller.d.ts} +0 -0
- /package/dist/{context-menu-controller.js → menus/context-menu-controller.js} +0 -0
- /package/dist/{link-drop-shape-menu.d.ts → menus/link-drop-shape-menu.d.ts} +0 -0
- /package/dist/{frame-name-editor-overlay.d.ts → overlays/frame-name-editor-overlay.d.ts} +0 -0
- /package/dist/{help-button.d.ts → overlays/help-button.d.ts} +0 -0
- /package/dist/{help-dialog.d.ts → overlays/help-dialog.d.ts} +0 -0
- /package/dist/{link-caption-editor.d.ts → overlays/link-caption-editor.d.ts} +0 -0
- /package/dist/{link-hover-popup.d.ts → overlays/link-hover-popup.d.ts} +0 -0
- /package/dist/{merge-dialog.d.ts → overlays/merge-dialog.d.ts} +0 -0
- /package/dist/{search-overlay.d.ts → overlays/search-overlay.d.ts} +0 -0
- /package/dist/{text-editor-overlay.d.ts → overlays/text-editor-overlay.d.ts} +0 -0
- /package/dist/{diff-panel.d.ts → panels/diff-panel.d.ts} +0 -0
- /package/dist/{diff-panel.js → panels/diff-panel.js} +0 -0
- /package/dist/{drawing-panel.d.ts → panels/drawing-panel.d.ts} +0 -0
- /package/dist/{edge-style-panel.d.ts → panels/edge-style-panel.d.ts} +0 -0
- /package/dist/{frame-panel.d.ts → panels/frame-panel.d.ts} +0 -0
- /package/dist/{library-panel.d.ts → panels/library-panel.d.ts} +0 -0
- /package/dist/{property-panel.d.ts → panels/property-panel.d.ts} +0 -0
- /package/dist/{selection-floating-panel.d.ts → panels/selection-floating-panel.d.ts} +0 -0
- /package/dist/{stats-panel.d.ts → panels/stats-panel.d.ts} +0 -0
- /package/dist/{bottom-sheet.d.ts → primitives/bottom-sheet.d.ts} +0 -0
- /package/dist/{button-group.d.ts → primitives/button-group.d.ts} +0 -0
- /package/dist/{button-group.js → primitives/button-group.js} +0 -0
- /package/dist/{icon-button.d.ts → primitives/icon-button.d.ts} +0 -0
- /package/dist/{icon-button.js → primitives/icon-button.js} +0 -0
- /package/dist/{markdown.d.ts → primitives/markdown.d.ts} +0 -0
- /package/dist/{markdown.js → primitives/markdown.js} +0 -0
- /package/dist/{modal.d.ts → primitives/modal.d.ts} +0 -0
- /package/dist/{segmented-control.d.ts → primitives/segmented-control.d.ts} +0 -0
- /package/dist/{segmented-control.js → primitives/segmented-control.js} +0 -0
- /package/dist/{slider.d.ts → primitives/slider.d.ts} +0 -0
- /package/dist/{slider.js → primitives/slider.js} +0 -0
- /package/dist/{toast.d.ts → primitives/toast.d.ts} +0 -0
- /package/dist/{tooltip.d.ts → primitives/tooltip.d.ts} +0 -0
- /package/dist/{format-time.d.ts → utils/format-time.d.ts} +0 -0
- /package/dist/{format-time.js → utils/format-time.js} +0 -0
- /package/dist/{use-dismiss-timer.d.ts → utils/use-dismiss-timer.d.ts} +0 -0
- /package/dist/{use-dismiss-timer.js → utils/use-dismiss-timer.js} +0 -0
- /package/dist/{minimap.d.ts → widgets/minimap.d.ts} +0 -0
- /package/dist/{versioning.d.ts → widgets/versioning.d.ts} +0 -0
- /package/dist/{zen-mode.d.ts → widgets/zen-mode.d.ts} +0 -0
- /package/dist/{zen-mode.js → widgets/zen-mode.js} +0 -0
|
@@ -0,0 +1,1492 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Fragment, isValidElement, useEffect, useRef, useState, } from "react";
|
|
3
|
+
import { AlignCenter, AlignCenterHorizontal, AlignCenterVertical, AlignEndHorizontal, AlignEndVertical, AlignHorizontalDistributeCenter, AlignStartVertical, AlignVerticalDistributeCenter, Group as GroupIcon, LayoutGrid, Ungroup as UngroupIcon, AlignLeft, AlignRight, AlignStartHorizontal, Bold, CaseSensitive, Download, Circle, Crop, Diamond, FileText, Hexagon as HexagonIcon, Highlighter, Image as ImageIcon, IndentDecrease, IndentIncrease, Italic, Link as LinkIcon, List, ListFilter, ListOrdered, Lock as LockIcon, MessageCircle, Minus, MoreHorizontal, MoreVertical, MoveRight, Proportions, RectangleHorizontal, RectangleVertical, SmilePlus, Spline, Square, Squircle, Star as StarIcon, StickyNote, SquareDashed, SquareDot, Tag, Strikethrough, Trash2, Tv, Underline, Upload, UserRound, Waypoints, Triangle as TriangleIcon, } from "lucide-react";
|
|
4
|
+
import { getBinaryFile, isText, isImage, canCarryLabel, isSticky, isEmoji, isFrame, isGroup, getDescendantsOf, isRectangle, isEllipse, isBrush, isPolygon, sliceRuns, IMAGE_MASK_POLYGON_PRESETS, } from "@oh-just-another/scene";
|
|
5
|
+
import { FRAME_SIZE_PRESETS, LABEL_DEFAULT_FONT_SIZE, STICKY_SIZE_PRESETS, TEXT_DEFAULT_FONT_FAMILY, defaultActionRegistry, formatHotkey, } from "@oh-just-another/state";
|
|
6
|
+
import { useDiagramOptional, useReadOnly, useScene, useSelectedLink, useSelection, } from "../core/hooks.js";
|
|
7
|
+
import { useEditorSelector } from "../core/context.js";
|
|
8
|
+
import { useContextMenuController } from "../menus/context-menu-controller.js";
|
|
9
|
+
import { ColorSwatchPicker } from "../color/color-swatch-picker.js";
|
|
10
|
+
import { Popover } from "../primitives/popover.js";
|
|
11
|
+
import { SegmentedControl } from "../primitives/segmented-control.js";
|
|
12
|
+
import { Slider } from "../primitives/slider.js";
|
|
13
|
+
import { SEPARATOR, intersectControlSets, } from "./control-sets.js";
|
|
14
|
+
import { BRUSH_WIDTH_MAX, BRUSH_WIDTH_MIN, TEXT_FONT_SIZE_MAX, TEXT_FONT_SIZE_MIN, TEXT_FONT_SIZE_PRESETS, TEXT_FONT_STACKS, EMOJI_QUICK_PICKS, IMAGE_MASK_DEFAULT_RADIUS, CONTROL_ICON, } from "../core/constants.js";
|
|
15
|
+
export const PropertyPanel = ({ style, className, mobile = false }) => {
|
|
16
|
+
const selection = useSelection();
|
|
17
|
+
const selectedLinkId = useSelectedLink();
|
|
18
|
+
const scene = useScene();
|
|
19
|
+
const readOnly = useReadOnly();
|
|
20
|
+
// Every control here mutates the selection (style / text / z-order / align /
|
|
21
|
+
// convert / delete / link). In read-only / view mode the whole panel is
|
|
22
|
+
// suppressed — the canvas is view-only, so there's nothing to edit.
|
|
23
|
+
if (readOnly)
|
|
24
|
+
return null;
|
|
25
|
+
// Dispatcher: edge wins only when no shape is selected — if both
|
|
26
|
+
// happen to be set (rare), the shape panel is more useful. Each branch
|
|
27
|
+
// splits its controls into `primary` (always visible) and `overflow`
|
|
28
|
+
// (behind the ⋮ on mobile; inline on desktop).
|
|
29
|
+
if (selection.size > 0) {
|
|
30
|
+
const selected = [...selection]
|
|
31
|
+
.map((id) => scene.elements.get(id))
|
|
32
|
+
.filter((s) => s !== undefined);
|
|
33
|
+
if (selected.length === 0)
|
|
34
|
+
return null;
|
|
35
|
+
// A selected group stands for its leaf descendants (reference
|
|
36
|
+
// behaviour): the toolbar shows — and writes — what those children share;
|
|
37
|
+
// the group element itself carries no editable style.
|
|
38
|
+
const targets = expandGroups(scene, selected);
|
|
39
|
+
if (targets.length === 0)
|
|
40
|
+
return null;
|
|
41
|
+
const single = targets.length === 1;
|
|
42
|
+
// Per-element control sets, intersected by control id for ≥2 targets
|
|
43
|
+
// (see control-sets.ts): uniform selections keep their type's multi set,
|
|
44
|
+
// mixed ones keep only what every member offers.
|
|
45
|
+
const set = intersectControlSets(targets.map((s) => controlSetFor(s, single ? "single" : "multi")));
|
|
46
|
+
const primary = renderControlSet(set.primary, targets);
|
|
47
|
+
const overflow = renderControlSet(set.overflow, targets);
|
|
48
|
+
// Mixed types: a Filter that narrows the actual selection to one type
|
|
49
|
+
// bucket leads the row, so the per-type controls come back on demand.
|
|
50
|
+
if (!single && selectionBuckets(targets).size > 1) {
|
|
51
|
+
primary.unshift(_jsx(SelectionFilterControl, { shapes: targets }, "filter"));
|
|
52
|
+
}
|
|
53
|
+
// Shared tail: `arrange · group · ungroup | comment | lock | ⋯`.
|
|
54
|
+
// Arrange / group / ungroup gate themselves on the selection; comment is
|
|
55
|
+
// single-only; z-order, duplicate / delete and flip stay in the context
|
|
56
|
+
// menu (⋯).
|
|
57
|
+
overflow.push(_jsx(Divider, {}, "d-arrange"), _jsx(ArrangeControl, { targets: targets }, "arrange"), _jsx(GroupControls, { selected: selected }, "group"), _jsx(Divider, {}, "d-comment"));
|
|
58
|
+
if (single)
|
|
59
|
+
overflow.push(_jsx(CommentControl, { shapes: targets }, "comment"));
|
|
60
|
+
overflow.push(_jsx(Divider, {}, "d-lock"), _jsx(LockControl, {}, "lock"), _jsx(MoreButton, {}, "more"));
|
|
61
|
+
return (_jsx(PanelShell, { mobile: mobile, primary: primary, overflow: overflow, className: className, style: style }));
|
|
62
|
+
}
|
|
63
|
+
if (selectedLinkId !== null) {
|
|
64
|
+
const edge = scene.links.get(selectedLinkId);
|
|
65
|
+
if (!edge)
|
|
66
|
+
return null;
|
|
67
|
+
const primary = [
|
|
68
|
+
_jsx(LinkStrokeColorControl, { edge: edge }, "color"),
|
|
69
|
+
_jsx(LinkStrokeWidthControl, { edge: edge }, "width"),
|
|
70
|
+
_jsx(LinkArrowheadControl, { edge: edge, side: "to" }, "arrow-to"),
|
|
71
|
+
];
|
|
72
|
+
const overflow = [
|
|
73
|
+
_jsx(LinkStrokeStyleControl, { edge: edge }, "dash"),
|
|
74
|
+
_jsx(LinkRoutingControl, { edge: edge }, "routing"),
|
|
75
|
+
_jsx(LinkLineKindControl, { edge: edge }, "kind"),
|
|
76
|
+
_jsx(LinkArrowheadControl, { edge: edge, side: "from" }, "arrow-from"),
|
|
77
|
+
_jsx(LinkAutoRouteControl, { edge: edge }, "auto"),
|
|
78
|
+
_jsx(LinkDeleteControl, {}, "delete"),
|
|
79
|
+
_jsx(MoreButton, {}, "more"),
|
|
80
|
+
];
|
|
81
|
+
return (_jsx(PanelShell, { mobile: mobile, primary: primary, overflow: overflow, className: className, style: style }));
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
};
|
|
85
|
+
const control = (id, render) => ({ id, payload: render });
|
|
86
|
+
/**
|
|
87
|
+
* The text-carrying view of an element: text elements as they are, labelable
|
|
88
|
+
* shapes through their embedded label (`labelView`), so one control reads
|
|
89
|
+
* font / style / colour off a shape + text selection alike. Writes go through
|
|
90
|
+
* the label-aware `editor.updateTextStyle` / `updateTextProps`.
|
|
91
|
+
*/
|
|
92
|
+
const textView = (s) => (isText(s) ? s : labelView(s));
|
|
93
|
+
const textViews = (targets) => targets.map(textView);
|
|
94
|
+
/** Shared text-carrier cluster ids — identical for text and labelled shapes. */
|
|
95
|
+
const TEXT_CLUSTER = [
|
|
96
|
+
control("font-family", (t) => _jsx(FontFamilyControl, { shapes: textViews(t) })),
|
|
97
|
+
control("font-size", (t) => _jsx(FontSizeControl, { shapes: textViews(t) })),
|
|
98
|
+
control("text-decoration", (t) => _jsx(TextDecorationControl, { shapes: textViews(t) })),
|
|
99
|
+
control("text-align", (t) => _jsx(TextAlignControl, { shapes: textViews(t) })),
|
|
100
|
+
];
|
|
101
|
+
const TEXT_COLOR_CLUSTER = [
|
|
102
|
+
control("text-color", (t) => _jsx(ColorOpacityControl, { shapes: textViews(t) })),
|
|
103
|
+
control("highlight", (t) => _jsx(HighlightControl, { shapes: textViews(t) })),
|
|
104
|
+
];
|
|
105
|
+
const CONVERT = control("convert", (t) => _jsx(ConvertTypeControl, { shapes: t }));
|
|
106
|
+
const LINK = control("link", (t) => _jsx(LinkControl, { shapes: t }));
|
|
107
|
+
/**
|
|
108
|
+
* Ordered controls one element offers. `multi` is the single set minus what
|
|
109
|
+
* makes no sense for several elements: the hyperlink, the text list type,
|
|
110
|
+
* per-file image actions, the frame ratio. Sticky notes use their own
|
|
111
|
+
* font ids (auto-size font, note fonts) so a note + shape selection shares
|
|
112
|
+
* only the style / alignment controls, as in the reference.
|
|
113
|
+
*/
|
|
114
|
+
const controlSetFor = (s, mode) => {
|
|
115
|
+
const single = mode === "single";
|
|
116
|
+
const convert = isConvertible(s) ? [CONVERT, SEPARATOR] : [];
|
|
117
|
+
if (isSticky(s)) {
|
|
118
|
+
return {
|
|
119
|
+
primary: [
|
|
120
|
+
...convert,
|
|
121
|
+
control("sticky-font-family", (t) => _jsx(FontFamilyControl, { shapes: textViews(t) })),
|
|
122
|
+
control("sticky-font-size", (t) => _jsx(FontSizeControl, { shapes: textViews(t), allowAuto: true })),
|
|
123
|
+
...TEXT_CLUSTER.slice(2),
|
|
124
|
+
SEPARATOR,
|
|
125
|
+
control("sticky-size", (t) => _jsx(StickySizeControl, { shapes: t })),
|
|
126
|
+
control("sticky-bg", (t) => _jsx(FillOpacityControl, { shapes: t })),
|
|
127
|
+
],
|
|
128
|
+
overflow: [
|
|
129
|
+
control("sticky-tags", (t) => _jsx(StickyTagControl, { shapes: t })),
|
|
130
|
+
control("sticky-author", (t) => _jsx(StickyAuthorControl, { shapes: t })),
|
|
131
|
+
],
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
if (isEmoji(s)) {
|
|
135
|
+
return { primary: [control("emoji", (t) => _jsx(EmojiPickerControl, { shapes: t }))], overflow: [] };
|
|
136
|
+
}
|
|
137
|
+
if (isFrame(s)) {
|
|
138
|
+
// A frame's border is fixed chrome; only its body fill is configurable.
|
|
139
|
+
return {
|
|
140
|
+
primary: [
|
|
141
|
+
control("frame-fill", (t) => _jsx(FillControl, { shapes: t })),
|
|
142
|
+
...(single ? [control("frame-ratio", (t) => _jsx(FrameRatioControl, { shapes: t }))] : []),
|
|
143
|
+
],
|
|
144
|
+
overflow: single ? [LINK] : [],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
if (isText(s)) {
|
|
148
|
+
return {
|
|
149
|
+
primary: [
|
|
150
|
+
...convert,
|
|
151
|
+
...TEXT_CLUSTER,
|
|
152
|
+
...(single ? [control("list", (t) => _jsx(ListControl, { shapes: t }))] : []),
|
|
153
|
+
],
|
|
154
|
+
overflow: [...(single ? [LINK, SEPARATOR] : []), ...TEXT_COLOR_CLUSTER],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (isImage(s)) {
|
|
158
|
+
// Pixels are the content — no fill / stroke. Per-file actions are
|
|
159
|
+
// single-only; crop / mask / opacity apply in bulk.
|
|
160
|
+
return {
|
|
161
|
+
primary: single ? [control("image-name", (t) => _jsx(ImageNameControl, { shapes: t }))] : [],
|
|
162
|
+
overflow: [
|
|
163
|
+
...(single
|
|
164
|
+
? [
|
|
165
|
+
control("image-replace", (t) => _jsx(ReplaceImageControl, { shapes: t })),
|
|
166
|
+
control("image-download", (t) => _jsx(DownloadImageControl, { shapes: t })),
|
|
167
|
+
control("image-alt", (t) => _jsx(ImageAltControl, { shapes: t })),
|
|
168
|
+
SEPARATOR,
|
|
169
|
+
]
|
|
170
|
+
: []),
|
|
171
|
+
control("image-crop", (t) => _jsx(CropControl, { shapes: t })),
|
|
172
|
+
control("image-mask", (t) => _jsx(MaskControl, { shapes: t })),
|
|
173
|
+
control("image-opacity", (t) => _jsx(OpacityControl, { shapes: t })),
|
|
174
|
+
],
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (isGroup(s))
|
|
178
|
+
return { primary: [], overflow: [] };
|
|
179
|
+
const body = isBrush(s)
|
|
180
|
+
? [
|
|
181
|
+
// Brush widths are baked per point — `style.strokeWidth` has no
|
|
182
|
+
// effect, so brushes get a slider that re-bases the baked widths.
|
|
183
|
+
control("stroke", (t) => _jsx(StrokeControl, { shapes: t })),
|
|
184
|
+
control("brush-width", (t) => _jsx(BrushWidthControl, { shapes: t })),
|
|
185
|
+
]
|
|
186
|
+
: [
|
|
187
|
+
control("border", (t) => _jsx(BorderGroupControl, { shapes: t })),
|
|
188
|
+
control("fill", (t) => _jsx(FillOpacityControl, { shapes: t })),
|
|
189
|
+
];
|
|
190
|
+
return {
|
|
191
|
+
primary: [
|
|
192
|
+
...convert,
|
|
193
|
+
...(canCarryLabel(s) ? [...TEXT_CLUSTER, ...TEXT_COLOR_CLUSTER, SEPARATOR] : []),
|
|
194
|
+
...body,
|
|
195
|
+
],
|
|
196
|
+
overflow: single ? [LINK] : [],
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
const renderControlSet = (entries, targets) => entries.map((e, i) => e === SEPARATOR ? (_jsx(Divider, {}, `sep-${String(i)}`)) : (_jsx(Fragment, { children: e.payload(targets) }, e.id)));
|
|
200
|
+
/**
|
|
201
|
+
* Replace every selected group by its leaf descendants (nested groups
|
|
202
|
+
* flattened); plain elements pass through. Order and uniqueness preserved.
|
|
203
|
+
*/
|
|
204
|
+
const expandGroups = (scene, selected) => {
|
|
205
|
+
const out = [];
|
|
206
|
+
const seen = new Set();
|
|
207
|
+
const push = (s) => {
|
|
208
|
+
if (seen.has(s.id))
|
|
209
|
+
return;
|
|
210
|
+
seen.add(s.id);
|
|
211
|
+
out.push(s);
|
|
212
|
+
};
|
|
213
|
+
for (const s of selected) {
|
|
214
|
+
if (!isGroup(s)) {
|
|
215
|
+
push(s);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
for (const d of getDescendantsOf(scene, s.id))
|
|
219
|
+
if (!isGroup(d))
|
|
220
|
+
push(d);
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
};
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// Tail: arrange / group / ungroup (registry-driven)
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
const ARRANGE_ALIGN = [
|
|
228
|
+
{ id: "align-left", Icon: AlignStartVertical },
|
|
229
|
+
{ id: "align-h-center", Icon: AlignCenterVertical },
|
|
230
|
+
{ id: "align-right", Icon: AlignEndVertical },
|
|
231
|
+
{ id: "align-top", Icon: AlignStartHorizontal },
|
|
232
|
+
{ id: "align-v-center", Icon: AlignCenterHorizontal },
|
|
233
|
+
{ id: "align-bottom", Icon: AlignEndHorizontal },
|
|
234
|
+
];
|
|
235
|
+
const ARRANGE_DISTRIBUTE = [
|
|
236
|
+
{ id: "distribute-horizontal", Icon: AlignHorizontalDistributeCenter },
|
|
237
|
+
{ id: "distribute-vertical", Icon: AlignVerticalDistributeCenter },
|
|
238
|
+
];
|
|
239
|
+
/** One registry action as a toolbar / popover button (disabled by its predicate). */
|
|
240
|
+
const ActionIconButton = ({ id, Icon, }) => {
|
|
241
|
+
const editor = useDiagramOptional();
|
|
242
|
+
const action = defaultActionRegistry.get(id);
|
|
243
|
+
if (!editor || !action)
|
|
244
|
+
return null;
|
|
245
|
+
const enabled = action.predicate ? action.predicate({ editor }) : true;
|
|
246
|
+
const matchers = action.hotkey === undefined
|
|
247
|
+
? []
|
|
248
|
+
: Array.isArray(action.hotkey)
|
|
249
|
+
? action.hotkey
|
|
250
|
+
: [action.hotkey];
|
|
251
|
+
const first = matchers[0];
|
|
252
|
+
const label = action.label ?? id;
|
|
253
|
+
return (_jsx("button", { type: "button", className: "du-sel-icon-button", "aria-label": label, title: first ? `${label} (${formatHotkey(first)})` : label, disabled: !enabled, onClick: () => {
|
|
254
|
+
defaultActionRegistry.dispatch(id, { editor });
|
|
255
|
+
}, children: _jsx(Icon, { ...CONTROL_ICON, "aria-hidden": true }) }));
|
|
256
|
+
};
|
|
257
|
+
/**
|
|
258
|
+
* Arrange popover for ≥2 targets: align (left / centre / right, top /
|
|
259
|
+
* middle / bottom) and distribute (horizontally / vertically, from 3). The
|
|
260
|
+
* buttons follow the registry predicates, so a single whole group — two
|
|
261
|
+
* children but one selected id — shows them disabled, as in the reference.
|
|
262
|
+
*/
|
|
263
|
+
const ArrangeControl = ({ targets }) => {
|
|
264
|
+
if (targets.length < 2)
|
|
265
|
+
return null;
|
|
266
|
+
return (_jsx(Popover, { ariaLabel: "Arrange", trigger: _jsx("button", { type: "button", className: "du-sel-icon-button", "aria-label": "Arrange", title: "Arrange", children: _jsx(LayoutGrid, { ...CONTROL_ICON, "aria-hidden": true }) }), children: _jsxs("div", { className: "du-sel-popover-section", children: [_jsx("header", { className: "du-sel-popover-label", children: "Align" }), _jsx("div", { className: "du-sel-arrange-row", children: ARRANGE_ALIGN.map((a) => (_jsx(ActionIconButton, { id: a.id, Icon: a.Icon }, a.id))) }), _jsx("header", { className: "du-sel-popover-label", children: "Distribute" }), _jsx("div", { className: "du-sel-arrange-row", children: ARRANGE_DISTRIBUTE.map((a) => (_jsx(ActionIconButton, { id: a.id, Icon: a.Icon }, a.id))) })] }) }));
|
|
267
|
+
};
|
|
268
|
+
/**
|
|
269
|
+
* Group / Ungroup buttons. Group needs ≥2 selected ids; Ungroup needs a
|
|
270
|
+
* group among them (both show for a group + something else). Neither shows
|
|
271
|
+
* while editing inside a group (double-clicked in): the children are being
|
|
272
|
+
* edited in place, not regrouped.
|
|
273
|
+
*/
|
|
274
|
+
const GroupControls = ({ selected }) => {
|
|
275
|
+
const enteredGroup = useEditorSelector((e) => e.enteredGroup, null);
|
|
276
|
+
if (enteredGroup !== null)
|
|
277
|
+
return null;
|
|
278
|
+
const hasGroup = selected.some((s) => isGroup(s));
|
|
279
|
+
return (_jsxs(_Fragment, { children: [selected.length >= 2 ? _jsx(ActionIconButton, { id: "group-selection", Icon: GroupIcon }) : null, hasGroup ? _jsx(ActionIconButton, { id: "ungroup-selection", Icon: UngroupIcon }) : null] }));
|
|
280
|
+
};
|
|
281
|
+
const PanelShell = ({ mobile, primary, overflow, className, style, }) => {
|
|
282
|
+
const [expanded, setExpanded] = useState(false);
|
|
283
|
+
const panelRef = useRef(null);
|
|
284
|
+
// Collapse the expanded overflow sheet on a tap outside the panel.
|
|
285
|
+
// The ⋮ button and the grabber collapse it directly; this covers taps
|
|
286
|
+
// on the canvas / elsewhere.
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
if (!mobile || !expanded)
|
|
289
|
+
return undefined;
|
|
290
|
+
const onDown = (e) => {
|
|
291
|
+
const el = panelRef.current;
|
|
292
|
+
if (el && e.target instanceof Node && !el.contains(e.target))
|
|
293
|
+
setExpanded(false);
|
|
294
|
+
};
|
|
295
|
+
// `capture` so we see the tap even if something stops propagation.
|
|
296
|
+
document.addEventListener("pointerdown", onDown, true);
|
|
297
|
+
return () => {
|
|
298
|
+
document.removeEventListener("pointerdown", onDown, true);
|
|
299
|
+
};
|
|
300
|
+
}, [mobile, expanded]);
|
|
301
|
+
if (!mobile) {
|
|
302
|
+
return (_jsx("div", { className: `du-sel-panel ${className ?? ""}`.trim(), style: style, children: groupControls([...primary, _jsx(Divider, {}, "d-overflow"), ...overflow]) }));
|
|
303
|
+
}
|
|
304
|
+
return (_jsxs("div", { ref: panelRef, className: `du-sel-panel du-sel-panel-mobile ${className ?? ""}`.trim(), style: style, children: [_jsxs("div", { className: "du-sel-mobile-row", children: [_jsx("div", { className: "du-sel-mobile-primary", children: groupControls(primary) }), overflow.length > 0 ? (_jsx("button", { type: "button", className: `du-sel-icon-button du-sel-mobile-expand${expanded ? " is-active" : ""}`, "aria-expanded": expanded, "aria-label": expanded ? "Hide more properties" : "More properties", title: "More properties", onClick: () => {
|
|
305
|
+
setExpanded((v) => !v);
|
|
306
|
+
}, children: _jsx(MoreVertical, { ...CONTROL_ICON, "aria-hidden": true }) })) : null] }), expanded && overflow.length > 0 ? (_jsx("div", { className: "du-sel-mobile-overflow", children: groupControls(overflow) })) : null] }));
|
|
307
|
+
};
|
|
308
|
+
/**
|
|
309
|
+
* Element link control. One trigger (chain icon, active when a link is
|
|
310
|
+
* set) opens a popover with a URL field plus Save / Open / Remove. Works
|
|
311
|
+
* for any shape — the href lives on `ElementBase`. The URL is normalised
|
|
312
|
+
* (`normalizeHref`: adds `https://`, `mailto:`, rejects `javascript:`)
|
|
313
|
+
* before storing. Multi-select applies to all.
|
|
314
|
+
*/
|
|
315
|
+
const LinkControl = ({ shapes }) => {
|
|
316
|
+
const editor = useDiagramOptional();
|
|
317
|
+
const inputRef = useRef(null);
|
|
318
|
+
if (!editor)
|
|
319
|
+
return null;
|
|
320
|
+
const ids = shapes.map((s) => s.id);
|
|
321
|
+
const current = sharedString(shapes, (s) => s.href);
|
|
322
|
+
const hasLink = shapes.some((s) => Boolean(s.href));
|
|
323
|
+
const save = (raw) => {
|
|
324
|
+
editor.setLink(ids, raw);
|
|
325
|
+
};
|
|
326
|
+
return (_jsx(Popover, { ariaLabel: "Link", trigger: _jsx("button", { type: "button", className: `du-sel-icon-button${hasLink ? " is-active" : ""}`, title: "Link", "aria-label": "Link", children: _jsx(LinkIcon, { ...CONTROL_ICON, "aria-hidden": true }) }), children: _jsxs("div", { className: "du-sel-popover-section", children: [_jsx("header", { className: "du-sel-popover-label", children: "Link" }), _jsx("input", { ref: inputRef, className: "du-sel-link-input", type: "text", placeholder: "https://\u2026 \u00B7 name@mail", defaultValue: current ?? "", "aria-label": "Link URL", onKeyDown: (ev) => {
|
|
327
|
+
if (ev.key === "Enter") {
|
|
328
|
+
ev.preventDefault();
|
|
329
|
+
save(ev.currentTarget.value);
|
|
330
|
+
}
|
|
331
|
+
} }), _jsxs("div", { style: { display: "flex", gap: 2 }, children: [_jsx("button", { type: "button", className: "du-sel-text-button", onClick: () => {
|
|
332
|
+
save(inputRef.current?.value ?? "");
|
|
333
|
+
}, children: "Save" }), hasLink ? (_jsx("button", { type: "button", className: "du-sel-text-button", onClick: () => {
|
|
334
|
+
editor.openLink(current);
|
|
335
|
+
}, children: "Open" })) : null, hasLink ? (_jsx("button", { type: "button", className: "du-sel-text-button", onClick: () => {
|
|
336
|
+
editor.setLink(ids, null);
|
|
337
|
+
}, children: "Remove" })) : null] })] }) }));
|
|
338
|
+
};
|
|
339
|
+
/**
|
|
340
|
+
* Start a comment thread on the selected element: adds an annotation pin
|
|
341
|
+
* anchored to the (first) selected shape and selects it, which opens the
|
|
342
|
+
* comment thread UI. Same backing model as the context menu's
|
|
343
|
+
* "Add comment" — this is just the toolbar affordance for it.
|
|
344
|
+
*/
|
|
345
|
+
const CommentControl = ({ shapes }) => {
|
|
346
|
+
const editor = useDiagramOptional();
|
|
347
|
+
const first = shapes[0];
|
|
348
|
+
if (!editor || !first)
|
|
349
|
+
return null;
|
|
350
|
+
return (_jsx("button", { type: "button", className: "du-sel-icon-button", title: "Add comment", "aria-label": "Add comment", onClick: () => {
|
|
351
|
+
editor.addAnnotation({ position: { x: 0, y: 0 }, elementId: first.id });
|
|
352
|
+
}, children: _jsx(MessageCircle, { ...CONTROL_ICON, "aria-hidden": true }) }));
|
|
353
|
+
};
|
|
354
|
+
/**
|
|
355
|
+
* Lock the current selection. Locked shapes become click-through (the
|
|
356
|
+
* hit-test skips them), so locking also drops the selection and hides
|
|
357
|
+
* this panel; unlocking goes through the right-click context menu.
|
|
358
|
+
*/
|
|
359
|
+
const LockControl = () => {
|
|
360
|
+
const editor = useDiagramOptional();
|
|
361
|
+
if (!editor)
|
|
362
|
+
return null;
|
|
363
|
+
return (_jsx("button", { type: "button", className: "du-sel-icon-button", title: "Lock", "aria-label": "Lock selection", onClick: () => {
|
|
364
|
+
editor.toggleLockSelection();
|
|
365
|
+
}, children: _jsx(LockIcon, { ...CONTROL_ICON, "aria-hidden": true }) }));
|
|
366
|
+
};
|
|
367
|
+
const MoreButton = () => {
|
|
368
|
+
const editor = useDiagramOptional();
|
|
369
|
+
const controller = useContextMenuController();
|
|
370
|
+
if (!editor || !controller)
|
|
371
|
+
return null;
|
|
372
|
+
return (_jsx("button", { type: "button", className: "du-sel-icon-button", title: "More actions", "aria-label": "More actions", onClick: (ev) => {
|
|
373
|
+
const rect = ev.currentTarget.getBoundingClientRect();
|
|
374
|
+
const screenPoint = { x: rect.left, y: rect.bottom + 4 };
|
|
375
|
+
const host = editor.hostElement;
|
|
376
|
+
const hostRect = host?.getBoundingClientRect();
|
|
377
|
+
const worldPoint = hostRect
|
|
378
|
+
? editor.screenToWorld({
|
|
379
|
+
x: screenPoint.x - hostRect.left,
|
|
380
|
+
y: screenPoint.y - hostRect.top,
|
|
381
|
+
})
|
|
382
|
+
: { x: 0, y: 0 };
|
|
383
|
+
controller.open({ screenPoint, worldPoint });
|
|
384
|
+
}, children: _jsx(MoreHorizontal, { ...CONTROL_ICON, "aria-hidden": true }) }));
|
|
385
|
+
};
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
// Inline controls
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
/**
|
|
390
|
+
* Color trigger: a 24×24 square button with a colored fill plus an
|
|
391
|
+
* outer ring, opens the full swatch picker in a popover.
|
|
392
|
+
*/
|
|
393
|
+
const ColorTrigger = ({ label, color, onChange, ariaLabel, onEyedrop, }) => (_jsx(Popover, { ariaLabel: ariaLabel, trigger: _jsx("button", { type: "button", className: "du-sel-color-trigger", title: label, "aria-label": ariaLabel, children: _jsx("span", { className: "du-sel-color-swatch", style: {
|
|
394
|
+
background: !color || color === "transparent"
|
|
395
|
+
? "repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 50% / 6px 6px"
|
|
396
|
+
: color,
|
|
397
|
+
} }) }), children: _jsxs("div", { className: "du-sel-popover-section", children: [_jsx("header", { className: "du-sel-popover-label", children: label }), _jsx(ColorSwatchPicker, { value: color, onChange: onChange, ...(onEyedrop ? { onEyedrop } : {}) })] }) }));
|
|
398
|
+
const useTextRunRange = (shapes) => {
|
|
399
|
+
const editingId = useEditorSelector((e) => e.editingTextElement, null);
|
|
400
|
+
const sel = useEditorSelector((e) => e.editingTextSelection, null);
|
|
401
|
+
if (editingId === null || sel === null || sel.start === sel.end)
|
|
402
|
+
return null;
|
|
403
|
+
const target = shapes.find((s) => s.id === editingId);
|
|
404
|
+
if (target === undefined)
|
|
405
|
+
return null;
|
|
406
|
+
if (!isText(target) && !("text" in target && target.label !== undefined))
|
|
407
|
+
return null;
|
|
408
|
+
return {
|
|
409
|
+
target: target,
|
|
410
|
+
from: Math.min(sel.start, sel.end),
|
|
411
|
+
to: Math.max(sel.start, sel.end),
|
|
412
|
+
};
|
|
413
|
+
};
|
|
414
|
+
/**
|
|
415
|
+
* Combined color & opacity control — a single swatch trigger whose
|
|
416
|
+
* popover has both the palette and an opacity slider. Used for the text
|
|
417
|
+
* panel in place of separate Fill + Opacity triggers. Writes `fill` and
|
|
418
|
+
* `opacity` through `editor.updateTextStyle` (element style for text,
|
|
419
|
+
* label style for labelable shapes).
|
|
420
|
+
*/
|
|
421
|
+
const ColorOpacityControl = ({ shapes }) => {
|
|
422
|
+
const editor = useDiagramOptional();
|
|
423
|
+
const runRange = useTextRunRange(shapes);
|
|
424
|
+
if (!editor)
|
|
425
|
+
return null;
|
|
426
|
+
const ids = shapes.map((s) => s.id);
|
|
427
|
+
const color = sharedString(shapes, (s) => s.style.fill);
|
|
428
|
+
const opacity = sharedValue(shapes, (s) => s.style.opacity ?? 1);
|
|
429
|
+
const pct = opacity === null ? null : Math.round(opacity * 100);
|
|
430
|
+
const swatchBg = !color || color === "transparent"
|
|
431
|
+
? "repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 50% / 6px 6px"
|
|
432
|
+
: color;
|
|
433
|
+
return (_jsx(Popover, { ariaLabel: "Text color and opacity", trigger: _jsx("button", { type: "button", className: "du-sel-color-trigger", title: "Text color & opacity", "aria-label": "Text color and opacity", children: _jsx("span", { className: "du-sel-color-swatch", style: { background: swatchBg, opacity: opacity ?? 1 } }) }), children: _jsxs("div", { className: "du-sel-popover-section", children: [_jsx("header", { className: "du-sel-popover-label", children: "Text color" }), _jsx(ColorSwatchPicker, { value: color, onChange: (v) => {
|
|
434
|
+
// In-edit text selection → colour just those characters (runs);
|
|
435
|
+
// otherwise colour the whole element(s) / label(s).
|
|
436
|
+
if (runRange) {
|
|
437
|
+
editor.applyTextStyleToRange(runRange.target.id, runRange.from, runRange.to, {
|
|
438
|
+
fill: v ?? "transparent",
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
editor.updateTextStyle(ids, { fill: v ?? "transparent" });
|
|
443
|
+
}
|
|
444
|
+
}, onEyedrop: (cb) => {
|
|
445
|
+
editor.beginEyedropperPick(cb);
|
|
446
|
+
} }), _jsx("header", { className: "du-sel-popover-label", children: "Opacity" }), _jsx(Slider, { value: pct, min: 0, max: 100, step: 5, ariaLabel: "Opacity", valueLabel: pct === null ? "—" : `${pct}%`, onChange: (v) => {
|
|
447
|
+
editor.updateTextStyle(ids, { opacity: v / 100 });
|
|
448
|
+
} })] }) }));
|
|
449
|
+
};
|
|
450
|
+
/** Sticky size presets (S / M / L squares). */
|
|
451
|
+
const StickySizeControl = ({ shapes }) => {
|
|
452
|
+
const editor = useDiagramOptional();
|
|
453
|
+
if (!editor)
|
|
454
|
+
return null;
|
|
455
|
+
const ids = shapes.map((s) => s.id);
|
|
456
|
+
const side = sharedValue(shapes, (s) => s.width);
|
|
457
|
+
return (_jsx(SegmentedControl, { ariaLabel: "Sticky size", value: side, options: STICKY_SIZE_PRESETS.map((p) => ({
|
|
458
|
+
value: p.side,
|
|
459
|
+
label: p.id.toUpperCase(),
|
|
460
|
+
icon: _jsx("span", { className: "du-sel-size-letter", children: p.id.toUpperCase() }),
|
|
461
|
+
})), onChange: (v) => {
|
|
462
|
+
editor.setStickySize(ids, v);
|
|
463
|
+
} }));
|
|
464
|
+
};
|
|
465
|
+
/** Tag editor for sticky notes: pills with remove + an input that adds on Enter. */
|
|
466
|
+
const StickyTagControl = ({ shapes }) => {
|
|
467
|
+
const editor = useDiagramOptional();
|
|
468
|
+
const first = shapes[0];
|
|
469
|
+
if (!editor || !first)
|
|
470
|
+
return null;
|
|
471
|
+
const tags = first.tags ?? [];
|
|
472
|
+
const ids = shapes.map((s) => s.id);
|
|
473
|
+
return (_jsx(Popover, { ariaLabel: "Tags", trigger: _jsx("button", { type: "button", className: "du-sel-icon-button", title: "Add tag", "aria-label": "Add tag", children: _jsx(Tag, { ...CONTROL_ICON, "aria-hidden": true }) }), children: _jsxs("div", { className: "du-sel-popover-section", children: [_jsx("header", { className: "du-sel-popover-label", children: "Tags" }), tags.length > 0 ? (_jsx("div", { className: "du-sel-tag-list", children: tags.map((tag) => (_jsxs("span", { className: "du-sel-tag-pill", children: [tag, _jsx("button", { type: "button", className: "du-sel-tag-remove", "aria-label": `Remove tag ${tag}`, onClick: () => {
|
|
474
|
+
editor.setStickyTags(ids, tags.filter((t) => t !== tag));
|
|
475
|
+
}, children: "\u00D7" })] }, tag))) })) : null, _jsx("input", { type: "text", className: "du-sel-name-input", placeholder: "Add tag\u2026", "aria-label": "New tag", onKeyDown: (ev) => {
|
|
476
|
+
if (ev.key !== "Enter")
|
|
477
|
+
return;
|
|
478
|
+
ev.preventDefault();
|
|
479
|
+
const value = ev.currentTarget.value.trim();
|
|
480
|
+
if (value === "" || tags.includes(value))
|
|
481
|
+
return;
|
|
482
|
+
editor.setStickyTags(ids, [...tags, value]);
|
|
483
|
+
ev.currentTarget.value = "";
|
|
484
|
+
} })] }) }));
|
|
485
|
+
};
|
|
486
|
+
/** Toggle the author-name strip along the sticky's bottom edge. */
|
|
487
|
+
const StickyAuthorControl = ({ shapes }) => {
|
|
488
|
+
const editor = useDiagramOptional();
|
|
489
|
+
if (!editor)
|
|
490
|
+
return null;
|
|
491
|
+
const ids = shapes.map((s) => s.id);
|
|
492
|
+
const on = shapes.every((s) => s.showAuthor === true);
|
|
493
|
+
return (_jsx("button", { type: "button", className: `du-sel-icon-button${on ? " is-active" : ""}`, title: "Show author", "aria-label": "Show author", "aria-pressed": on, onClick: () => {
|
|
494
|
+
editor.toggleStickyAuthor(ids);
|
|
495
|
+
}, children: _jsx(UserRound, { ...CONTROL_ICON, "aria-hidden": true }) }));
|
|
496
|
+
};
|
|
497
|
+
/** Emoji picker: replace the element's glyph with one of the presets. */
|
|
498
|
+
const EmojiPickerControl = ({ shapes }) => {
|
|
499
|
+
const editor = useDiagramOptional();
|
|
500
|
+
const first = shapes[0];
|
|
501
|
+
if (!editor || !first)
|
|
502
|
+
return null;
|
|
503
|
+
const current = first.glyph;
|
|
504
|
+
return (_jsx(Popover, { ariaLabel: "Choose emoji", trigger: _jsx("button", { type: "button", className: "du-sel-text-button", title: "Choose emoji", "aria-label": "Choose emoji", children: current }), children: _jsx("div", { className: "du-sel-emoji-grid", children: EMOJI_QUICK_PICKS.map((glyph) => (_jsx("button", { type: "button", className: "du-sel-emoji-item", "aria-label": `Emoji ${glyph}`, onClick: () => {
|
|
505
|
+
editor.setEmojiGlyph(shapes.map((s) => s.id), glyph);
|
|
506
|
+
}, children: glyph }, glyph))) }) }));
|
|
507
|
+
};
|
|
508
|
+
/**
|
|
509
|
+
* Grouped border editor: stroke color, width, dash style and corner
|
|
510
|
+
* rounding in ONE popover ("border style, opacity, corners and color").
|
|
511
|
+
* Every write goes through `editor.updateStyle` on the whole selection.
|
|
512
|
+
*/
|
|
513
|
+
const BorderGroupControl = ({ shapes }) => {
|
|
514
|
+
const editor = useDiagramOptional();
|
|
515
|
+
if (!editor)
|
|
516
|
+
return null;
|
|
517
|
+
const ids = shapes.map((s) => s.id);
|
|
518
|
+
const stroke = sharedString(shapes, (s) => s.style.stroke);
|
|
519
|
+
const width = sharedValue(shapes, (s) => s.style.strokeWidth ?? 1);
|
|
520
|
+
const dash = sharedValue(shapes, (s) => {
|
|
521
|
+
const d = s.style.dashArray;
|
|
522
|
+
if (!d || d.length === 0)
|
|
523
|
+
return "solid";
|
|
524
|
+
return (d[0] ?? 0) <= 2 ? "dotted" : "dashed";
|
|
525
|
+
});
|
|
526
|
+
const round = sharedValue(shapes, (s) => s.style.roundness?.type ?? "sharp");
|
|
527
|
+
const roundable = shapes.some((s) => isRectangle(s));
|
|
528
|
+
return (_jsx(Popover, { ariaLabel: "Border style", trigger: _jsx("button", { type: "button", className: "du-sel-color-trigger", title: "Border style, corners and color", "aria-label": "Border style, corners and color", children: _jsx("span", { className: "du-sel-color-swatch du-sel-swatch-ring", style: { borderColor: stroke ?? "var(--menu-border)" } }) }), children: _jsxs("div", { className: "du-sel-popover-section", children: [_jsx("header", { className: "du-sel-popover-label", children: "Border color" }), _jsx(ColorSwatchPicker, { value: stroke, onChange: (v) => {
|
|
529
|
+
editor.updateStyle(ids, { stroke: v ?? "transparent" });
|
|
530
|
+
}, onEyedrop: (cb) => {
|
|
531
|
+
editor.beginEyedropperPick(cb);
|
|
532
|
+
} }), _jsx("header", { className: "du-sel-popover-label", children: "Width" }), _jsx(SegmentedControl, { ariaLabel: "Border width", value: width, options: [
|
|
533
|
+
{ value: 1, label: "Thin", icon: _jsx(StrokeWidthIcon, { thickness: 1 }) },
|
|
534
|
+
{ value: 2, label: "Medium", icon: _jsx(StrokeWidthIcon, { thickness: 2.5 }) },
|
|
535
|
+
{ value: 4, label: "Thick", icon: _jsx(StrokeWidthIcon, { thickness: 4 }) },
|
|
536
|
+
], onChange: (v) => {
|
|
537
|
+
editor.updateStyle(ids, { strokeWidth: v });
|
|
538
|
+
} }), _jsx("header", { className: "du-sel-popover-label", children: "Style" }), _jsx(SegmentedControl, { ariaLabel: "Border dash style", value: dash, options: [
|
|
539
|
+
{ value: "solid", label: "Solid", icon: _jsx(Square, { ...CONTROL_ICON }) },
|
|
540
|
+
{
|
|
541
|
+
value: "dashed",
|
|
542
|
+
label: "Dashed",
|
|
543
|
+
icon: _jsx(SquareDashed, { ...CONTROL_ICON }),
|
|
544
|
+
},
|
|
545
|
+
{ value: "dotted", label: "Dotted", icon: _jsx(SquareDot, { ...CONTROL_ICON }) },
|
|
546
|
+
], onChange: (v) => {
|
|
547
|
+
editor.updateStyle(ids, {
|
|
548
|
+
dashArray: v === "solid" ? [] : v === "dashed" ? [8, 4] : [2, 4],
|
|
549
|
+
});
|
|
550
|
+
} }), roundable ? (_jsxs(_Fragment, { children: [_jsx("header", { className: "du-sel-popover-label", children: "Corners" }), _jsx(SegmentedControl, { ariaLabel: "Corner rounding", value: round, options: [
|
|
551
|
+
{ value: "sharp", label: "Sharp", icon: _jsx(CornerIcon, { kind: "sharp" }) },
|
|
552
|
+
{ value: "round", label: "Round", icon: _jsx(CornerIcon, { kind: "round" }) },
|
|
553
|
+
], onChange: (v) => {
|
|
554
|
+
editor.updateStyle(ids, { roundness: { type: v } });
|
|
555
|
+
} })] })) : null] }) }));
|
|
556
|
+
};
|
|
557
|
+
/**
|
|
558
|
+
* Grouped fill editor: fill color + opacity in one popover
|
|
559
|
+
* ("set color and opacity").
|
|
560
|
+
*/
|
|
561
|
+
const FillOpacityControl = ({ shapes }) => {
|
|
562
|
+
const editor = useDiagramOptional();
|
|
563
|
+
if (!editor || !shapes.some(hasFill))
|
|
564
|
+
return null;
|
|
565
|
+
const ids = shapes.map((s) => s.id);
|
|
566
|
+
const fill = sharedString(shapes, (s) => s.style.fill);
|
|
567
|
+
const opacity = sharedValue(shapes, (s) => s.style.opacity ?? 1);
|
|
568
|
+
const pct = opacity === null ? null : Math.round(opacity * 100);
|
|
569
|
+
const swatchBg = !fill || fill === "transparent"
|
|
570
|
+
? "repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 50% / 6px 6px"
|
|
571
|
+
: fill;
|
|
572
|
+
return (_jsx(Popover, { ariaLabel: "Fill color and opacity", trigger: _jsx("button", { type: "button", className: "du-sel-color-trigger", title: "Fill color & opacity", "aria-label": "Fill color and opacity", children: _jsx("span", { className: "du-sel-color-swatch", style: { background: swatchBg, opacity: opacity ?? 1 } }) }), children: _jsxs("div", { className: "du-sel-popover-section", children: [_jsx("header", { className: "du-sel-popover-label", children: "Fill" }), _jsx(ColorSwatchPicker, { value: fill, onChange: (v) => {
|
|
573
|
+
editor.updateStyle(ids, { fill: v ?? "transparent" });
|
|
574
|
+
}, onEyedrop: (cb) => {
|
|
575
|
+
editor.beginEyedropperPick(cb);
|
|
576
|
+
} }), _jsx("header", { className: "du-sel-popover-label", children: "Opacity" }), _jsx(Slider, { value: pct, min: 0, max: 100, step: 5, ariaLabel: "Fill opacity", valueLabel: pct === null ? "—" : `${String(pct)}%`, onChange: (v) => {
|
|
577
|
+
editor.updateStyle(ids, { opacity: v / 100 });
|
|
578
|
+
} })] }) }));
|
|
579
|
+
};
|
|
580
|
+
/**
|
|
581
|
+
* Frame size presets (paper / screen ratios) as a dropdown. Applying one
|
|
582
|
+
* resizes the frame to the canonical size; free resize simply diverges
|
|
583
|
+
* (Custom is implicit — nothing is stored on the element).
|
|
584
|
+
*/
|
|
585
|
+
const FrameRatioControl = ({ shapes }) => {
|
|
586
|
+
const editor = useDiagramOptional();
|
|
587
|
+
const first = shapes[0];
|
|
588
|
+
if (!editor || !first)
|
|
589
|
+
return null;
|
|
590
|
+
return (_jsx(Popover, { ariaLabel: "Frame size", trigger: _jsx("button", { type: "button", className: "du-sel-icon-button", title: "Frame size", "aria-label": "Frame size", children: _jsx(Proportions, { ...CONTROL_ICON, "aria-hidden": true }) }), children: _jsxs("div", { className: "du-sel-popover-section", children: [_jsx("header", { className: "du-sel-popover-label", children: "Frame size" }), _jsx("div", { className: "du-sel-preset-list", children: FRAME_SIZE_PRESETS.map((preset) => (_jsx("button", { type: "button", className: "du-sel-preset-item", onClick: () => {
|
|
591
|
+
editor.applyFramePreset(first.id, preset);
|
|
592
|
+
}, children: preset.label }, preset.id))) })] }) }));
|
|
593
|
+
};
|
|
594
|
+
/** The backing BinaryFile of a single-image selection, if any. */
|
|
595
|
+
const backingFile = (editor, shapes) => {
|
|
596
|
+
const first = shapes[0];
|
|
597
|
+
if (!first || !isImage(first) || first.fileId === undefined)
|
|
598
|
+
return null;
|
|
599
|
+
const file = getBinaryFile(editor.scene, first.fileId);
|
|
600
|
+
return file ? { elementId: first.id, fileId: first.fileId, file } : null;
|
|
601
|
+
};
|
|
602
|
+
/**
|
|
603
|
+
* File-name input for a single selected image backed by a registered
|
|
604
|
+
* binary file. Commits on Enter / blur through the editor (undoable);
|
|
605
|
+
* images without a file registry entry (raw `src`) hide the control.
|
|
606
|
+
*/
|
|
607
|
+
const ImageNameControl = ({ shapes }) => {
|
|
608
|
+
const editor = useDiagramOptional();
|
|
609
|
+
if (!editor)
|
|
610
|
+
return null;
|
|
611
|
+
const backing = backingFile(editor, shapes);
|
|
612
|
+
if (!backing)
|
|
613
|
+
return null;
|
|
614
|
+
const commit = (value) => {
|
|
615
|
+
const trimmed = value.trim();
|
|
616
|
+
if (trimmed.length > 0)
|
|
617
|
+
editor.renameBinaryFile(backing.fileId, trimmed);
|
|
618
|
+
};
|
|
619
|
+
return (_jsx("input", { type: "text", className: "du-sel-name-input", defaultValue: backing.file.name ?? "", placeholder: "File name", "aria-label": "File name", onBlur: (ev) => {
|
|
620
|
+
commit(ev.currentTarget.value);
|
|
621
|
+
}, onKeyDown: (ev) => {
|
|
622
|
+
if (ev.key === "Enter") {
|
|
623
|
+
ev.preventDefault();
|
|
624
|
+
commit(ev.currentTarget.value);
|
|
625
|
+
ev.currentTarget.blur();
|
|
626
|
+
}
|
|
627
|
+
} }, backing.file.name ?? backing.fileId));
|
|
628
|
+
};
|
|
629
|
+
/**
|
|
630
|
+
* Pick a new media file (image / GIF / video) and swap it under the
|
|
631
|
+
* selected shape. Position and width are kept (height refits to the new
|
|
632
|
+
* aspect); the crop resets when the media kind changes.
|
|
633
|
+
*/
|
|
634
|
+
const ReplaceImageControl = ({ shapes }) => {
|
|
635
|
+
const editor = useDiagramOptional();
|
|
636
|
+
const inputRef = useRef(null);
|
|
637
|
+
const first = shapes[0];
|
|
638
|
+
if (!editor || !first || !isImage(first))
|
|
639
|
+
return null;
|
|
640
|
+
return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "du-sel-icon-button", title: "Replace media", "aria-label": "Replace media", onClick: () => {
|
|
641
|
+
inputRef.current?.click();
|
|
642
|
+
}, children: _jsx(Upload, { ...CONTROL_ICON, "aria-hidden": true }) }), _jsx("input", { ref: inputRef, type: "file", accept: "image/*,video/*,.gif,.mp4,.webm,.mov", style: { display: "none" }, onChange: (ev) => {
|
|
643
|
+
const file = ev.currentTarget.files?.[0];
|
|
644
|
+
ev.currentTarget.value = "";
|
|
645
|
+
if (file)
|
|
646
|
+
void editor.replaceImageFile(first.id, file, file.name);
|
|
647
|
+
} })] }));
|
|
648
|
+
};
|
|
649
|
+
/** Download the image's original bytes with their stored name / mime. */
|
|
650
|
+
const DownloadImageControl = ({ shapes }) => {
|
|
651
|
+
const editor = useDiagramOptional();
|
|
652
|
+
if (!editor)
|
|
653
|
+
return null;
|
|
654
|
+
const backing = backingFile(editor, shapes);
|
|
655
|
+
if (!backing)
|
|
656
|
+
return null;
|
|
657
|
+
return (_jsx("button", { type: "button", className: "du-sel-icon-button", title: "Download image", "aria-label": "Download image", onClick: () => {
|
|
658
|
+
const blob = new Blob([backing.file.data], { type: backing.file.mime });
|
|
659
|
+
const url = URL.createObjectURL(blob);
|
|
660
|
+
const a = document.createElement("a");
|
|
661
|
+
a.href = url;
|
|
662
|
+
a.download = backing.file.name ?? "image";
|
|
663
|
+
document.body.appendChild(a);
|
|
664
|
+
a.click();
|
|
665
|
+
document.body.removeChild(a);
|
|
666
|
+
URL.revokeObjectURL(url);
|
|
667
|
+
}, children: _jsx(Download, { ...CONTROL_ICON, "aria-hidden": true }) }));
|
|
668
|
+
};
|
|
669
|
+
/** Alt-text editor (accessible description) for the selected image. */
|
|
670
|
+
const ImageAltControl = ({ shapes }) => {
|
|
671
|
+
const editor = useDiagramOptional();
|
|
672
|
+
const first = shapes[0];
|
|
673
|
+
if (!editor || !first || !isImage(first))
|
|
674
|
+
return null;
|
|
675
|
+
const current = first.alt ?? "";
|
|
676
|
+
return (_jsx(Popover, { ariaLabel: "Alt text", trigger: _jsx("button", { type: "button", className: "du-sel-icon-button", title: "Alt text", "aria-label": "Alt text", children: _jsx(FileText, { ...CONTROL_ICON, "aria-hidden": true }) }), children: _jsxs("div", { className: "du-sel-popover-section", children: [_jsx("header", { className: "du-sel-popover-label", children: "Alt text" }), _jsx("textarea", { className: "du-sel-alt-input", defaultValue: current, rows: 3, placeholder: "Describe this image", "aria-label": "Alt text", onBlur: (ev) => {
|
|
677
|
+
const value = ev.currentTarget.value.trim();
|
|
678
|
+
if (value !== current)
|
|
679
|
+
editor.setImageAlt([first.id], value === "" ? null : value);
|
|
680
|
+
} })] }) }));
|
|
681
|
+
};
|
|
682
|
+
/**
|
|
683
|
+
* Text lists as a two-row dropdown block: list kind (bulleted / ordered)
|
|
684
|
+
* over nesting (decrease / increase indent). While inline-editing, the
|
|
685
|
+
* operations target the paragraphs under the caret / selection; otherwise
|
|
686
|
+
* the whole element. Clicking the active kind again clears it.
|
|
687
|
+
*/
|
|
688
|
+
const ListControl = ({ shapes }) => {
|
|
689
|
+
const editor = useDiagramOptional();
|
|
690
|
+
if (!editor)
|
|
691
|
+
return null;
|
|
692
|
+
const ids = shapes.map((s) => s.id);
|
|
693
|
+
const kind = sharedValue(shapes, (s) => {
|
|
694
|
+
const paragraphs = s.paragraphs ?? [];
|
|
695
|
+
const first = paragraphs[0]?.list;
|
|
696
|
+
if (first === undefined)
|
|
697
|
+
return "none";
|
|
698
|
+
return paragraphs.every((p) => p.list === first) ? first : "none";
|
|
699
|
+
});
|
|
700
|
+
return (_jsx(Popover, { ariaLabel: "List", trigger: _jsx("button", { type: "button", className: "du-sel-icon-button", title: "List", "aria-label": "List", children: _jsx(List, { ...CONTROL_ICON, "aria-hidden": true }) }), children: _jsxs("div", { className: "du-sel-align-rows", children: [_jsx(SegmentedControl, { ariaLabel: "List kind", value: kind === "none" ? null : kind, options: [
|
|
701
|
+
{
|
|
702
|
+
value: "bullet",
|
|
703
|
+
label: "Bulleted list",
|
|
704
|
+
icon: _jsx(List, { ...CONTROL_ICON }),
|
|
705
|
+
},
|
|
706
|
+
{
|
|
707
|
+
value: "numbered",
|
|
708
|
+
label: "Ordered list",
|
|
709
|
+
icon: _jsx(ListOrdered, { ...CONTROL_ICON }),
|
|
710
|
+
},
|
|
711
|
+
], onChange: (v) => {
|
|
712
|
+
editor.setParagraphList(ids, kind === v ? null : v);
|
|
713
|
+
} }), _jsxs("div", { className: "du-sel-align-rows-row", children: [_jsx("button", { type: "button", className: "du-sel-icon-button", title: "Decrease indent", "aria-label": "Decrease indent", onClick: () => {
|
|
714
|
+
editor.indentParagraphs(ids, -1);
|
|
715
|
+
}, children: _jsx(IndentDecrease, { ...CONTROL_ICON, "aria-hidden": true }) }), _jsx("button", { type: "button", className: "du-sel-icon-button", title: "Increase indent", "aria-label": "Increase indent", onClick: () => {
|
|
716
|
+
editor.indentParagraphs(ids, 1);
|
|
717
|
+
}, children: _jsx(IndentIncrease, { ...CONTROL_ICON, "aria-hidden": true }) })] })] }) }));
|
|
718
|
+
};
|
|
719
|
+
/**
|
|
720
|
+
* Marker-style text highlight (background behind glyphs). During inline
|
|
721
|
+
* editing with a selected range the highlight lands on just those
|
|
722
|
+
* characters (styled runs); otherwise on the whole element(s). Picking
|
|
723
|
+
* "no color" clears the highlight.
|
|
724
|
+
*/
|
|
725
|
+
const HighlightControl = ({ shapes }) => {
|
|
726
|
+
const editor = useDiagramOptional();
|
|
727
|
+
const runRange = useTextRunRange(shapes);
|
|
728
|
+
if (!editor)
|
|
729
|
+
return null;
|
|
730
|
+
const ids = shapes.map((s) => s.id);
|
|
731
|
+
const value = sharedString(shapes, (s) => s.style.highlight);
|
|
732
|
+
return (_jsx(Popover, { ariaLabel: "Highlight color", trigger: _jsx("button", { type: "button", className: "du-sel-icon-button", title: "Highlight color", "aria-label": "Highlight color", children: _jsx(Highlighter, { ...CONTROL_ICON, "aria-hidden": true }) }), children: _jsxs("div", { className: "du-sel-popover-section", children: [_jsx("header", { className: "du-sel-popover-label", children: "Highlight" }), _jsx(ColorSwatchPicker, { value: value, onChange: (v) => {
|
|
733
|
+
const partial = { highlight: v ?? "transparent" };
|
|
734
|
+
if (runRange) {
|
|
735
|
+
editor.applyTextStyleToRange(runRange.target.id, runRange.from, runRange.to, partial);
|
|
736
|
+
}
|
|
737
|
+
else {
|
|
738
|
+
editor.updateTextStyle(ids, partial);
|
|
739
|
+
}
|
|
740
|
+
}, onEyedrop: (cb) => {
|
|
741
|
+
editor.beginEyedropperPick(cb);
|
|
742
|
+
} })] }) }));
|
|
743
|
+
};
|
|
744
|
+
const FillControl = ({ shapes }) => {
|
|
745
|
+
const editor = useDiagramOptional();
|
|
746
|
+
if (!editor || !shapes.some(hasFill))
|
|
747
|
+
return null;
|
|
748
|
+
const value = sharedString(shapes, (s) => s.style.fill);
|
|
749
|
+
const ids = shapes.map((s) => s.id);
|
|
750
|
+
return (_jsx(ColorTrigger, { label: "Fill", ariaLabel: "Fill color", color: value, onChange: (v) => {
|
|
751
|
+
editor.updateStyle(ids, { fill: v ?? "transparent" });
|
|
752
|
+
}, onEyedrop: (cb) => {
|
|
753
|
+
editor.beginEyedropperPick(cb);
|
|
754
|
+
} }));
|
|
755
|
+
};
|
|
756
|
+
// ---------------------------------------------------------------------------
|
|
757
|
+
// Text controls — rendered only for a text-only selection (see the
|
|
758
|
+
// dispatcher in `PropertyPanel`). `fontSize` / `fontFamily` are
|
|
759
|
+
// top-level `TextElement` fields written via `editor.updateTextProps`;
|
|
760
|
+
// `textAlign` / `textBaseline` are `TextStyle` fields written via
|
|
761
|
+
// `editor.updateStyle`.
|
|
762
|
+
// ---------------------------------------------------------------------------
|
|
763
|
+
const FontSizeControl = ({ shapes, allowAuto, }) => {
|
|
764
|
+
const editor = useDiagramOptional();
|
|
765
|
+
if (!editor)
|
|
766
|
+
return null;
|
|
767
|
+
const ids = shapes.map((s) => s.id);
|
|
768
|
+
const value = sharedValue(shapes,
|
|
769
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- cast asserts TextElement; non-text shapes lack fontSize at runtime
|
|
770
|
+
(s) => s.fontSize ?? null);
|
|
771
|
+
const auto = allowAuto === true && shapes.every((s) => s.label?.autoFit === true);
|
|
772
|
+
const presetValue = value !== null && TEXT_FONT_SIZE_PRESETS.some((p) => p.value === value) ? value : null;
|
|
773
|
+
return (_jsx(Popover, { ariaLabel: "Font size", trigger: _jsx("button", { type: "button", className: "du-sel-text-button", title: "Font size", "aria-label": `Font size ${auto ? "auto" : (value ?? "mixed")}`, children: auto ? "A" : value === null ? "—" : `${Math.round(value)}` }), children: _jsxs("div", { className: "du-sel-popover-section", children: [_jsx("header", { className: "du-sel-popover-label", children: "Font size" }), allowAuto === true ? (_jsx("button", { type: "button", className: `du-sel-icon-button du-sel-auto-size${auto ? " is-active" : ""}`, "aria-pressed": auto, title: "Auto \u2014 text size follows the sticky", onClick: () => {
|
|
774
|
+
editor.setLabelAutoFit(ids, !auto);
|
|
775
|
+
}, children: "Auto" })) : null, _jsx(SegmentedControl, { ariaLabel: "Font size preset", value: presetValue, options: TEXT_FONT_SIZE_PRESETS.map((p) => ({
|
|
776
|
+
value: p.value,
|
|
777
|
+
label: p.label,
|
|
778
|
+
icon: _jsx("span", { style: { fontSize: 11, fontWeight: 600 }, children: p.label }),
|
|
779
|
+
})), onChange: (v) => {
|
|
780
|
+
editor.updateTextProps(ids, { fontSize: v });
|
|
781
|
+
} }), _jsx(Slider, { value: value, min: TEXT_FONT_SIZE_MIN, max: TEXT_FONT_SIZE_MAX, step: 1, ariaLabel: "Font size", valueLabel: value === null ? "—" : `${value}px`, onChange: (v) => {
|
|
782
|
+
editor.updateTextProps(ids, { fontSize: v });
|
|
783
|
+
} })] }) }));
|
|
784
|
+
};
|
|
785
|
+
const FontFamilyControl = ({ shapes }) => {
|
|
786
|
+
const editor = useDiagramOptional();
|
|
787
|
+
if (!editor)
|
|
788
|
+
return null;
|
|
789
|
+
const ids = shapes.map((s) => s.id);
|
|
790
|
+
const value = sharedString(shapes, (s) => s.fontFamily);
|
|
791
|
+
const current = TEXT_FONT_STACKS.find((f) => f.value === value);
|
|
792
|
+
const label = value === null ? "Mixed" : (current?.label ?? "Custom");
|
|
793
|
+
return (_jsx(Popover, { ariaLabel: "Font family", trigger: _jsx("button", { type: "button", className: "du-sel-text-button", title: "Font family", "aria-label": `Font family ${label}`, style: value ? { fontFamily: value } : undefined, children: label }), children: _jsxs("div", { className: "du-sel-popover-section", children: [_jsx("header", { className: "du-sel-popover-label", children: "Font family" }), TEXT_FONT_STACKS.map((f) => (_jsx("button", { type: "button", role: "menuitemradio", "aria-checked": f.value === value, className: `du-sel-menu-row${f.value === value ? " is-active" : ""}`, style: { fontFamily: f.value }, onClick: () => {
|
|
794
|
+
editor.updateTextProps(ids, { fontFamily: f.value });
|
|
795
|
+
}, children: f.label }, f.value)))] }) }));
|
|
796
|
+
};
|
|
797
|
+
/**
|
|
798
|
+
* Text alignment as a two-row dropdown block: horizontal (left / center /
|
|
799
|
+
* right → `textAlign`) over vertical (top / middle / bottom →
|
|
800
|
+
* `textBaseline`). One trigger keeps the toolbar row compact and matches
|
|
801
|
+
* the target design's grouped alignment control.
|
|
802
|
+
*/
|
|
803
|
+
const TextAlignControl = ({ shapes }) => {
|
|
804
|
+
const editor = useDiagramOptional();
|
|
805
|
+
if (!editor)
|
|
806
|
+
return null;
|
|
807
|
+
const ids = shapes.map((s) => s.id);
|
|
808
|
+
const value = sharedValue(shapes, (s) => s.style.textAlign ?? "left");
|
|
809
|
+
const valign = sharedValue(shapes, (s) => s.style.textBaseline ?? "top");
|
|
810
|
+
return (_jsx(Popover, { ariaLabel: "Text alignment", trigger: _jsx("button", { type: "button", className: "du-sel-icon-button", title: "Text alignment", "aria-label": "Text alignment", children: value === "right" ? (_jsx(AlignRight, { ...CONTROL_ICON, "aria-hidden": true })) : value === "center" ? (_jsx(AlignCenter, { ...CONTROL_ICON, "aria-hidden": true })) : (_jsx(AlignLeft, { ...CONTROL_ICON, "aria-hidden": true })) }), children: _jsxs("div", { className: "du-sel-align-rows", children: [_jsx(SegmentedControl, { ariaLabel: "Horizontal text alignment", value: value, options: [
|
|
811
|
+
{ value: "left", label: "Left", icon: _jsx(AlignLeft, { ...CONTROL_ICON }) },
|
|
812
|
+
{
|
|
813
|
+
value: "center",
|
|
814
|
+
label: "Center",
|
|
815
|
+
icon: _jsx(AlignCenter, { ...CONTROL_ICON }),
|
|
816
|
+
},
|
|
817
|
+
{ value: "right", label: "Right", icon: _jsx(AlignRight, { ...CONTROL_ICON }) },
|
|
818
|
+
], onChange: (v) => {
|
|
819
|
+
editor.updateTextStyle(ids, { textAlign: v });
|
|
820
|
+
} }), _jsx(SegmentedControl, { ariaLabel: "Vertical text alignment", value: valign, options: [
|
|
821
|
+
{
|
|
822
|
+
value: "top",
|
|
823
|
+
label: "Top",
|
|
824
|
+
icon: _jsx(AlignStartHorizontal, { ...CONTROL_ICON }),
|
|
825
|
+
},
|
|
826
|
+
{
|
|
827
|
+
value: "middle",
|
|
828
|
+
label: "Middle",
|
|
829
|
+
icon: _jsx(AlignCenterHorizontal, { ...CONTROL_ICON }),
|
|
830
|
+
},
|
|
831
|
+
{
|
|
832
|
+
value: "bottom",
|
|
833
|
+
label: "Bottom",
|
|
834
|
+
icon: _jsx(AlignEndHorizontal, { ...CONTROL_ICON }),
|
|
835
|
+
},
|
|
836
|
+
], onChange: (v) => {
|
|
837
|
+
editor.updateTextStyle(ids, { textBaseline: v });
|
|
838
|
+
} })] }) }));
|
|
839
|
+
};
|
|
840
|
+
/**
|
|
841
|
+
* One decoration toggle inside {@link TextDecorationControl}'s popover.
|
|
842
|
+
* MUST stay a module-level component: defining it inside its parent would
|
|
843
|
+
* give it a fresh identity on every parent re-render, so React would
|
|
844
|
+
* remount the `<button>` each frame. During inline text editing the panel
|
|
845
|
+
* re-renders constantly (caret blink), and a remount between `mousedown`
|
|
846
|
+
* and `mouseup` swallows the synthesized `click` — the toggle would then
|
|
847
|
+
* silently never fire.
|
|
848
|
+
*/
|
|
849
|
+
const TextStyleToggle = ({ active, label, icon, onClick, }) => (_jsx("button", { type: "button", className: `du-sel-icon-button${active ? " is-active" : ""}`, title: label, "aria-label": label, "aria-pressed": active, onClick: onClick, children: icon }));
|
|
850
|
+
/**
|
|
851
|
+
* Bold / Italic / Underline / Strikethrough. One trigger (Aa) opens a
|
|
852
|
+
* popover with four independent toggles. Each writes through
|
|
853
|
+
* `editor.updateStyle`: bold→`fontWeight`, italic→`fontStyle`,
|
|
854
|
+
* underline/strikethrough→merged `textDecoration`. Active = every
|
|
855
|
+
* selected shape already has that decoration on.
|
|
856
|
+
*/
|
|
857
|
+
const TextDecorationControl = ({ shapes }) => {
|
|
858
|
+
const editor = useDiagramOptional();
|
|
859
|
+
const runRange = useTextRunRange(shapes);
|
|
860
|
+
if (!editor)
|
|
861
|
+
return null;
|
|
862
|
+
const ids = shapes.map((s) => s.id);
|
|
863
|
+
// Active state: over an in-edit range, read the effective per-run style
|
|
864
|
+
// (run overlay ?? element style); otherwise read the whole-element style.
|
|
865
|
+
const rangeSegs = runRange ? sliceRuns(runRange.target, runRange.from, runRange.to) : [];
|
|
866
|
+
const base = runRange?.target.style;
|
|
867
|
+
const segEvery = (pred) => rangeSegs.length > 0 && rangeSegs.every((r) => pred(r.style));
|
|
868
|
+
const allBold = runRange
|
|
869
|
+
? segEvery((st) => (st?.fontWeight ?? base?.fontWeight) === "bold")
|
|
870
|
+
: shapes.every((s) => s.style?.fontWeight === "bold");
|
|
871
|
+
const allItalic = runRange
|
|
872
|
+
? segEvery((st) => (st?.fontStyle ?? base?.fontStyle) === "italic")
|
|
873
|
+
: shapes.every((s) => s.style?.fontStyle === "italic");
|
|
874
|
+
const allUnderline = runRange
|
|
875
|
+
? segEvery((st) => (st?.textDecoration ?? base?.textDecoration)?.underline === true)
|
|
876
|
+
: shapes.every((s) => s.style?.textDecoration?.underline === true);
|
|
877
|
+
const allStrike = runRange
|
|
878
|
+
? segEvery((st) => (st?.textDecoration ?? base?.textDecoration)?.strikethrough === true)
|
|
879
|
+
: shapes.every((s) => s.style?.textDecoration?.strikethrough === true);
|
|
880
|
+
// Apply a partial text style to the in-edit range (rich text) or, with no
|
|
881
|
+
// active range, to the whole selected element(s).
|
|
882
|
+
const applyPartial = (partial) => {
|
|
883
|
+
if (runRange) {
|
|
884
|
+
editor.applyTextStyleToRange(runRange.target.id, runRange.from, runRange.to, partial);
|
|
885
|
+
}
|
|
886
|
+
else {
|
|
887
|
+
editor.updateTextStyle(ids, partial);
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
// Toggling underline/strikethrough must preserve the other flag. Range mode
|
|
891
|
+
// replaces the whole `textDecoration`, so rebuild both flags from the
|
|
892
|
+
// range's current state; whole-element mode merges per shape.
|
|
893
|
+
const setDecoration = (key, on) => {
|
|
894
|
+
if (runRange) {
|
|
895
|
+
editor.applyTextStyleToRange(runRange.target.id, runRange.from, runRange.to, {
|
|
896
|
+
textDecoration: { underline: allUnderline, strikethrough: allStrike, [key]: on },
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
else {
|
|
900
|
+
for (const s of shapes) {
|
|
901
|
+
const cur = s.style?.textDecoration ?? {};
|
|
902
|
+
editor.updateTextStyle([s.id], { textDecoration: { ...cur, [key]: on } });
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
return (_jsx(Popover, { ariaLabel: "Text style", trigger: _jsx("button", { type: "button", className: "du-sel-icon-button", title: "Text style (bold / italic / \u2026)", "aria-label": "Text style", children: _jsx(CaseSensitive, { ...CONTROL_ICON, "aria-hidden": true }) }), children: _jsxs("div", { className: "du-sel-popover-section", children: [_jsx("header", { className: "du-sel-popover-label", children: "Style" }), _jsxs("div", { style: { display: "flex", gap: 2 }, children: [_jsx(TextStyleToggle, { active: allBold, label: "Bold", icon: _jsx(Bold, { ...CONTROL_ICON }), onClick: () => {
|
|
907
|
+
applyPartial({ fontWeight: allBold ? "normal" : "bold" });
|
|
908
|
+
} }), _jsx(TextStyleToggle, { active: allItalic, label: "Italic", icon: _jsx(Italic, { ...CONTROL_ICON }), onClick: () => {
|
|
909
|
+
applyPartial({ fontStyle: allItalic ? "normal" : "italic" });
|
|
910
|
+
} }), _jsx(TextStyleToggle, { active: allUnderline, label: "Underline", icon: _jsx(Underline, { ...CONTROL_ICON }), onClick: () => {
|
|
911
|
+
setDecoration("underline", !allUnderline);
|
|
912
|
+
} }), _jsx(TextStyleToggle, { active: allStrike, label: "Strikethrough", icon: _jsx(Strikethrough, { ...CONTROL_ICON }), onClick: () => {
|
|
913
|
+
setDecoration("strikethrough", !allStrike);
|
|
914
|
+
} })] })] }) }));
|
|
915
|
+
};
|
|
916
|
+
const StrokeControl = ({ shapes }) => {
|
|
917
|
+
const editor = useDiagramOptional();
|
|
918
|
+
if (!editor || !shapes.some(hasStroke))
|
|
919
|
+
return null;
|
|
920
|
+
const value = sharedString(shapes, (s) => s.style.stroke);
|
|
921
|
+
const ids = shapes.map((s) => s.id);
|
|
922
|
+
return (_jsx(ColorTrigger, { label: "Stroke", ariaLabel: "Stroke color", color: value, onChange: (v) => {
|
|
923
|
+
editor.updateStyle(ids, { stroke: v ?? "transparent" });
|
|
924
|
+
}, onEyedrop: (cb) => {
|
|
925
|
+
editor.beginEyedropperPick(cb);
|
|
926
|
+
} }));
|
|
927
|
+
};
|
|
928
|
+
/**
|
|
929
|
+
* Width control for brush strokes: a popover with a range slider driving
|
|
930
|
+
* `editor.setBrushWidth`, which re-bases the baked per-point widths while
|
|
931
|
+
* keeping the stroke's pressure profile. Rendered instead of
|
|
932
|
+
* {@link StrokeWidthControl} when the whole selection is brush strokes.
|
|
933
|
+
*/
|
|
934
|
+
const BrushWidthControl = ({ shapes }) => {
|
|
935
|
+
const editor = useDiagramOptional();
|
|
936
|
+
if (!editor)
|
|
937
|
+
return null;
|
|
938
|
+
const ids = shapes.map((s) => s.id);
|
|
939
|
+
const value = sharedValue(shapes, (s) => {
|
|
940
|
+
const brush = s;
|
|
941
|
+
const base = brush.baseWidth ?? brush.points.reduce((m, p) => Math.max(m, p.width), 0);
|
|
942
|
+
return base > 0 ? Math.round(base) : null;
|
|
943
|
+
});
|
|
944
|
+
return (_jsx(Popover, { ariaLabel: "Brush width", trigger: _jsx("button", { type: "button", className: "du-sel-icon-button", title: "Brush width", "aria-label": "Brush width", children: _jsx(StrokeWidthIcon, { thickness: 2.5 }) }), children: _jsxs("div", { className: "du-sel-popover-section", children: [_jsx("header", { className: "du-sel-popover-label", children: `Width ${value !== null ? String(value) : "mixed"}` }), _jsx("input", { type: "range", min: BRUSH_WIDTH_MIN, max: BRUSH_WIDTH_MAX, step: 1, value: value ?? BRUSH_WIDTH_MIN, onChange: (e) => {
|
|
945
|
+
editor.setBrushWidth(ids, e.currentTarget.valueAsNumber);
|
|
946
|
+
}, style: { width: "100%" }, "aria-label": "Brush width" })] }) }));
|
|
947
|
+
};
|
|
948
|
+
/**
|
|
949
|
+
* Opacity control: percentage badge button opens a popover with a
|
|
950
|
+
* slider. The trigger is always rendered (even at implicit opacity 1) so
|
|
951
|
+
* the user can jump to 50% without a multi-step interaction.
|
|
952
|
+
*/
|
|
953
|
+
const OpacityControl = ({ shapes }) => {
|
|
954
|
+
const editor = useDiagramOptional();
|
|
955
|
+
if (!editor)
|
|
956
|
+
return null;
|
|
957
|
+
const value = sharedValue(shapes, (s) => s.style.opacity ?? 1);
|
|
958
|
+
const ids = shapes.map((s) => s.id);
|
|
959
|
+
const percent = value === null ? null : Math.round(value * 100);
|
|
960
|
+
const label = percent === null ? "—" : `${percent}%`;
|
|
961
|
+
return (_jsx(Popover, { ariaLabel: "Opacity", trigger: _jsx("button", { type: "button", className: "du-sel-text-button", title: "Opacity", "aria-label": `Opacity ${label}`, children: label }), children: _jsxs("div", { className: "du-sel-popover-section", children: [_jsx("header", { className: "du-sel-popover-label", children: "Opacity" }), _jsx(Slider, { value: percent, min: 0, max: 100, step: 5, ariaLabel: "Opacity", valueLabel: label, onChange: (v) => {
|
|
962
|
+
editor.updateStyle(ids, { opacity: v / 100 });
|
|
963
|
+
} })] }) }));
|
|
964
|
+
};
|
|
965
|
+
/**
|
|
966
|
+
* Convert-type control (F9): switch every selected rectangle / ellipse /
|
|
967
|
+
* diamond to another of those types, preserving position, size and style.
|
|
968
|
+
* Renders only when every selected shape is convertible. The active type is
|
|
969
|
+
* shown as the pressed segment (or `null` for a mixed selection).
|
|
970
|
+
*/
|
|
971
|
+
/**
|
|
972
|
+
* Selection-filter buckets (reference grouping): coarse type groups the
|
|
973
|
+
* Filter menu offers, keyed by bucket id. `types` maps element `type`
|
|
974
|
+
* strings into the bucket.
|
|
975
|
+
*/
|
|
976
|
+
const SELECTION_BUCKETS = [
|
|
977
|
+
{
|
|
978
|
+
id: "shapes",
|
|
979
|
+
label: "Shapes",
|
|
980
|
+
icon: Square,
|
|
981
|
+
types: ["rectangle", "ellipse", "polygon", "path", "block-arrow"],
|
|
982
|
+
},
|
|
983
|
+
{ id: "sticky", label: "Sticky notes", icon: StickyNote, types: ["sticky"] },
|
|
984
|
+
{ id: "text", label: "Text", icon: CaseSensitive, types: ["text"] },
|
|
985
|
+
{ id: "image", label: "Images", icon: ImageIcon, types: ["image"] },
|
|
986
|
+
{ id: "frame", label: "Frames", icon: Proportions, types: ["frame"] },
|
|
987
|
+
{ id: "brush", label: "Drawings", icon: Spline, types: ["brush"] },
|
|
988
|
+
{ id: "emoji", label: "Emoji", icon: SmilePlus, types: ["emoji"] },
|
|
989
|
+
{ id: "other", label: "Other", icon: MoreHorizontal, types: [] },
|
|
990
|
+
];
|
|
991
|
+
const bucketIdOf = (type) => SELECTION_BUCKETS.find((b) => b.types.includes(type))?.id ?? "other";
|
|
992
|
+
/** Group a selection's shapes into type buckets (insertion-ordered). */
|
|
993
|
+
const selectionBuckets = (shapes) => {
|
|
994
|
+
const out = new Map();
|
|
995
|
+
for (const shape of shapes) {
|
|
996
|
+
const id = bucketIdOf(shape.type);
|
|
997
|
+
const list = out.get(id);
|
|
998
|
+
if (list)
|
|
999
|
+
list.push(shape);
|
|
1000
|
+
else
|
|
1001
|
+
out.set(id, [shape]);
|
|
1002
|
+
}
|
|
1003
|
+
return out;
|
|
1004
|
+
};
|
|
1005
|
+
/**
|
|
1006
|
+
* Filter control for MIXED selections (reference behaviour): a popover
|
|
1007
|
+
* listing the selection's type buckets with counts; picking one NARROWS
|
|
1008
|
+
* THE ACTUAL SELECTION to that bucket's elements (the per-type toolbar
|
|
1009
|
+
* then takes over). Single bucket per pick — no multi-filter.
|
|
1010
|
+
*/
|
|
1011
|
+
const SelectionFilterControl = ({ shapes }) => {
|
|
1012
|
+
const editor = useDiagramOptional();
|
|
1013
|
+
if (!editor)
|
|
1014
|
+
return null;
|
|
1015
|
+
const buckets = selectionBuckets(shapes);
|
|
1016
|
+
return (_jsx(Popover, { ariaLabel: "Filter selection", trigger: _jsx("button", { type: "button", className: "du-sel-icon-button", title: "Filter selection by type", "aria-label": "Filter selection by type", children: _jsx(ListFilter, { ...CONTROL_ICON, "aria-hidden": true }) }), children: _jsxs("div", { className: "du-sel-popover-section", children: [_jsx("header", { className: "du-sel-popover-label", children: "Select only" }), _jsx("div", { className: "du-sel-mask-list", role: "menu", "aria-label": "Selection type filter", children: [...buckets.entries()].map(([id, members]) => {
|
|
1017
|
+
const bucket = SELECTION_BUCKETS.find((b) => b.id === id);
|
|
1018
|
+
if (!bucket)
|
|
1019
|
+
return null;
|
|
1020
|
+
const Icon = bucket.icon;
|
|
1021
|
+
return (_jsxs("button", { type: "button", role: "menuitem", className: "du-sel-mask-row", "aria-label": `Select only ${bucket.label}`, onClick: () => {
|
|
1022
|
+
editor.setSelection(members.map((m) => m.id));
|
|
1023
|
+
}, children: [_jsx(Icon, { ...CONTROL_ICON, "aria-hidden": true }), _jsx("span", { children: bucket.label }), _jsx("span", { className: "du-sel-mask-ratio", children: members.length })] }, id));
|
|
1024
|
+
}) })] }) }));
|
|
1025
|
+
};
|
|
1026
|
+
/**
|
|
1027
|
+
* Switch-type targets offered by {@link ConvertTypeControl}, in menu
|
|
1028
|
+
* order. Every listed kind converts to every other one (shape kinds ↔
|
|
1029
|
+
* text ↔ sticky).
|
|
1030
|
+
*/
|
|
1031
|
+
const CONVERT_TARGETS = [
|
|
1032
|
+
{ value: "rectangle", label: "Rectangle", icon: Square },
|
|
1033
|
+
{ value: "ellipse", label: "Ellipse", icon: Circle },
|
|
1034
|
+
{ value: "polygon", label: "Diamond", icon: Diamond },
|
|
1035
|
+
{ value: "text", label: "Text", icon: CaseSensitive },
|
|
1036
|
+
{ value: "sticky", label: "Sticky note", icon: StickyNote },
|
|
1037
|
+
];
|
|
1038
|
+
/** Kinds inside the switch-type matrix — the only ones the picker handles. */
|
|
1039
|
+
const isConvertible = (s) => isRectangle(s) || isEllipse(s) || isPolygon(s) || isText(s) || isSticky(s);
|
|
1040
|
+
const convertTargetOf = (s) => isRectangle(s)
|
|
1041
|
+
? "rectangle"
|
|
1042
|
+
: isEllipse(s)
|
|
1043
|
+
? "ellipse"
|
|
1044
|
+
: isText(s)
|
|
1045
|
+
? "text"
|
|
1046
|
+
: isSticky(s)
|
|
1047
|
+
? "sticky"
|
|
1048
|
+
: "polygon";
|
|
1049
|
+
/**
|
|
1050
|
+
* Switch-type picker: one trigger showing the selection's current kind
|
|
1051
|
+
* that opens a menu of every target kind. Picking a row converts the
|
|
1052
|
+
* selection in place and closes the menu.
|
|
1053
|
+
*/
|
|
1054
|
+
const ConvertTypeControl = ({ shapes }) => {
|
|
1055
|
+
const editor = useDiagramOptional();
|
|
1056
|
+
const [open, setOpen] = useState(false);
|
|
1057
|
+
if (!editor)
|
|
1058
|
+
return null;
|
|
1059
|
+
const value = sharedValue(shapes, convertTargetOf);
|
|
1060
|
+
const current = CONVERT_TARGETS.find((t) => t.value === value);
|
|
1061
|
+
const TriggerIcon = current?.icon ?? Square;
|
|
1062
|
+
return (_jsx(Popover, { ariaLabel: "Switch type", open: open, onOpenChange: setOpen, trigger: _jsx("button", { type: "button", className: "du-sel-icon-button", title: "Switch type", "aria-label": "Switch type", children: _jsx(TriggerIcon, { ...CONTROL_ICON, "aria-hidden": true }) }), children: _jsxs("div", { className: "du-sel-popover-section", children: [_jsx("header", { className: "du-sel-popover-label", children: "Switch type" }), _jsx("div", { className: "du-sel-mask-list", role: "menu", "aria-label": "Switch type targets", children: CONVERT_TARGETS.map((target) => {
|
|
1063
|
+
const Icon = target.icon;
|
|
1064
|
+
const active = target.value === value;
|
|
1065
|
+
return (_jsxs("button", { type: "button", role: "menuitemradio", "aria-checked": active, className: `du-sel-mask-row${active ? " is-active" : ""}`, "aria-label": target.label, onClick: () => {
|
|
1066
|
+
editor.convertSelection(target.value);
|
|
1067
|
+
setOpen(false);
|
|
1068
|
+
}, children: [_jsx(Icon, { ...CONTROL_ICON, "aria-hidden": true }), _jsx("span", { children: target.label })] }, target.value));
|
|
1069
|
+
}) })] }) }));
|
|
1070
|
+
};
|
|
1071
|
+
/**
|
|
1072
|
+
* Mask presets offered by {@link MaskControl}: `null` = no mask, plus
|
|
1073
|
+
* the two parametric kinds and the built-in polygon rings. `radius`
|
|
1074
|
+
* default for round-rect comes from `IMAGE_MASK_DEFAULT_RADIUS`.
|
|
1075
|
+
*/
|
|
1076
|
+
const MASK_TILES = [
|
|
1077
|
+
{ key: "none", label: "No mask", icon: Square, mask: null },
|
|
1078
|
+
{ key: "ellipse", label: "Ellipse mask", icon: Circle, mask: { kind: "ellipse" } },
|
|
1079
|
+
{
|
|
1080
|
+
key: "round-rect",
|
|
1081
|
+
label: "Rounded mask",
|
|
1082
|
+
icon: Squircle,
|
|
1083
|
+
mask: { kind: "round-rect", radius: IMAGE_MASK_DEFAULT_RADIUS },
|
|
1084
|
+
},
|
|
1085
|
+
...[
|
|
1086
|
+
["diamond", "Diamond mask", Diamond],
|
|
1087
|
+
["triangle", "Triangle mask", TriangleIcon],
|
|
1088
|
+
["hexagon", "Hexagon mask", HexagonIcon],
|
|
1089
|
+
["star", "Star mask", StarIcon],
|
|
1090
|
+
].map(([key, label, icon]) => ({
|
|
1091
|
+
key,
|
|
1092
|
+
label,
|
|
1093
|
+
icon,
|
|
1094
|
+
mask: {
|
|
1095
|
+
kind: "polygon",
|
|
1096
|
+
points: IMAGE_MASK_POLYGON_PRESETS[key] ?? [],
|
|
1097
|
+
},
|
|
1098
|
+
})),
|
|
1099
|
+
];
|
|
1100
|
+
/**
|
|
1101
|
+
* Aspect-preset tiles (reference mask list): momentary actions, not
|
|
1102
|
+
* stored kinds — Custom enters the free-crop mode, Original resets
|
|
1103
|
+
* crop + mask to the source's natural aspect, the rest centre-crop to
|
|
1104
|
+
* the ratio via `Editor.setImageAspectPreset` (Circle also installs an
|
|
1105
|
+
* ellipse mask on the square box).
|
|
1106
|
+
*/
|
|
1107
|
+
const ASPECT_TILES = [
|
|
1108
|
+
{ key: "custom", label: "Custom", icon: Crop },
|
|
1109
|
+
{ key: "original", label: "Original", icon: ImageIcon },
|
|
1110
|
+
{ key: "circle", label: "Circle", icon: Circle },
|
|
1111
|
+
{ key: "square", label: "Square", icon: Square },
|
|
1112
|
+
{ key: "portrait", label: "Portrait", icon: RectangleVertical, ratio: "3:4" },
|
|
1113
|
+
{ key: "landscape", label: "Landscape", icon: RectangleHorizontal, ratio: "4:3" },
|
|
1114
|
+
{ key: "wide", label: "Wide", icon: Tv, ratio: "16:9" },
|
|
1115
|
+
];
|
|
1116
|
+
/** The tile a shape's current mask corresponds to (for the pressed state). */
|
|
1117
|
+
const maskTileKey = (mask) => {
|
|
1118
|
+
if (!mask)
|
|
1119
|
+
return "none";
|
|
1120
|
+
if (mask.kind === "polygon") {
|
|
1121
|
+
for (const [name, ring] of Object.entries(IMAGE_MASK_POLYGON_PRESETS)) {
|
|
1122
|
+
if (ring.length === mask.points.length &&
|
|
1123
|
+
ring.every((p, i) => {
|
|
1124
|
+
const q = mask.points[i];
|
|
1125
|
+
return q?.x === p.x && q.y === p.y;
|
|
1126
|
+
})) {
|
|
1127
|
+
return name;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
return "polygon";
|
|
1131
|
+
}
|
|
1132
|
+
return mask.kind;
|
|
1133
|
+
};
|
|
1134
|
+
/**
|
|
1135
|
+
* Shape-mask picker for image selections: a popover of shape tiles
|
|
1136
|
+
* (None / Ellipse / Rounded / polygon presets) applied instantly, plus a
|
|
1137
|
+
* corner-radius slider when the rounded mask is active. The canvas
|
|
1138
|
+
* renderer clips through `RenderTarget.clip`, so the mask reaches every
|
|
1139
|
+
* backend and PNG / SVG exports.
|
|
1140
|
+
*/
|
|
1141
|
+
const MaskControl = ({ shapes }) => {
|
|
1142
|
+
const editor = useDiagramOptional();
|
|
1143
|
+
if (!editor)
|
|
1144
|
+
return null;
|
|
1145
|
+
const images = shapes.filter((s) => isImage(s));
|
|
1146
|
+
if (images.length === 0)
|
|
1147
|
+
return null;
|
|
1148
|
+
const ids = images.map((s) => s.id);
|
|
1149
|
+
const current = sharedValue(images, (s) => maskTileKey(s.mask));
|
|
1150
|
+
const firstMask = images[0]?.mask;
|
|
1151
|
+
const radius = firstMask?.kind === "round-rect" ? firstMask.radius : null;
|
|
1152
|
+
return (_jsx(Popover, { ariaLabel: "Image mask", trigger: _jsx("button", { type: "button", className: "du-sel-icon-button", title: "Mask", "aria-label": "Image mask", children: _jsx(Squircle, { ...CONTROL_ICON, "aria-hidden": true }) }), children: _jsxs("div", { className: "du-sel-popover-section", children: [_jsx("header", { className: "du-sel-popover-label", children: "Mask" }), _jsx("div", { className: "du-sel-mask-list", role: "menu", "aria-label": "Aspect presets", children: ASPECT_TILES.map((tile) => {
|
|
1153
|
+
const Icon = tile.icon;
|
|
1154
|
+
return (_jsxs("button", { type: "button", role: "menuitem", className: "du-sel-mask-row", "aria-label": tile.label, onClick: () => {
|
|
1155
|
+
if (tile.key === "custom") {
|
|
1156
|
+
const first = ids[0];
|
|
1157
|
+
if (first !== undefined)
|
|
1158
|
+
editor.beginImageCrop(first);
|
|
1159
|
+
}
|
|
1160
|
+
else {
|
|
1161
|
+
editor.setImageAspectPreset(ids, tile.key);
|
|
1162
|
+
}
|
|
1163
|
+
}, children: [_jsx(Icon, { ...CONTROL_ICON, "aria-hidden": true }), _jsx("span", { children: tile.label }), tile.ratio !== undefined ? (_jsx("span", { className: "du-sel-mask-ratio", children: tile.ratio })) : null] }, tile.key));
|
|
1164
|
+
}) }), _jsx("div", { className: "du-sel-mask-tiles", children: MASK_TILES.map((tile) => {
|
|
1165
|
+
const Icon = tile.icon;
|
|
1166
|
+
const active = current === tile.key;
|
|
1167
|
+
return (_jsx("button", { type: "button", className: `du-sel-icon-button${active ? " is-active" : ""}`, title: tile.label, "aria-label": tile.label, "aria-pressed": active, onClick: () => {
|
|
1168
|
+
editor.setImageMask(ids, tile.mask);
|
|
1169
|
+
}, children: _jsx(Icon, { ...CONTROL_ICON, "aria-hidden": true }) }, tile.key));
|
|
1170
|
+
}) }), radius !== null ? (_jsx(Slider, { value: Math.round(radius * 100), min: 0, max: 50, step: 5, ariaLabel: "Corner radius", valueLabel: `${String(Math.round(radius * 100))}%`, onChange: (v) => {
|
|
1171
|
+
editor.setImageMask(ids, { kind: "round-rect", radius: v / 100 });
|
|
1172
|
+
} })) : null] }) }));
|
|
1173
|
+
};
|
|
1174
|
+
/**
|
|
1175
|
+
* Crop control (F10): a button that enters image-crop mode for the single
|
|
1176
|
+
* selected image. Renders only for a lone image selection (crop is
|
|
1177
|
+
* single-target). Double-clicking the image on the canvas does the same.
|
|
1178
|
+
*/
|
|
1179
|
+
const CropControl = ({ shapes }) => {
|
|
1180
|
+
const editor = useDiagramOptional();
|
|
1181
|
+
if (!editor)
|
|
1182
|
+
return null;
|
|
1183
|
+
const only = shapes.length === 1 ? shapes[0] : undefined;
|
|
1184
|
+
if (only === undefined || !isImage(only))
|
|
1185
|
+
return null;
|
|
1186
|
+
const id = only.id;
|
|
1187
|
+
return (_jsx("button", { type: "button", className: "du-sel-icon-button", title: "Crop image (double-click)", "aria-label": "Crop image", onClick: () => {
|
|
1188
|
+
editor.beginImageCrop(id);
|
|
1189
|
+
}, children: _jsx(Crop, { ...CONTROL_ICON, "aria-hidden": true }) }));
|
|
1190
|
+
};
|
|
1191
|
+
// ---------------------------------------------------------------------------
|
|
1192
|
+
// Link controls — horizontal compact set used when an edge (not a
|
|
1193
|
+
// shape) is the active selection. Mirrors the shape control surface
|
|
1194
|
+
// (color triggers + segmented row controls) so the floating panel
|
|
1195
|
+
// reads consistently regardless of selection type.
|
|
1196
|
+
// ---------------------------------------------------------------------------
|
|
1197
|
+
const LinkStrokeColorControl = ({ edge }) => {
|
|
1198
|
+
const editor = useDiagramOptional();
|
|
1199
|
+
if (!editor)
|
|
1200
|
+
return null;
|
|
1201
|
+
const color = typeof edge.style.stroke === "string" ? edge.style.stroke : null;
|
|
1202
|
+
return (_jsx(ColorTrigger, { label: "Stroke", ariaLabel: "Link stroke color", color: color, onChange: (v) => {
|
|
1203
|
+
editor.updateSelectedLink((e) => ({
|
|
1204
|
+
...e,
|
|
1205
|
+
style: { ...e.style, stroke: v ?? "transparent" },
|
|
1206
|
+
}));
|
|
1207
|
+
}, onEyedrop: (cb) => {
|
|
1208
|
+
editor.beginEyedropperPick(cb);
|
|
1209
|
+
} }));
|
|
1210
|
+
};
|
|
1211
|
+
const LinkStrokeWidthControl = ({ edge }) => {
|
|
1212
|
+
const editor = useDiagramOptional();
|
|
1213
|
+
if (!editor)
|
|
1214
|
+
return null;
|
|
1215
|
+
const value = typeof edge.style.strokeWidth === "number" ? edge.style.strokeWidth : null;
|
|
1216
|
+
return (_jsx(SegmentedControl, { ariaLabel: "Link stroke width", value: value, options: [
|
|
1217
|
+
{ value: 1, label: "Thin", icon: _jsx(StrokeWidthIcon, { thickness: 1 }) },
|
|
1218
|
+
{ value: 2, label: "Medium", icon: _jsx(StrokeWidthIcon, { thickness: 2.5 }) },
|
|
1219
|
+
{ value: 4, label: "Thick", icon: _jsx(StrokeWidthIcon, { thickness: 4 }) },
|
|
1220
|
+
], onChange: (v) => {
|
|
1221
|
+
editor.updateSelectedLink((e) => ({
|
|
1222
|
+
...e,
|
|
1223
|
+
style: { ...e.style, strokeWidth: v },
|
|
1224
|
+
}));
|
|
1225
|
+
} }));
|
|
1226
|
+
};
|
|
1227
|
+
const LinkStrokeStyleControl = ({ edge }) => {
|
|
1228
|
+
const editor = useDiagramOptional();
|
|
1229
|
+
if (!editor)
|
|
1230
|
+
return null;
|
|
1231
|
+
const da = edge.style.dashArray;
|
|
1232
|
+
const value = (() => {
|
|
1233
|
+
if (!da || da.length === 0)
|
|
1234
|
+
return "solid";
|
|
1235
|
+
const first = da[0] ?? 0;
|
|
1236
|
+
return first <= 3 ? "dotted" : "dashed";
|
|
1237
|
+
})();
|
|
1238
|
+
return (_jsx(SegmentedControl, { ariaLabel: "Link stroke style", value: value, options: [
|
|
1239
|
+
{ value: "solid", label: "Solid", icon: _jsx(Square, { ...CONTROL_ICON }) },
|
|
1240
|
+
{ value: "dashed", label: "Dashed", icon: _jsx(SquareDashed, { ...CONTROL_ICON }) },
|
|
1241
|
+
{ value: "dotted", label: "Dotted", icon: _jsx(SquareDot, { ...CONTROL_ICON }) },
|
|
1242
|
+
], onChange: (v) => {
|
|
1243
|
+
const dashArray = v === "solid" ? [] : v === "dashed" ? [8, 4] : [2, 4];
|
|
1244
|
+
editor.updateSelectedLink((e) => ({
|
|
1245
|
+
...e,
|
|
1246
|
+
style: { ...e.style, dashArray },
|
|
1247
|
+
}));
|
|
1248
|
+
} }));
|
|
1249
|
+
};
|
|
1250
|
+
const LinkLineKindControl = ({ edge }) => {
|
|
1251
|
+
const editor = useDiagramOptional();
|
|
1252
|
+
if (!editor)
|
|
1253
|
+
return null;
|
|
1254
|
+
const value = edge.lineKind ?? "line";
|
|
1255
|
+
return (_jsx(SegmentedControl, { ariaLabel: "Link body", value: value, options: [
|
|
1256
|
+
{ value: "line", label: "Line", icon: _jsx(Minus, { ...CONTROL_ICON }) },
|
|
1257
|
+
{
|
|
1258
|
+
value: "block-arrow",
|
|
1259
|
+
label: "Block arrow",
|
|
1260
|
+
icon: _jsx(MoveRight, { ...CONTROL_ICON }),
|
|
1261
|
+
},
|
|
1262
|
+
], onChange: (v) => {
|
|
1263
|
+
editor.updateSelectedLink((e) => ({ ...e, lineKind: v }));
|
|
1264
|
+
} }));
|
|
1265
|
+
};
|
|
1266
|
+
const LinkRoutingControl = ({ edge }) => {
|
|
1267
|
+
const editor = useDiagramOptional();
|
|
1268
|
+
if (!editor)
|
|
1269
|
+
return null;
|
|
1270
|
+
const value = edge.routing ?? "straight";
|
|
1271
|
+
return (_jsx(SegmentedControl, { ariaLabel: "Link routing", value: value, options: [
|
|
1272
|
+
{ value: "straight", label: "Straight", icon: _jsx(RoutingIcon, { kind: "straight" }) },
|
|
1273
|
+
{ value: "orthogonal", label: "Elbow", icon: _jsx(RoutingIcon, { kind: "orthogonal" }) },
|
|
1274
|
+
{ value: "bezier", label: "Curved", icon: _jsx(Spline, { ...CONTROL_ICON }) },
|
|
1275
|
+
], onChange: (v) => {
|
|
1276
|
+
editor.updateSelectedLink((e) => ({ ...e, routing: v }));
|
|
1277
|
+
} }));
|
|
1278
|
+
};
|
|
1279
|
+
const LinkArrowheadControl = ({ edge, side, }) => {
|
|
1280
|
+
const editor = useDiagramOptional();
|
|
1281
|
+
const [showErd, setShowErd] = useState(false);
|
|
1282
|
+
if (!editor)
|
|
1283
|
+
return null;
|
|
1284
|
+
const current = edge.arrowheads?.[side] ?? "none";
|
|
1285
|
+
const pick = (style) => {
|
|
1286
|
+
editor.updateSelectedLink((e) => ({
|
|
1287
|
+
...e,
|
|
1288
|
+
arrowheads: { ...(e.arrowheads ?? {}), [side]: style },
|
|
1289
|
+
}));
|
|
1290
|
+
};
|
|
1291
|
+
const Option = ({ style }) => (_jsx("button", { type: "button", className: `du-arrowhead-option${style === current ? " du-arrowhead-option--active" : ""}`, title: ARROWHEAD_LABELS[style], "aria-label": ARROWHEAD_LABELS[style], "aria-pressed": style === current, onClick: () => {
|
|
1292
|
+
pick(style);
|
|
1293
|
+
}, children: style === "none" ? (_jsx("span", { className: "du-arrowhead-none", children: "\u2205" })) : arrowheadGlyphFamily(style) === "none" ? (_jsx("span", { className: "du-arrowhead-erd-label", children: ARROWHEAD_LABELS[style].replace(/^ERD /, "") })) : (_jsx(ArrowheadGlyph, { kind: style, side: side })) }));
|
|
1294
|
+
return (_jsx(Popover, { trigger: _jsx("button", { type: "button", className: "du-sel-icon-button", title: `Arrow ${side}: ${ARROWHEAD_LABELS[current]}`, "aria-label": `Arrow ${side}`, children: _jsx(ArrowheadGlyph, { kind: current, side: side }) }), children: _jsxs("div", { className: "du-sel-popover-section", children: [_jsxs("header", { className: "du-sel-popover-label", children: ["Arrow ", side] }), _jsx("div", { className: "du-arrowhead-grid", children: BASIC_ARROWHEADS.map((s) => (_jsx(Option, { style: s }, s))) }), _jsxs("button", { type: "button", className: "du-arrowhead-erd-toggle", "aria-expanded": showErd, onClick: () => {
|
|
1295
|
+
setShowErd((v) => !v);
|
|
1296
|
+
}, children: [showErd ? "▾" : "▸", " ER diagram"] }), showErd && (_jsx("div", { className: "du-arrowhead-grid", children: ERD_ARROWHEADS.map((s) => (_jsx(Option, { style: s }, s))) }))] }) }));
|
|
1297
|
+
};
|
|
1298
|
+
const LinkAutoRouteControl = ({ edge }) => {
|
|
1299
|
+
const editor = useDiagramOptional();
|
|
1300
|
+
if (!editor)
|
|
1301
|
+
return null;
|
|
1302
|
+
// Obstacle-avoidance produces an orthogonal path, so it only makes sense
|
|
1303
|
+
// for straight / elbow links — a curved (bezier) link can't carry the
|
|
1304
|
+
// routed polyline. Hide the toggle for curved.
|
|
1305
|
+
const routing = edge.routing ?? "straight";
|
|
1306
|
+
if (routing === "bezier")
|
|
1307
|
+
return null;
|
|
1308
|
+
const on = edge.avoidObstacles === true;
|
|
1309
|
+
return (_jsx("button", { type: "button", className: `du-sel-icon-button${on ? " is-active" : ""}`, title: "Route around shapes", "aria-label": "Route around shapes", "aria-pressed": on, onClick: () => {
|
|
1310
|
+
editor.setSelectedLinkAvoidObstacles(!on);
|
|
1311
|
+
}, children: _jsx(Waypoints, { ...CONTROL_ICON, "aria-hidden": true }) }));
|
|
1312
|
+
};
|
|
1313
|
+
const LinkDeleteControl = () => {
|
|
1314
|
+
const editor = useDiagramOptional();
|
|
1315
|
+
if (!editor)
|
|
1316
|
+
return null;
|
|
1317
|
+
return (_jsx("button", { type: "button", className: "du-sel-icon-button", title: "Delete edge", "aria-label": "Delete edge", onClick: () => {
|
|
1318
|
+
editor.deleteSelected();
|
|
1319
|
+
}, children: _jsx(Trash2, { ...CONTROL_ICON, "aria-hidden": true }) }));
|
|
1320
|
+
};
|
|
1321
|
+
// Inline SVG glyph for routing variant — Lucide has `Spline` for the
|
|
1322
|
+
// curve but no clean "straight" / "elbow" line variants.
|
|
1323
|
+
const RoutingIcon = ({ kind }) => {
|
|
1324
|
+
if (kind === "straight") {
|
|
1325
|
+
return (_jsx("svg", { width: 14, height: 14, viewBox: "0 0 14 14", fill: "none", "aria-hidden": true, children: _jsx("line", { x1: 2, y1: 11, x2: 12, y2: 3, stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round" }) }));
|
|
1326
|
+
}
|
|
1327
|
+
// orthogonal (elbow)
|
|
1328
|
+
return (_jsx("svg", { width: 14, height: 14, viewBox: "0 0 14 14", fill: "none", "aria-hidden": true, children: _jsx("path", { d: "M 2 11 L 2 7 L 12 7 L 12 3", stroke: "currentColor", strokeWidth: 1.5, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" }) }));
|
|
1329
|
+
};
|
|
1330
|
+
// Which compact glyph family renders a given arrowhead style.
|
|
1331
|
+
const arrowheadGlyphFamily = (kind) => {
|
|
1332
|
+
switch (kind) {
|
|
1333
|
+
case "arrow":
|
|
1334
|
+
case "openArrow":
|
|
1335
|
+
case "roundedArrow":
|
|
1336
|
+
case "arcArrow":
|
|
1337
|
+
return "open";
|
|
1338
|
+
case "triangle":
|
|
1339
|
+
case "filledArrow":
|
|
1340
|
+
return "triangle";
|
|
1341
|
+
case "diamond":
|
|
1342
|
+
case "rhombus":
|
|
1343
|
+
case "filledRhombus":
|
|
1344
|
+
return "diamond";
|
|
1345
|
+
case "circle":
|
|
1346
|
+
case "filledCircle":
|
|
1347
|
+
return "circle";
|
|
1348
|
+
default:
|
|
1349
|
+
return "none"; // none + ERD caps (labelled by name in the picker)
|
|
1350
|
+
}
|
|
1351
|
+
};
|
|
1352
|
+
// Picker option sets. ERD caps are gated behind a toggle.
|
|
1353
|
+
const BASIC_ARROWHEADS = [
|
|
1354
|
+
"none",
|
|
1355
|
+
"arrow",
|
|
1356
|
+
"openArrow",
|
|
1357
|
+
"roundedArrow",
|
|
1358
|
+
"arcArrow",
|
|
1359
|
+
"filledArrow",
|
|
1360
|
+
"triangle",
|
|
1361
|
+
"circle",
|
|
1362
|
+
"filledCircle",
|
|
1363
|
+
"rhombus",
|
|
1364
|
+
"filledRhombus",
|
|
1365
|
+
];
|
|
1366
|
+
const ERD_ARROWHEADS = [
|
|
1367
|
+
"erdOne",
|
|
1368
|
+
"erdOnlyOne",
|
|
1369
|
+
"erdMany",
|
|
1370
|
+
"erdOneOrMany",
|
|
1371
|
+
"erdZeroOrOne",
|
|
1372
|
+
"erdZeroOrMany",
|
|
1373
|
+
];
|
|
1374
|
+
const ARROWHEAD_LABELS = {
|
|
1375
|
+
none: "None",
|
|
1376
|
+
arrow: "Arrow",
|
|
1377
|
+
openArrow: "Open arrow",
|
|
1378
|
+
roundedArrow: "Rounded arrow",
|
|
1379
|
+
arcArrow: "Arc arrow",
|
|
1380
|
+
triangle: "Triangle",
|
|
1381
|
+
filledArrow: "Filled arrow",
|
|
1382
|
+
circle: "Circle",
|
|
1383
|
+
filledCircle: "Filled circle",
|
|
1384
|
+
diamond: "Diamond",
|
|
1385
|
+
rhombus: "Rhombus",
|
|
1386
|
+
filledRhombus: "Filled rhombus",
|
|
1387
|
+
erdOne: "ERD one",
|
|
1388
|
+
erdOnlyOne: "ERD only one",
|
|
1389
|
+
erdMany: "ERD many",
|
|
1390
|
+
erdOneOrMany: "ERD one or many",
|
|
1391
|
+
erdZeroOrOne: "ERD zero or one",
|
|
1392
|
+
erdZeroOrMany: "ERD zero or many",
|
|
1393
|
+
};
|
|
1394
|
+
const ArrowheadGlyph = ({ kind, side, }) => {
|
|
1395
|
+
// Build a 14×14 horizontal line with the head at right (for `to`) or
|
|
1396
|
+
// left (for `from`), all in stroke / fill currentColor.
|
|
1397
|
+
const flipped = side === "from";
|
|
1398
|
+
const lineX1 = flipped ? 12 : 2;
|
|
1399
|
+
const lineX2 = flipped ? 5 : 9;
|
|
1400
|
+
const headCx = flipped ? 3 : 11;
|
|
1401
|
+
const headBase = flipped ? 5 : 9;
|
|
1402
|
+
const stroke = (_jsx("line", { x1: lineX1, y1: 7, x2: lineX2, y2: 7, stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round" }));
|
|
1403
|
+
// Map the full vocabulary onto the four drawable glyph families. ERD caps
|
|
1404
|
+
// have no compact glyph — they fall through to a bare line; the picker
|
|
1405
|
+
// labels them by name so they stay distinguishable.
|
|
1406
|
+
const fam = arrowheadGlyphFamily(kind);
|
|
1407
|
+
let head = null;
|
|
1408
|
+
if (fam === "open") {
|
|
1409
|
+
head = (_jsx("polyline", { points: flipped
|
|
1410
|
+
? `${headCx + 3},4 ${headCx},7 ${headCx + 3},10`
|
|
1411
|
+
: `${headCx - 3},4 ${headCx},7 ${headCx - 3},10`, stroke: "currentColor", strokeWidth: 1.5, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" }));
|
|
1412
|
+
}
|
|
1413
|
+
else if (fam === "triangle") {
|
|
1414
|
+
head = (_jsx("polygon", { points: flipped
|
|
1415
|
+
? `${headCx},7 ${headBase},4 ${headBase},10`
|
|
1416
|
+
: `${headCx},7 ${headBase},4 ${headBase},10`, fill: "currentColor" }));
|
|
1417
|
+
}
|
|
1418
|
+
else if (fam === "diamond") {
|
|
1419
|
+
head = (_jsx("polygon", { points: `${headCx},4 ${headCx + (flipped ? -3 : 3)},7 ${headCx},10 ${headCx + (flipped ? 3 : -3)},7`, fill: kind === "filledRhombus" ? "currentColor" : "none", stroke: "currentColor", strokeWidth: 1.2 }));
|
|
1420
|
+
}
|
|
1421
|
+
else if (fam === "circle") {
|
|
1422
|
+
head = (_jsx("circle", { cx: headCx, cy: 7, r: 2.2, fill: kind === "filledCircle" ? "currentColor" : "none", stroke: "currentColor", strokeWidth: 1.2 }));
|
|
1423
|
+
}
|
|
1424
|
+
return (_jsxs("svg", { width: 14, height: 14, viewBox: "0 0 14 14", fill: "none", "aria-hidden": true, children: [stroke, head] }));
|
|
1425
|
+
};
|
|
1426
|
+
// ---------------------------------------------------------------------------
|
|
1427
|
+
// Layout primitives
|
|
1428
|
+
// ---------------------------------------------------------------------------
|
|
1429
|
+
const Divider = () => _jsx("span", { className: "du-sel-divider", "aria-hidden": true });
|
|
1430
|
+
/**
|
|
1431
|
+
* Pseudo-shape exposing a shape's LABEL as if it were a text element, so
|
|
1432
|
+
* the text controls read label values. A shape without a label yet shows
|
|
1433
|
+
* the defaults its first text will take (`seedLabel` in the editor).
|
|
1434
|
+
*/
|
|
1435
|
+
const labelView = (s) => ({
|
|
1436
|
+
...s,
|
|
1437
|
+
style: s.label?.style ?? {},
|
|
1438
|
+
fontSize: s.label?.fontSize ?? LABEL_DEFAULT_FONT_SIZE,
|
|
1439
|
+
fontFamily: s.label?.fontFamily ?? TEXT_DEFAULT_FONT_FAMILY,
|
|
1440
|
+
text: s.label?.text ?? "",
|
|
1441
|
+
runs: s.label?.runs,
|
|
1442
|
+
});
|
|
1443
|
+
/**
|
|
1444
|
+
* Split the control list on `<Divider />` markers into `.du-sel-group`
|
|
1445
|
+
* clusters. The separators themselves are CSS: a group draws one before
|
|
1446
|
+
* itself only when a NON-EMPTY group precedes it, so an optional cluster
|
|
1447
|
+
* whose controls all render `null` for this selection (no label text, no
|
|
1448
|
+
* crop, …) never leaves a stray or doubled divider.
|
|
1449
|
+
*/
|
|
1450
|
+
const groupControls = (nodes) => {
|
|
1451
|
+
const groups = [[]];
|
|
1452
|
+
for (const node of nodes) {
|
|
1453
|
+
if (isValidElement(node) && node.type === Divider)
|
|
1454
|
+
groups.push([]);
|
|
1455
|
+
else
|
|
1456
|
+
groups[groups.length - 1]?.push(node);
|
|
1457
|
+
}
|
|
1458
|
+
return groups
|
|
1459
|
+
.filter((g) => g.length > 0)
|
|
1460
|
+
.map((g, i) => (_jsx("span", { className: "du-sel-group", children: g }, `g-${String(i)}`)));
|
|
1461
|
+
};
|
|
1462
|
+
// ---------------------------------------------------------------------------
|
|
1463
|
+
// Inline SVG glyphs
|
|
1464
|
+
// ---------------------------------------------------------------------------
|
|
1465
|
+
const StrokeWidthIcon = ({ thickness }) => (_jsx("svg", { width: 14, height: 14, viewBox: "0 0 14 14", fill: "none", "aria-hidden": true, children: _jsx("line", { x1: 2, y1: 7, x2: 12, y2: 7, stroke: "currentColor", strokeWidth: thickness, strokeLinecap: "round" }) }));
|
|
1466
|
+
const CornerIcon = ({ kind }) => {
|
|
1467
|
+
if (kind === "sharp") {
|
|
1468
|
+
return _jsx(Square, { ...CONTROL_ICON, "aria-hidden": true });
|
|
1469
|
+
}
|
|
1470
|
+
return (_jsx("svg", { width: 14, height: 14, viewBox: "0 0 14 14", fill: "none", "aria-hidden": true, children: _jsx("rect", { x: 2, y: 2, width: 10, height: 10, rx: 3, ry: 3, stroke: "currentColor", strokeWidth: 1.5 }) }));
|
|
1471
|
+
};
|
|
1472
|
+
// ---------------------------------------------------------------------------
|
|
1473
|
+
// Helpers
|
|
1474
|
+
// ---------------------------------------------------------------------------
|
|
1475
|
+
const sharedValue = (elements, pick) => {
|
|
1476
|
+
const set = new Set();
|
|
1477
|
+
for (const s of elements)
|
|
1478
|
+
set.add(pick(s));
|
|
1479
|
+
if (set.size !== 1)
|
|
1480
|
+
return null;
|
|
1481
|
+
const v = set.values().next().value;
|
|
1482
|
+
return v ?? null;
|
|
1483
|
+
};
|
|
1484
|
+
const sharedString = (elements, pick) => {
|
|
1485
|
+
const value = sharedValue(elements, (s) => pick(s));
|
|
1486
|
+
return typeof value === "string" ? value : null;
|
|
1487
|
+
};
|
|
1488
|
+
// Frames are always fillable (white by default) even when `style.fill` is
|
|
1489
|
+
// unset.
|
|
1490
|
+
const hasFill = (shape) => shape.style.fill !== undefined || isFrame(shape);
|
|
1491
|
+
const hasStroke = (shape) => shape.style.stroke !== undefined;
|
|
1492
|
+
//# sourceMappingURL=property-panel.js.map
|