@elixpo/lixsketch 5.4.7 → 5.5.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/dist/react/AIRenderer-M4LMJ66J.js +1105 -0
- package/dist/react/AIRenderer-M4LMJ66J.js.map +7 -0
- package/dist/react/Arrow-BJDAXPMH.js +8 -0
- package/dist/react/Arrow-BJDAXPMH.js.map +7 -0
- package/dist/react/Circle-KPG7TKIX.js +8 -0
- package/dist/react/Circle-KPG7TKIX.js.map +7 -0
- package/dist/react/CodeShape-K4DIZAIV.js +8 -0
- package/dist/react/CodeShape-K4DIZAIV.js.map +7 -0
- package/dist/react/CopyPaste-4CKJRHJ3.js +569 -0
- package/dist/react/CopyPaste-4CKJRHJ3.js.map +7 -0
- package/dist/react/EraserTrail-PA6IQLQN.js +20 -0
- package/dist/react/EraserTrail-PA6IQLQN.js.map +7 -0
- package/dist/react/EventDispatcher-SRFH6LQZ.js +463 -0
- package/dist/react/EventDispatcher-SRFH6LQZ.js.map +7 -0
- package/dist/react/Frame-6UR5XPAX.js +10 -0
- package/dist/react/Frame-6UR5XPAX.js.map +7 -0
- package/dist/react/FreehandStroke-CK24NUT3.js +8 -0
- package/dist/react/FreehandStroke-CK24NUT3.js.map +7 -0
- package/dist/react/GraphEngine-QE5B2PMR.js +340 -0
- package/dist/react/GraphEngine-QE5B2PMR.js.map +7 -0
- package/dist/react/GraphMathParser-2WMTY4IE.js +10 -0
- package/dist/react/GraphMathParser-2WMTY4IE.js.map +7 -0
- package/dist/react/IconShape-KMIA6T2H.js +10 -0
- package/dist/react/IconShape-KMIA6T2H.js.map +7 -0
- package/dist/react/ImageShape-CHHGBXUQ.js +10 -0
- package/dist/react/ImageShape-CHHGBXUQ.js.map +7 -0
- package/dist/react/LayerOrder-XCHF4MSS.js +66 -0
- package/dist/react/LayerOrder-XCHF4MSS.js.map +7 -0
- package/dist/react/Line-2KOQEEGA.js +10 -0
- package/dist/react/Line-2KOQEEGA.js.map +7 -0
- package/dist/react/LixScriptParser-JYWJX375.js +1033 -0
- package/dist/react/LixScriptParser-JYWJX375.js.map +7 -0
- package/dist/react/MermaidFlowchartRenderer-4QBAZ5MB.js +412 -0
- package/dist/react/MermaidFlowchartRenderer-4QBAZ5MB.js.map +7 -0
- package/dist/react/MermaidSequenceParser-UKG2NSZY.js +8 -0
- package/dist/react/MermaidSequenceParser-UKG2NSZY.js.map +7 -0
- package/dist/react/MermaidSequenceRenderer-HPLHY5II.js +387 -0
- package/dist/react/MermaidSequenceRenderer-HPLHY5II.js.map +7 -0
- package/dist/react/Rectangle-6FE7RN6W.js +8 -0
- package/dist/react/Rectangle-6FE7RN6W.js.map +7 -0
- package/dist/react/ResizeCode-WAMDTKSW.js +2 -0
- package/dist/react/ResizeCode-WAMDTKSW.js.map +7 -0
- package/dist/react/ResizeShapes-LKEQTITP.js +250 -0
- package/dist/react/ResizeShapes-LKEQTITP.js.map +7 -0
- package/dist/react/SceneSerializer-4U4B24JM.js +739 -0
- package/dist/react/SceneSerializer-4U4B24JM.js.map +7 -0
- package/dist/react/Selection-VVUHY5YJ.js +34 -0
- package/dist/react/Selection-VVUHY5YJ.js.map +7 -0
- package/dist/react/SketchEngine-YA4RG3KX.js +413 -0
- package/dist/react/SketchEngine-YA4RG3KX.js.map +7 -0
- package/dist/react/TextShape-54ZJ66E3.js +8 -0
- package/dist/react/TextShape-54ZJ66E3.js.map +7 -0
- package/dist/react/UndoRedo-M2JZUBKN.js +30 -0
- package/dist/react/UndoRedo-M2JZUBKN.js.map +7 -0
- package/dist/react/ZoomPan-U3TXC5AJ.js +186 -0
- package/dist/react/ZoomPan-U3TXC5AJ.js.map +7 -0
- package/dist/react/arrowTool-CWEMIRXK.js +17 -0
- package/dist/react/arrowTool-CWEMIRXK.js.map +7 -0
- package/dist/react/boxicons-5JNJQPUP.woff +0 -0
- package/dist/react/boxicons-6NVWQSCQ.eot +0 -0
- package/dist/react/boxicons-AQ5N6WP7.woff2 +0 -0
- package/dist/react/boxicons-GDR4JU3X.ttf +0 -0
- package/dist/react/boxicons-NNFHLMOR.svg +1660 -0
- package/dist/react/chunk-2OQXI7QB.js +564 -0
- package/dist/react/chunk-2OQXI7QB.js.map +7 -0
- package/dist/react/chunk-5OUBTHHO.js +187 -0
- package/dist/react/chunk-5OUBTHHO.js.map +7 -0
- package/dist/react/chunk-6PFXZKL2.js +628 -0
- package/dist/react/chunk-6PFXZKL2.js.map +7 -0
- package/dist/react/chunk-6SFNWGGZ.js +453 -0
- package/dist/react/chunk-6SFNWGGZ.js.map +7 -0
- package/dist/react/chunk-7TZSIR6V.js +210 -0
- package/dist/react/chunk-7TZSIR6V.js.map +7 -0
- package/dist/react/chunk-AJAZC37E.js +662 -0
- package/dist/react/chunk-AJAZC37E.js.map +7 -0
- package/dist/react/chunk-BU4D2WYQ.js +1213 -0
- package/dist/react/chunk-BU4D2WYQ.js.map +7 -0
- package/dist/react/chunk-BZE4CEBG.js +747 -0
- package/dist/react/chunk-BZE4CEBG.js.map +7 -0
- package/dist/react/chunk-CB2RANCB.js +212 -0
- package/dist/react/chunk-CB2RANCB.js.map +7 -0
- package/dist/react/chunk-DNAG7ML6.js +128 -0
- package/dist/react/chunk-DNAG7ML6.js.map +7 -0
- package/dist/react/chunk-HZWLPXFN.js +198 -0
- package/dist/react/chunk-HZWLPXFN.js.map +7 -0
- package/dist/react/chunk-I6HCCIXB.js +572 -0
- package/dist/react/chunk-I6HCCIXB.js.map +7 -0
- package/dist/react/chunk-IZ5OGWGW.js +235 -0
- package/dist/react/chunk-IZ5OGWGW.js.map +7 -0
- package/dist/react/chunk-JJCJNLNN.js +1490 -0
- package/dist/react/chunk-JJCJNLNN.js.map +7 -0
- package/dist/react/chunk-JQ3IRFDP.js +222 -0
- package/dist/react/chunk-JQ3IRFDP.js.map +7 -0
- package/dist/react/chunk-L5OARZJ7.js +1134 -0
- package/dist/react/chunk-L5OARZJ7.js.map +7 -0
- package/dist/react/chunk-LHRTXNVE.js +1600 -0
- package/dist/react/chunk-LHRTXNVE.js.map +7 -0
- package/dist/react/chunk-Q2GA4MBZ.js +1653 -0
- package/dist/react/chunk-Q2GA4MBZ.js.map +7 -0
- package/dist/react/chunk-QNSE5BAY.js +519 -0
- package/dist/react/chunk-QNSE5BAY.js.map +7 -0
- package/dist/react/chunk-QXFBG7OM.js +191 -0
- package/dist/react/chunk-QXFBG7OM.js.map +7 -0
- package/dist/react/chunk-T6ZVV5ZO.js +714 -0
- package/dist/react/chunk-T6ZVV5ZO.js.map +7 -0
- package/dist/react/chunk-URB6MY46.js +974 -0
- package/dist/react/chunk-URB6MY46.js.map +7 -0
- package/dist/react/chunk-VN62KLOC.js +931 -0
- package/dist/react/chunk-VN62KLOC.js.map +7 -0
- package/dist/react/chunk-VQVEPJSA.js +158 -0
- package/dist/react/chunk-VQVEPJSA.js.map +7 -0
- package/dist/react/chunk-XISEAGBV.js +1632 -0
- package/dist/react/chunk-XISEAGBV.js.map +7 -0
- package/dist/react/chunk-YKJUBFHE.js +575 -0
- package/dist/react/chunk-YKJUBFHE.js.map +7 -0
- package/dist/react/circleTool-NUATSEI2.js +14 -0
- package/dist/react/circleTool-NUATSEI2.js.map +7 -0
- package/dist/react/codeTool-MHCB2FNB.js +38 -0
- package/dist/react/codeTool-MHCB2FNB.js.map +7 -0
- package/dist/react/eraserTool-2GS2LQCS.js +103 -0
- package/dist/react/eraserTool-2GS2LQCS.js.map +7 -0
- package/dist/react/frameTool-PWVKKXSP.js +14 -0
- package/dist/react/frameTool-PWVKKXSP.js.map +7 -0
- package/dist/react/freehandTool-SLYGASVP.js +14 -0
- package/dist/react/freehandTool-SLYGASVP.js.map +7 -0
- package/dist/react/iconTool-GPZ7O7NH.js +18 -0
- package/dist/react/iconTool-GPZ7O7NH.js.map +7 -0
- package/dist/react/imageTool-D5R4XGEU.js +15 -0
- package/dist/react/imageTool-D5R4XGEU.js.map +7 -0
- package/dist/react/index.js +5342 -0
- package/dist/react/index.js.map +7 -0
- package/dist/react/laserTool-XVMV63AV.js +239 -0
- package/dist/react/laserTool-XVMV63AV.js.map +7 -0
- package/dist/react/lineTool-WNI5EMZ5.js +14 -0
- package/dist/react/lineTool-WNI5EMZ5.js.map +7 -0
- package/dist/react/lixCode-OJMVUXXI.ttf +0 -0
- package/dist/react/lixDefault-JEAPAUJL.ttf +0 -0
- package/dist/react/lixFancy-BPXYM5CQ.ttf +0 -0
- package/dist/react/lixFont-CPKEUDVM.woff2 +0 -0
- package/dist/react/rectangleTool-U7DZ6ZTA.js +14 -0
- package/dist/react/rectangleTool-U7DZ6ZTA.js.map +7 -0
- package/dist/react/styles.css +6627 -0
- package/dist/react/textTool-M7S6F3VC.js +21 -0
- package/dist/react/textTool-M7S6F3VC.js.map +7 -0
- package/package.json +39 -5
- package/src/EngineShortcuts.js +83 -27
- package/src/core/AIRenderer.js +30 -18
- package/src/core/GraphEngine.js +64 -41
- package/src/core/MermaidFlowchartRenderer.js +153 -88
- package/src/core/MermaidSequenceRenderer.js +168 -93
- package/src/core/SceneSerializer.js +86 -2
- package/src/core/Selection.js +119 -19
- package/src/core/UndoRedo.js +82 -15
- package/src/core/ZoomPan.js +15 -5
- package/src/index.js +14 -1
- package/src/react/LixSketchCanvas.jsx +347 -0
- package/src/react/components/AppMenu.jsx +475 -0
- package/src/react/components/Footer.jsx +85 -0
- package/src/react/components/Toolbar.jsx +140 -0
- package/src/react/components/canvas/CanvasLoadingOverlay.jsx +76 -0
- package/src/react/components/canvas/ContextMenu.jsx +466 -0
- package/src/react/components/canvas/FindBar.jsx +177 -0
- package/src/react/components/canvas/ImageSourcePicker.jsx +133 -0
- package/src/react/components/canvas/MultiSelectActions.jsx +120 -0
- package/src/react/components/canvas/SVGCanvas.jsx +145 -0
- package/src/react/components/canvas/SaveStatusIndicator.jsx +29 -0
- package/src/react/components/modals/CanvasPropertiesModal.jsx +358 -0
- package/src/react/components/modals/CommandPalette.jsx +307 -0
- package/src/react/components/modals/ExportImageModal.jsx +287 -0
- package/src/react/components/modals/GraphModal.jsx +174 -0
- package/src/react/components/modals/HelpModal.jsx +208 -0
- package/src/react/components/modals/LixScriptModal.jsx +130 -0
- package/src/react/components/modals/ShortcutsModal.jsx +125 -0
- package/src/react/components/sidebars/ArrowSidebar.jsx +173 -0
- package/src/react/components/sidebars/CircleSidebar.jsx +134 -0
- package/src/react/components/sidebars/FrameSidebar.jsx +263 -0
- package/src/react/components/sidebars/IconSidebar.jsx +279 -0
- package/src/react/components/sidebars/ImageSidebar.jsx +69 -0
- package/src/react/components/sidebars/LineSidebar.jsx +109 -0
- package/src/react/components/sidebars/PaintbrushSidebar.jsx +119 -0
- package/src/react/components/sidebars/RectangleSidebar.jsx +165 -0
- package/src/react/components/sidebars/ShapeSidebar.jsx +130 -0
- package/src/react/components/sidebars/TextSidebar.jsx +174 -0
- package/src/react/hooks/inertStores.js +58 -0
- package/src/react/hooks/useSketchEngine.js +76 -0
- package/src/react/hooks/useTranslation.js +92 -0
- package/src/react/index.js +41 -0
- package/src/react/store/useSketchStore.js +234 -0
- package/src/react/store/useUIStore.js +196 -0
- package/src/react/styles/arrowSidebar.css +263 -0
- package/src/react/styles/canvasStroke.css +47 -0
- package/src/react/styles/canvasTools.css +769 -0
- package/src/react/styles/circleSidebar.css +206 -0
- package/src/react/styles/fonts/fonts.css +29 -0
- package/src/react/styles/fonts/lixCode.ttf +0 -0
- package/src/react/styles/fonts/lixDefault.ttf +0 -0
- package/src/react/styles/fonts/lixDocs.ttf +0 -0
- package/src/react/styles/fonts/lixFancy.ttf +0 -0
- package/src/react/styles/fonts/lixFont.woff2 +0 -0
- package/src/react/styles/lineSidebar.css +207 -0
- package/src/react/styles/paintbrushSidebar.css +247 -0
- package/src/react/styles/squareSidebar.css +206 -0
- package/src/react/styles/writeText.css +149 -0
- package/src/react/styles.css +175 -0
- package/src/react/toolsEnum.js +20 -0
- package/src/react/utils/toast.js +18 -0
- package/src/shapes/Frame.js +7 -1
- package/src/shapes/Line.js +11 -5
- package/src/tools/arrowTool.js +30 -4
- package/src/tools/circleTool.js +33 -9
- package/src/tools/codeTool.js +5 -3
- package/src/tools/frameTool.js +47 -7
- package/src/tools/freehandTool.js +12 -4
- package/src/tools/iconTool.js +7 -1
- package/src/tools/imageTool.js +7 -1
- package/src/tools/lineTool.js +27 -3
- package/src/tools/rectangleTool.js +45 -8
- package/src/tools/textTool.js +23 -6
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import useSketchStore, { TOOLS } from '../store/useSketchStore'
|
|
4
|
+
import useUIStore from '../store/useUIStore'
|
|
5
|
+
|
|
6
|
+
// Items are built lazily inside the component body so esbuild's lazy ESM
|
|
7
|
+
// wrapper can't evaluate them before the store module finishes init.
|
|
8
|
+
function getViewModeItems() {
|
|
9
|
+
return [
|
|
10
|
+
{ tool: TOOLS.PAN, icon: 'bxs-hand', title: 'Pan (H)', key: 'H' },
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getToolItems() {
|
|
15
|
+
return [
|
|
16
|
+
{ tool: TOOLS.PAN, icon: 'bxs-hand', title: 'Pan (H)', key: 'H' },
|
|
17
|
+
{ tool: TOOLS.SELECT, icon: 'bxs-pointer', title: 'Select (V)', key: 'V' },
|
|
18
|
+
'spacer',
|
|
19
|
+
{ tool: TOOLS.RECTANGLE, icon: 'bx-square', title: 'Rectangle (R)', key: 'R' },
|
|
20
|
+
{ tool: TOOLS.CIRCLE, icon: 'bx-circle', title: 'Circle (O)', key: 'O' },
|
|
21
|
+
{ tool: TOOLS.LINE, icon: 'bx-minus', title: 'Line (L)', key: 'L' },
|
|
22
|
+
{ tool: TOOLS.ARROW, icon: 'bx-right-arrow-alt', title: 'Arrow (A)', rotate: true, key: 'A' },
|
|
23
|
+
{ tool: TOOLS.TEXT, icon: 'bx-text', title: 'Text (T)', key: 'T' },
|
|
24
|
+
{ tool: TOOLS.FREEHAND, icon: 'bx-pen', title: 'Freehand (P)', key: 'P' },
|
|
25
|
+
{ tool: TOOLS.IMAGE, icon: 'bx-image-alt', title: 'Image (9)', key: '9' },
|
|
26
|
+
{ tool: TOOLS.ICON, icon: 'bx-wink-smile', title: 'Icon (I)', key: 'I' },
|
|
27
|
+
'spacer',
|
|
28
|
+
{ tool: TOOLS.FRAME, icon: 'bx-crop', title: 'Frame (F)', key: 'F' },
|
|
29
|
+
{ tool: TOOLS.LASER, icon: 'bxs-magic-wand', title: 'Laser (K)', key: 'K' },
|
|
30
|
+
{ tool: TOOLS.ERASER, icon: 'bxs-eraser', title: 'Eraser (E)', key: 'E' },
|
|
31
|
+
// AI tool entry removed while the assistant is coming-soon.
|
|
32
|
+
// Restore `{ tool: 'ai', icon: null, title: 'AI', isAI: true }` here
|
|
33
|
+
// when the modal becomes a real feature again.
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default function Toolbar() {
|
|
38
|
+
const activeTool = useSketchStore((s) => s.activeTool)
|
|
39
|
+
const setActiveTool = useSketchStore((s) => s.setActiveTool)
|
|
40
|
+
const viewMode = useSketchStore((s) => s.viewMode)
|
|
41
|
+
const toolLock = useSketchStore((s) => s.toolLock)
|
|
42
|
+
const toggleToolLock = useSketchStore((s) => s.toggleToolLock)
|
|
43
|
+
const toggleAIModal = useUIStore((s) => s.toggleAIModal)
|
|
44
|
+
|
|
45
|
+
const items = viewMode ? getViewModeItems() : getToolItems()
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<>
|
|
49
|
+
<div className={`absolute left-2.5 top-1/2 -translate-y-1/2 w-[46px] rounded-xl bg-surface z-[1000] flex flex-col items-center py-1.5 gap-0.5 font-[lixFont] max-h-[calc(100vh-120px)] overflow-y-auto no-scrollbar`}>
|
|
50
|
+
{/* Tool lock button at the top */}
|
|
51
|
+
{!viewMode && (
|
|
52
|
+
<>
|
|
53
|
+
<button
|
|
54
|
+
title="Tool Lock (Q)"
|
|
55
|
+
onClick={toggleToolLock}
|
|
56
|
+
className={`relative w-[33px] h-[30px] flex items-center justify-center rounded-lg transition-all duration-200 ${
|
|
57
|
+
toolLock
|
|
58
|
+
? 'bg-accent-blue/20 text-accent-blue'
|
|
59
|
+
: 'text-text-dim hover:text-text-muted hover:bg-surface-hover'
|
|
60
|
+
}`}
|
|
61
|
+
>
|
|
62
|
+
<i className={`bx ${toolLock ? 'bxs-lock-alt' : 'bx-lock-alt'} text-lg`} />
|
|
63
|
+
<span className="absolute bottom-0.5 right-[-1px] text-[10px] leading-none opacity-50">Q</span>
|
|
64
|
+
</button>
|
|
65
|
+
<div className="w-6 h-px bg-border-light my-0.5" />
|
|
66
|
+
</>
|
|
67
|
+
)}
|
|
68
|
+
|
|
69
|
+
{items.map((item, idx) => {
|
|
70
|
+
if (item === 'spacer') {
|
|
71
|
+
return (
|
|
72
|
+
<div
|
|
73
|
+
key={`spacer-${idx}`}
|
|
74
|
+
className="w-6 h-px bg-border-light my-0.5"
|
|
75
|
+
/>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const isActive = activeTool === item.tool
|
|
80
|
+
|
|
81
|
+
if (item.isAI) {
|
|
82
|
+
return (
|
|
83
|
+
<button
|
|
84
|
+
key="ai"
|
|
85
|
+
title={item.title}
|
|
86
|
+
onClick={toggleAIModal}
|
|
87
|
+
className="w-[33px] h-[31px] flex items-center justify-center rounded-lg text-text-muted hover:text-accent hover:bg-surface-hover transition-all duration-200"
|
|
88
|
+
>
|
|
89
|
+
<svg
|
|
90
|
+
width="20"
|
|
91
|
+
height="20"
|
|
92
|
+
viewBox="0 0 24 24"
|
|
93
|
+
fill="none"
|
|
94
|
+
stroke="currentColor"
|
|
95
|
+
strokeWidth="2"
|
|
96
|
+
strokeLinecap="round"
|
|
97
|
+
strokeLinejoin="round"
|
|
98
|
+
>
|
|
99
|
+
<path d="M12 3l1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5L12 3z" />
|
|
100
|
+
<path d="M18 14l1 3 3 1-3 1-1 3-1-3-3-1 3-1 1-3z" />
|
|
101
|
+
</svg>
|
|
102
|
+
</button>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<button
|
|
108
|
+
key={item.tool}
|
|
109
|
+
title={item.title}
|
|
110
|
+
onClick={() => setActiveTool(item.tool)}
|
|
111
|
+
className={`relative w-[33px] h-[31px] flex items-center justify-center rounded-lg transition-all duration-200 ${
|
|
112
|
+
isActive
|
|
113
|
+
? 'bg-surface-active text-text-primary'
|
|
114
|
+
: 'text-text-muted hover:text-text-primary hover:bg-surface-hover'
|
|
115
|
+
}`}
|
|
116
|
+
>
|
|
117
|
+
<i
|
|
118
|
+
className={`bx ${item.icon} text-xl`}
|
|
119
|
+
style={item.rotate ? { transform: 'rotate(-45deg)' } : undefined}
|
|
120
|
+
/>
|
|
121
|
+
{item.key && (
|
|
122
|
+
<span className={`absolute bottom-0.5 right-[-1px] text-[10px] leading-none ${isActive ? 'opacity-60' : 'opacity-35'}`}>
|
|
123
|
+
{item.key}
|
|
124
|
+
</span>
|
|
125
|
+
)}
|
|
126
|
+
</button>
|
|
127
|
+
)
|
|
128
|
+
})}
|
|
129
|
+
</div>
|
|
130
|
+
{viewMode && (
|
|
131
|
+
<div className="absolute top-16 left-2.5 w-[46px] z-[1000] flex justify-center font-[lixFont]">
|
|
132
|
+
<span className="text-text-dim text-[9px] text-center leading-tight">
|
|
133
|
+
View<br/>Mode<br/>
|
|
134
|
+
<kbd className="text-[8px] text-text-muted">Esc</kbd>
|
|
135
|
+
</span>
|
|
136
|
+
</div>
|
|
137
|
+
)}
|
|
138
|
+
</>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import useUIStore from '../../store/useUIStore'
|
|
4
|
+
|
|
5
|
+
export default function CanvasLoadingOverlay() {
|
|
6
|
+
const loading = useUIStore((s) => s.canvasLoading)
|
|
7
|
+
const message = useUIStore((s) => s.canvasLoadingMessage)
|
|
8
|
+
|
|
9
|
+
if (!loading) return null
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm font-[lixFont] pointer-events-all">
|
|
13
|
+
<div className="flex flex-col items-center gap-5">
|
|
14
|
+
<style>{`
|
|
15
|
+
@keyframes cl-glob {
|
|
16
|
+
0%, 100% { transform: translate(0, 0) scale(1); opacity: 0.7; }
|
|
17
|
+
25% { transform: translate(10px, -6px) scale(1.12); opacity: 0.9; }
|
|
18
|
+
50% { transform: translate(-5px, 8px) scale(0.9); opacity: 0.6; }
|
|
19
|
+
75% { transform: translate(-8px, -4px) scale(1.08); opacity: 0.85; }
|
|
20
|
+
}
|
|
21
|
+
@keyframes cl-glob-2 {
|
|
22
|
+
0%, 100% { transform: translate(0, 0) scale(1); opacity: 0.6; }
|
|
23
|
+
25% { transform: translate(-8px, 5px) scale(1.1); opacity: 0.85; }
|
|
24
|
+
50% { transform: translate(6px, -8px) scale(0.95); opacity: 0.7; }
|
|
25
|
+
75% { transform: translate(5px, 6px) scale(1.12); opacity: 0.9; }
|
|
26
|
+
}
|
|
27
|
+
@keyframes cl-float {
|
|
28
|
+
0%, 100% { transform: translateY(0); }
|
|
29
|
+
50% { transform: translateY(-3px); }
|
|
30
|
+
}
|
|
31
|
+
`}</style>
|
|
32
|
+
|
|
33
|
+
<div className="relative w-20 h-20">
|
|
34
|
+
<div className="absolute rounded-full" style={{
|
|
35
|
+
width: 38, height: 38, top: 2, left: 4,
|
|
36
|
+
background: 'radial-gradient(circle, rgba(59,130,246,0.5) 0%, transparent 70%)',
|
|
37
|
+
filter: 'blur(10px)',
|
|
38
|
+
animation: 'cl-glob 3.5s ease-in-out infinite',
|
|
39
|
+
willChange: 'transform, opacity',
|
|
40
|
+
}} />
|
|
41
|
+
<div className="absolute rounded-full" style={{
|
|
42
|
+
width: 34, height: 34, top: 14, right: 2,
|
|
43
|
+
background: 'radial-gradient(circle, rgba(168,85,247,0.45) 0%, transparent 70%)',
|
|
44
|
+
filter: 'blur(10px)',
|
|
45
|
+
animation: 'cl-glob-2 4s ease-in-out infinite',
|
|
46
|
+
willChange: 'transform, opacity',
|
|
47
|
+
}} />
|
|
48
|
+
<div className="absolute rounded-full" style={{
|
|
49
|
+
width: 30, height: 30, bottom: 2, left: 12,
|
|
50
|
+
background: 'radial-gradient(circle, rgba(52,211,153,0.4) 0%, transparent 70%)',
|
|
51
|
+
filter: 'blur(10px)',
|
|
52
|
+
animation: 'cl-glob 4.2s ease-in-out infinite reverse',
|
|
53
|
+
willChange: 'transform, opacity',
|
|
54
|
+
}} />
|
|
55
|
+
<div className="absolute inset-0 flex items-center justify-center" style={{ animation: 'cl-float 2.5s ease-in-out infinite' }}>
|
|
56
|
+
<div className="w-9 h-9 rounded-lg bg-white/[0.06] backdrop-blur-sm border border-white/[0.08] flex items-center justify-center">
|
|
57
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="opacity-70">
|
|
58
|
+
<rect x="3" y="3" width="7" height="7" rx="1" />
|
|
59
|
+
<rect x="14" y="3" width="7" height="7" rx="1" />
|
|
60
|
+
<rect x="3" y="14" width="7" height="7" rx="1" />
|
|
61
|
+
<rect x="14" y="14" width="7" height="7" rx="1" />
|
|
62
|
+
</svg>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div className="flex items-center gap-1.5">
|
|
68
|
+
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-bounce" style={{ animationDelay: '0ms' }} />
|
|
69
|
+
<span className="w-1.5 h-1.5 rounded-full bg-purple-400 animate-bounce" style={{ animationDelay: '150ms' }} />
|
|
70
|
+
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-bounce" style={{ animationDelay: '300ms' }} />
|
|
71
|
+
</div>
|
|
72
|
+
<p className="text-text-dim text-sm">{message}</p>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
4
|
+
import useSketchStore from '../../store/useSketchStore'
|
|
5
|
+
import useUIStore from '../../store/useUIStore'
|
|
6
|
+
import { showToast } from '../../utils/toast'
|
|
7
|
+
|
|
8
|
+
// ── Helpers ──────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
function getCleanSVG() {
|
|
11
|
+
const svgEl = window.svg
|
|
12
|
+
if (!svgEl) return null
|
|
13
|
+
const clone = svgEl.cloneNode(true)
|
|
14
|
+
clone.querySelectorAll(
|
|
15
|
+
'[data-selection], .selection-handle, .resize-handle, .rotation-handle, .anchor, .rotate-anchor, .resize-anchor, .rotation-anchor, .selection-outline'
|
|
16
|
+
).forEach((el) => el.remove())
|
|
17
|
+
return clone
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function renderToCanvas(clone, scale, bgColor) {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
const svgData = new XMLSerializer().serializeToString(clone)
|
|
23
|
+
const vb = window.svg.viewBox.baseVal
|
|
24
|
+
const canvas = document.createElement('canvas')
|
|
25
|
+
canvas.width = vb.width * scale
|
|
26
|
+
canvas.height = vb.height * scale
|
|
27
|
+
const ctx = canvas.getContext('2d')
|
|
28
|
+
ctx.scale(scale, scale)
|
|
29
|
+
const img = new Image()
|
|
30
|
+
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' })
|
|
31
|
+
const url = URL.createObjectURL(blob)
|
|
32
|
+
img.onload = () => {
|
|
33
|
+
if (bgColor) {
|
|
34
|
+
ctx.fillStyle = bgColor
|
|
35
|
+
ctx.fillRect(0, 0, vb.width, vb.height)
|
|
36
|
+
}
|
|
37
|
+
ctx.drawImage(img, 0, 0, vb.width, vb.height)
|
|
38
|
+
URL.revokeObjectURL(url)
|
|
39
|
+
resolve(canvas)
|
|
40
|
+
}
|
|
41
|
+
img.onerror = () => { URL.revokeObjectURL(url); resolve(null) }
|
|
42
|
+
img.src = url
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getShapeAtPoint(x, y) {
|
|
47
|
+
const shapes = window.shapes
|
|
48
|
+
if (!shapes) return null
|
|
49
|
+
// Walk backwards (topmost first)
|
|
50
|
+
for (let i = shapes.length - 1; i >= 0; i--) {
|
|
51
|
+
const shape = shapes[i]
|
|
52
|
+
const el = shape.group || shape.element
|
|
53
|
+
if (!el) continue
|
|
54
|
+
const rect = el.getBoundingClientRect()
|
|
55
|
+
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
|
|
56
|
+
return shape
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Menu item components ─────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
function MenuItem({ label, shortcut, onClick, disabled, danger }) {
|
|
65
|
+
return (
|
|
66
|
+
<button
|
|
67
|
+
onClick={onClick}
|
|
68
|
+
disabled={disabled}
|
|
69
|
+
className={`w-full flex items-center justify-between px-3 py-1.5 text-xs transition-colors duration-100 ${
|
|
70
|
+
disabled
|
|
71
|
+
? 'text-text-dim cursor-default'
|
|
72
|
+
: danger
|
|
73
|
+
? 'text-red-400 hover:bg-red-500/15 cursor-pointer'
|
|
74
|
+
: 'text-text-secondary hover:bg-surface-hover cursor-pointer'
|
|
75
|
+
}`}
|
|
76
|
+
>
|
|
77
|
+
<span>{label}</span>
|
|
78
|
+
{shortcut && <span className="text-text-dim text-[10px] ml-6">{shortcut}</span>}
|
|
79
|
+
</button>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function CheckMenuItem({ label, shortcut, checked, onClick }) {
|
|
84
|
+
return (
|
|
85
|
+
<button
|
|
86
|
+
onClick={onClick}
|
|
87
|
+
className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-text-secondary hover:bg-surface-hover cursor-pointer transition-colors duration-100"
|
|
88
|
+
>
|
|
89
|
+
<span className="flex items-center gap-2">
|
|
90
|
+
{checked && <i className="bx bx-check text-sm text-accent-blue -ml-0.5" />}
|
|
91
|
+
{!checked && <span className="w-[14px]" />}
|
|
92
|
+
{label}
|
|
93
|
+
</span>
|
|
94
|
+
{shortcut && <span className="text-text-dim text-[10px] ml-6">{shortcut}</span>}
|
|
95
|
+
</button>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function Separator() {
|
|
100
|
+
return <hr className="border-border-light my-1" />
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Main component ───────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
export default function ContextMenu() {
|
|
106
|
+
const [visible, setVisible] = useState(false)
|
|
107
|
+
const [position, setPosition] = useState({ x: 0, y: 0 })
|
|
108
|
+
const [targetShape, setTargetShape] = useState(null)
|
|
109
|
+
const menuRef = useRef(null)
|
|
110
|
+
|
|
111
|
+
const gridEnabled = useSketchStore((s) => s.gridEnabled)
|
|
112
|
+
const snapToObjects = useSketchStore((s) => s.snapToObjects)
|
|
113
|
+
const zenMode = useSketchStore((s) => s.zenMode)
|
|
114
|
+
const viewMode = useSketchStore((s) => s.viewMode)
|
|
115
|
+
|
|
116
|
+
const close = useCallback(() => {
|
|
117
|
+
setVisible(false)
|
|
118
|
+
setTargetShape(null)
|
|
119
|
+
}, [])
|
|
120
|
+
|
|
121
|
+
// Context menu handler
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
const handleContextMenu = (e) => {
|
|
124
|
+
const svg = document.getElementById('freehand-canvas')
|
|
125
|
+
if (!svg) return
|
|
126
|
+
|
|
127
|
+
// Only handle right-clicks on or within the SVG canvas
|
|
128
|
+
if (!svg.contains(e.target) && e.target !== svg) return
|
|
129
|
+
|
|
130
|
+
e.preventDefault()
|
|
131
|
+
const shape = getShapeAtPoint(e.clientX, e.clientY)
|
|
132
|
+
|
|
133
|
+
// Also check if there's a currentShape selected
|
|
134
|
+
const current = window.currentShape || null
|
|
135
|
+
|
|
136
|
+
setTargetShape(shape || current)
|
|
137
|
+
setPosition({ x: e.clientX, y: e.clientY })
|
|
138
|
+
setVisible(true)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const handleClick = () => close()
|
|
142
|
+
const handleKeyDown = (e) => { if (e.key === 'Escape') close() }
|
|
143
|
+
|
|
144
|
+
document.addEventListener('contextmenu', handleContextMenu)
|
|
145
|
+
document.addEventListener('click', handleClick)
|
|
146
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
147
|
+
return () => {
|
|
148
|
+
document.removeEventListener('contextmenu', handleContextMenu)
|
|
149
|
+
document.removeEventListener('click', handleClick)
|
|
150
|
+
document.removeEventListener('keydown', handleKeyDown)
|
|
151
|
+
}
|
|
152
|
+
}, [close])
|
|
153
|
+
|
|
154
|
+
// Adjust position so menu doesn't overflow viewport
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (!visible || !menuRef.current) return
|
|
157
|
+
const rect = menuRef.current.getBoundingClientRect()
|
|
158
|
+
const vw = window.innerWidth
|
|
159
|
+
const vh = window.innerHeight
|
|
160
|
+
let { x, y } = position
|
|
161
|
+
if (x + rect.width > vw - 8) x = vw - rect.width - 8
|
|
162
|
+
if (y + rect.height > vh - 8) y = vh - rect.height - 8
|
|
163
|
+
if (x !== position.x || y !== position.y) setPosition({ x, y })
|
|
164
|
+
}, [visible, position])
|
|
165
|
+
|
|
166
|
+
if (!visible) return null
|
|
167
|
+
|
|
168
|
+
// ── Action handlers ────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
const handlePaste = () => {
|
|
171
|
+
if (typeof window.pasteClipboard === 'function') window.pasteClipboard()
|
|
172
|
+
else document.dispatchEvent(new KeyboardEvent('keydown', { key: 'v', ctrlKey: true }))
|
|
173
|
+
close()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const handleCopy = () => {
|
|
177
|
+
if (typeof window.copySelected === 'function') window.copySelected()
|
|
178
|
+
else document.dispatchEvent(new KeyboardEvent('keydown', { key: 'c', ctrlKey: true }))
|
|
179
|
+
close()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const handleCut = () => {
|
|
183
|
+
handleCopy()
|
|
184
|
+
handleDeleteShape()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const handleSelectAll = () => {
|
|
188
|
+
useSketchStore.getState().setActiveTool('select')
|
|
189
|
+
if (window.multiSelection && window.shapes) {
|
|
190
|
+
window.multiSelection.clearSelection()
|
|
191
|
+
window.shapes.forEach((s) => window.multiSelection.addShape(s))
|
|
192
|
+
}
|
|
193
|
+
close()
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const handleCopyPNG = async () => {
|
|
197
|
+
const clone = getCleanSVG()
|
|
198
|
+
if (!clone) { close(); return }
|
|
199
|
+
const canvas = await renderToCanvas(clone, 2, '#121212')
|
|
200
|
+
if (!canvas) { close(); return }
|
|
201
|
+
canvas.toBlob((blob) => {
|
|
202
|
+
if (blob) navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]).catch(() => {})
|
|
203
|
+
}, 'image/png')
|
|
204
|
+
close()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const handleCopySVG = () => {
|
|
208
|
+
const clone = getCleanSVG()
|
|
209
|
+
if (!clone) { close(); return }
|
|
210
|
+
const svgData = new XMLSerializer().serializeToString(clone)
|
|
211
|
+
navigator.clipboard.writeText(svgData).catch(() => {})
|
|
212
|
+
close()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const handleToggleGrid = () => {
|
|
216
|
+
useSketchStore.getState().toggleGrid()
|
|
217
|
+
close()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const handleToggleSnap = () => {
|
|
221
|
+
useSketchStore.getState().toggleSnapToObjects()
|
|
222
|
+
close()
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const handleZenMode = () => {
|
|
226
|
+
useSketchStore.getState().toggleZenMode()
|
|
227
|
+
close()
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const handleViewMode = () => {
|
|
231
|
+
useSketchStore.getState().toggleViewMode()
|
|
232
|
+
close()
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const handleDeleteShape = () => {
|
|
236
|
+
// Multi-selection delete
|
|
237
|
+
if (window.multiSelection?.selectedShapes?.size > 0) {
|
|
238
|
+
if (typeof window.deleteSelectedShapes === 'function') window.deleteSelectedShapes()
|
|
239
|
+
close()
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
const shape = targetShape || window.currentShape
|
|
243
|
+
if (!shape) { close(); return }
|
|
244
|
+
const shapes = window.shapes
|
|
245
|
+
|
|
246
|
+
// Issue #24 bug #7: frame → orphan children + snapshot for undo
|
|
247
|
+
// (mirrors deleteSelectedShapes / EngineShortcuts).
|
|
248
|
+
const isFrame = shape.shapeName === 'frame'
|
|
249
|
+
const isDiagramFrame = isFrame && !!shape._diagramType
|
|
250
|
+
let childSnapshot = null
|
|
251
|
+
if (isFrame && !isDiagramFrame) {
|
|
252
|
+
childSnapshot = [...(shape.containedShapes || [])]
|
|
253
|
+
for (const child of childSnapshot) {
|
|
254
|
+
if (!child || child === shape) continue
|
|
255
|
+
const el = child.group || child.element
|
|
256
|
+
if (el) {
|
|
257
|
+
if (shape.clipGroup && el.parentNode === shape.clipGroup) shape.clipGroup.removeChild(el)
|
|
258
|
+
if (window.svg && el.parentNode !== window.svg) window.svg.appendChild(el)
|
|
259
|
+
}
|
|
260
|
+
child.parentFrame = null
|
|
261
|
+
delete child.isBeingMovedByFrame
|
|
262
|
+
}
|
|
263
|
+
shape.containedShapes = []
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (shapes) {
|
|
267
|
+
const idx = shapes.indexOf(shape)
|
|
268
|
+
if (idx !== -1) shapes.splice(idx, 1)
|
|
269
|
+
}
|
|
270
|
+
if (typeof window.cleanupAttachments === 'function') window.cleanupAttachments(shape)
|
|
271
|
+
if (shape.parentFrame && typeof shape.parentFrame.removeShapeFromFrame === 'function') {
|
|
272
|
+
shape.parentFrame.removeShapeFromFrame(shape)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (isFrame && !isDiagramFrame) {
|
|
276
|
+
if (shape.group && shape.group.parentNode) shape.group.parentNode.removeChild(shape.group)
|
|
277
|
+
if (shape.clipGroup && shape.clipGroup.parentNode) shape.clipGroup.parentNode.removeChild(shape.clipGroup)
|
|
278
|
+
if (shape.clipPath && shape.clipPath.parentNode) shape.clipPath.parentNode.removeChild(shape.clipPath)
|
|
279
|
+
} else {
|
|
280
|
+
const el = shape.group || shape.element
|
|
281
|
+
if (el && el.parentNode) el.parentNode.removeChild(el)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (typeof window.pushDeleteAction === 'function') {
|
|
285
|
+
if (childSnapshot) window.pushDeleteAction(shape, { childSnapshot })
|
|
286
|
+
else window.pushDeleteAction(shape)
|
|
287
|
+
}
|
|
288
|
+
window.currentShape = null
|
|
289
|
+
if (typeof window.disableAllSideBars === 'function') window.disableAllSideBars()
|
|
290
|
+
close()
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const handleDuplicate = () => {
|
|
294
|
+
// Copy then paste
|
|
295
|
+
if (typeof window.copySelected === 'function') window.copySelected()
|
|
296
|
+
setTimeout(() => {
|
|
297
|
+
if (typeof window.pasteClipboard === 'function') window.pasteClipboard()
|
|
298
|
+
}, 50)
|
|
299
|
+
close()
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Layer order
|
|
303
|
+
const handleLayerAction = (action) => {
|
|
304
|
+
const shape = targetShape || window.currentShape
|
|
305
|
+
if (!shape || !window.__layerOrder) { close(); return }
|
|
306
|
+
window.__layerOrder[action](shape)
|
|
307
|
+
close()
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Flip (transform via scale)
|
|
311
|
+
const handleFlip = (axis) => {
|
|
312
|
+
const shape = targetShape || window.currentShape
|
|
313
|
+
if (!shape) { close(); return }
|
|
314
|
+
const el = shape.group || shape.element
|
|
315
|
+
if (!el) { close(); return }
|
|
316
|
+
const current = el.getAttribute('transform') || ''
|
|
317
|
+
const bbox = el.getBBox()
|
|
318
|
+
const cx = bbox.x + bbox.width / 2
|
|
319
|
+
const cy = bbox.y + bbox.height / 2
|
|
320
|
+
const scaleStr = axis === 'h'
|
|
321
|
+
? `translate(${cx * 2}, 0) scale(-1, 1)`
|
|
322
|
+
: `translate(0, ${cy * 2}) scale(1, -1)`
|
|
323
|
+
el.setAttribute('transform', current + ' ' + scaleStr)
|
|
324
|
+
close()
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Wrap selection in frame
|
|
328
|
+
const handleWrapInFrame = () => {
|
|
329
|
+
// Simulate the frame wrap if engine supports it
|
|
330
|
+
close()
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── Group / ungroup helpers ─────────────────────────────────
|
|
334
|
+
// Mirror the Ctrl+G / Ctrl+Shift+G behavior so the right-click menu
|
|
335
|
+
// is a discoverable alternative.
|
|
336
|
+
const handleGroup = () => {
|
|
337
|
+
const ms = window.multiSelection
|
|
338
|
+
const sel = ms?.selectedShapes
|
|
339
|
+
const targets = sel && sel.size > 1
|
|
340
|
+
? Array.from(sel)
|
|
341
|
+
: (window.currentShape ? [window.currentShape] : [])
|
|
342
|
+
if (targets.length > 1) {
|
|
343
|
+
const newId = `g-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`
|
|
344
|
+
for (const s of targets) s.groupId = newId
|
|
345
|
+
if (typeof ms?.updateControls === 'function') ms.updateControls()
|
|
346
|
+
showToast(`Grouped ${targets.length} shapes`, { tone: 'success' })
|
|
347
|
+
}
|
|
348
|
+
close()
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const handleUngroup = () => {
|
|
352
|
+
const ms = window.multiSelection
|
|
353
|
+
const sel = ms?.selectedShapes
|
|
354
|
+
const targets = sel && sel.size > 0
|
|
355
|
+
? Array.from(sel)
|
|
356
|
+
: (window.currentShape ? [window.currentShape] : [])
|
|
357
|
+
const groupIds = new Set(targets.map(s => s.groupId).filter(Boolean))
|
|
358
|
+
if (groupIds.size > 0 && Array.isArray(window.shapes)) {
|
|
359
|
+
let cleared = 0
|
|
360
|
+
for (const s of window.shapes) {
|
|
361
|
+
if (s.groupId && groupIds.has(s.groupId)) { s.groupId = null; cleared++ }
|
|
362
|
+
}
|
|
363
|
+
if (typeof ms?.updateControls === 'function') ms.updateControls()
|
|
364
|
+
showToast(`Ungrouped ${cleared} shapes`, { tone: 'success' })
|
|
365
|
+
}
|
|
366
|
+
close()
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Compute selection state for the menu rendering below.
|
|
370
|
+
const selectionShapes = (() => {
|
|
371
|
+
const sel = window.multiSelection?.selectedShapes
|
|
372
|
+
if (sel && sel.size > 0) return Array.from(sel)
|
|
373
|
+
if (targetShape) return [targetShape]
|
|
374
|
+
return []
|
|
375
|
+
})()
|
|
376
|
+
const canGroup = selectionShapes.length > 1
|
|
377
|
+
const groupIdsInSelection = new Set(selectionShapes.map(s => s.groupId).filter(Boolean))
|
|
378
|
+
const canUngroup = groupIdsInSelection.size > 0
|
|
379
|
+
const fullyGrouped = selectionShapes.length > 1 &&
|
|
380
|
+
selectionShapes.every(s => s.groupId) &&
|
|
381
|
+
groupIdsInSelection.size === 1
|
|
382
|
+
|
|
383
|
+
const handleCanvasProperties = () => {
|
|
384
|
+
// Toggle shape sidebar / properties via Alt+/
|
|
385
|
+
close()
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── Render ─────────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
const isShape = !!targetShape
|
|
391
|
+
|
|
392
|
+
return (
|
|
393
|
+
<div
|
|
394
|
+
ref={menuRef}
|
|
395
|
+
className="fixed z-9999 min-w-[220px] py-1.5 bg-surface/90 backdrop-blur-xl rounded-xl border border-border-light shadow-2xl font-[lixFont] select-none"
|
|
396
|
+
style={{ left: position.x, top: position.y }}
|
|
397
|
+
onClick={(e) => e.stopPropagation()}
|
|
398
|
+
>
|
|
399
|
+
{isShape ? (
|
|
400
|
+
/* ── Shape context menu ── */
|
|
401
|
+
<>
|
|
402
|
+
<MenuItem label="Cut" shortcut="Ctrl+X" onClick={handleCut} />
|
|
403
|
+
<MenuItem label="Copy" shortcut="Ctrl+C" onClick={handleCopy} />
|
|
404
|
+
<MenuItem label="Paste" shortcut="Ctrl+V" onClick={handlePaste} />
|
|
405
|
+
|
|
406
|
+
<Separator />
|
|
407
|
+
{/* Frame wrap doesn't make sense when the selection is already
|
|
408
|
+
grouped — the group itself is the implicit container. */}
|
|
409
|
+
{!fullyGrouped && (
|
|
410
|
+
<MenuItem label="Wrap selection in frame" onClick={handleWrapInFrame} />
|
|
411
|
+
)}
|
|
412
|
+
{canGroup && !fullyGrouped && (
|
|
413
|
+
<MenuItem label="Group" shortcut="Ctrl+G" onClick={handleGroup} />
|
|
414
|
+
)}
|
|
415
|
+
{canUngroup && (
|
|
416
|
+
<MenuItem label="Ungroup" shortcut="Ctrl+Shift+G" onClick={handleUngroup} />
|
|
417
|
+
)}
|
|
418
|
+
|
|
419
|
+
<Separator />
|
|
420
|
+
<MenuItem label="Copy to clipboard as PNG" shortcut="Shift+Alt+C" onClick={handleCopyPNG} />
|
|
421
|
+
<MenuItem label="Copy to clipboard as SVG" onClick={handleCopySVG} />
|
|
422
|
+
|
|
423
|
+
<Separator />
|
|
424
|
+
<MenuItem label="Send backward" shortcut="Ctrl+[" onClick={() => handleLayerAction('sendBackward')} />
|
|
425
|
+
<MenuItem label="Bring forward" shortcut="Ctrl+]" onClick={() => handleLayerAction('bringForward')} />
|
|
426
|
+
<MenuItem label="Send to back" shortcut="Ctrl+Shift+[" onClick={() => handleLayerAction('sendToBack')} />
|
|
427
|
+
<MenuItem label="Bring to front" shortcut="Ctrl+Shift+]" onClick={() => handleLayerAction('bringToFront')} />
|
|
428
|
+
|
|
429
|
+
<Separator />
|
|
430
|
+
<MenuItem label="Flip horizontal" shortcut="Shift+H" onClick={() => handleFlip('h')} />
|
|
431
|
+
<MenuItem label="Flip vertical" shortcut="Shift+V" onClick={() => handleFlip('v')} />
|
|
432
|
+
|
|
433
|
+
<Separator />
|
|
434
|
+
<MenuItem label="Duplicate" shortcut="Ctrl+D" onClick={handleDuplicate} />
|
|
435
|
+
<MenuItem label="Delete" danger onClick={handleDeleteShape} />
|
|
436
|
+
</>
|
|
437
|
+
) : (
|
|
438
|
+
/* ── Canvas context menu ── */
|
|
439
|
+
<>
|
|
440
|
+
<MenuItem label="Paste" shortcut="Ctrl+V" onClick={handlePaste} />
|
|
441
|
+
<MenuItem label="Copy to clipboard as PNG" shortcut="Shift+Alt+C" onClick={handleCopyPNG} />
|
|
442
|
+
<MenuItem label="Copy to clipboard as SVG" onClick={handleCopySVG} />
|
|
443
|
+
|
|
444
|
+
<Separator />
|
|
445
|
+
<MenuItem label="Select all" shortcut="Ctrl+A" onClick={handleSelectAll} />
|
|
446
|
+
|
|
447
|
+
<Separator />
|
|
448
|
+
<CheckMenuItem label="Toggle grid" shortcut="Ctrl+'" checked={gridEnabled} onClick={handleToggleGrid} />
|
|
449
|
+
<CheckMenuItem label="Snap to objects" shortcut="Alt+S" checked={snapToObjects} onClick={handleToggleSnap} />
|
|
450
|
+
|
|
451
|
+
<Separator />
|
|
452
|
+
<MenuItem label="Find on canvas" shortcut="Ctrl+F" onClick={() => { useUIStore.getState().toggleFindBar(); close() }} />
|
|
453
|
+
<MenuItem label="Zen mode" shortcut="Alt+Z" onClick={handleZenMode} />
|
|
454
|
+
<MenuItem label="View mode" shortcut="Alt+R" onClick={handleViewMode} />
|
|
455
|
+
|
|
456
|
+
<Separator />
|
|
457
|
+
<MenuItem label="Reset canvas" danger onClick={() => {
|
|
458
|
+
const serializer = window.__sceneSerializer
|
|
459
|
+
if (serializer?.resetCanvas) serializer.resetCanvas()
|
|
460
|
+
close()
|
|
461
|
+
}} />
|
|
462
|
+
</>
|
|
463
|
+
)}
|
|
464
|
+
</div>
|
|
465
|
+
)
|
|
466
|
+
}
|