@elixpo/lixsketch 5.5.23 → 5.6.2
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/README.md +85 -6
- package/dist/mcp/index.js +585 -0
- package/dist/mcp/index.js.map +7 -0
- package/dist/mcp/node.js +184 -0
- package/dist/mcp/node.js.map +7 -0
- package/dist/mcp/stdio.js +723 -0
- package/dist/mcp/stdio.js.map +7 -0
- package/dist/react/{CopyPaste-5OWPTCQ5.js → CopyPaste-YUDZ6DHY.js} +7 -7
- package/dist/react/EventDispatcher-7LAK7GQ7.js +1097 -0
- package/dist/react/EventDispatcher-7LAK7GQ7.js.map +7 -0
- package/dist/react/Frame-7D2MXCFF.js +11 -0
- package/dist/react/IconShape-ZADX7Y33.js +10 -0
- package/dist/react/ImageShape-UJQN2HI7.js +10 -0
- package/dist/react/Line-APEULMHE.js +10 -0
- package/dist/react/{SceneSerializer-RDISX4X7.js → SceneSerializer-SG6SJN5X.js} +17 -7
- package/dist/react/SceneSerializer-SG6SJN5X.js.map +7 -0
- package/dist/react/{Selection-ZL3G6IAO.js → Selection-2TSH3J6P.js} +6 -4
- package/dist/react/{SketchEngine-OB6ZW3B6.js → SketchEngine-UD2XV2KT.js} +50 -23
- package/dist/react/{SketchEngine-OB6ZW3B6.js.map → SketchEngine-UD2XV2KT.js.map} +2 -2
- package/dist/react/{UndoRedo-NL7KLR5B.js → UndoRedo-IN4SXDG6.js} +2 -2
- package/dist/react/{ZoomPan-FAWA5OPS.js → ZoomPan-RR7KUYZT.js} +34 -1
- package/dist/react/{ZoomPan-FAWA5OPS.js.map → ZoomPan-RR7KUYZT.js.map} +2 -2
- package/dist/react/{arrowTool-CNVGM6RX.js → arrowTool-OYDWJGQK.js} +3 -3
- package/dist/react/{chunk-U3TB4KRS.js → chunk-6GPEWDRB.js} +19 -12
- package/dist/react/chunk-6GPEWDRB.js.map +7 -0
- package/dist/react/{chunk-4BLMNGTR.js → chunk-7CM5XQDN.js} +3 -3
- package/dist/react/{chunk-XFQECTTF.js → chunk-APTOPAOY.js} +3 -3
- package/dist/react/{chunk-EDSKEM6E.js → chunk-ASBC5G35.js} +47 -8
- package/dist/react/chunk-ASBC5G35.js.map +7 -0
- package/dist/react/{chunk-4TAS67SL.js → chunk-EOURRJ55.js} +2 -2
- package/dist/react/{chunk-ETAAQUUV.js → chunk-GQUZPON4.js} +4 -4
- package/dist/react/{chunk-KVAQPCUH.js → chunk-K5BG5MHY.js} +49 -5
- package/dist/react/chunk-K5BG5MHY.js.map +7 -0
- package/dist/react/{chunk-AH4PWNEB.js → chunk-K5RY4EV7.js} +23 -3
- package/dist/react/chunk-K5RY4EV7.js.map +7 -0
- package/dist/react/{chunk-GC3KTQDA.js → chunk-LJR7EVW2.js} +3 -3
- package/dist/react/{chunk-SZD7DKYJ.js → chunk-LSHZYN77.js} +2 -2
- package/dist/react/{chunk-HFQZYCHJ.js → chunk-M6TMN76K.js} +3 -3
- package/dist/react/{chunk-V2QTYSBK.js → chunk-OP56XPWF.js} +2 -2
- package/dist/react/chunk-QZI4UIE5.js +48 -0
- package/dist/react/chunk-QZI4UIE5.js.map +7 -0
- package/dist/react/{chunk-ILGAFR56.js → chunk-RGMDWLRJ.js} +3 -3
- package/dist/react/{chunk-OFGJJKPH.js → chunk-XFL4R5ZF.js} +3 -3
- package/dist/react/{chunk-GPIYXREE.js → chunk-XOWNCYE4.js} +3 -3
- package/dist/react/{chunk-YF3TK7SC.js → chunk-XYUFEIZR.js} +2 -2
- package/dist/react/{circleTool-KICNRQCI.js → circleTool-LDKGZALF.js} +4 -4
- package/dist/react/{codeTool-NCQ73R6M.js → codeTool-4YZO5W7J.js} +4 -4
- package/dist/react/{frameTool-Y6KB67DO.js → frameTool-MXNJEXQQ.js} +4 -4
- package/dist/react/{freehandTool-MMULHCQ7.js → freehandTool-26D3FOOX.js} +4 -4
- package/dist/react/{iconTool-LSW2DYGB.js → iconTool-46O5VEUX.js} +4 -4
- package/dist/react/{imageTool-2DSR32RX.js → imageTool-P2RWZRHO.js} +4 -4
- package/dist/react/index.js +848 -614
- package/dist/react/index.js.map +4 -4
- package/dist/react/{lineTool-N5I7IGT7.js → lineTool-ZMR27U5L.js} +4 -4
- package/dist/react/{rectangleTool-YUT7ZK2E.js → rectangleTool-EJKK6UTX.js} +4 -4
- package/dist/react/{textTool-JMTX7OVT.js → textTool-S4XXZHZH.js} +5 -5
- package/package.json +7 -1
- package/src/EngineShortcuts.js +22 -0
- package/src/SketchEngine.js +27 -0
- package/src/core/EventDispatcher.js +35 -4
- package/src/core/SceneSerializer.js +5 -0
- package/src/core/Selection.js +26 -0
- package/src/core/UndoRedo.js +20 -16
- package/src/core/WebEmbedPolicy.js +41 -0
- package/src/core/ZoomPan.js +36 -0
- package/src/index.js +4 -0
- package/src/mcp/fileStore.js +70 -0
- package/src/mcp/index.js +5 -0
- package/src/mcp/node.js +2 -0
- package/src/mcp/preview.js +39 -0
- package/src/mcp/scene.js +275 -0
- package/src/mcp/server.js +195 -0
- package/src/mcp/stdio.js +41 -0
- package/src/mcp/stdioTransport.js +46 -0
- package/src/mcp/store.js +23 -0
- package/src/mcp/templates.js +51 -0
- package/src/react/LixSketchCanvas.jsx +4 -0
- package/src/react/components/Toolbar.jsx +84 -7
- package/src/react/components/modals/CommandPalette.jsx +8 -0
- package/src/react/components/modals/WebEmbedModal.jsx +40 -0
- package/src/react/components/sidebars/PaintBucketSidebar.jsx +49 -0
- package/src/react/components/sidebars/PaintbrushSidebar.jsx +1 -1
- package/src/react/store/useSketchStore.js +13 -0
- package/src/react/toolsEnum.js +4 -0
- package/src/shapes/Frame.js +47 -4
- package/src/shapes/ImageShape.js +49 -6
- package/src/tools/lassoTool.js +84 -0
- package/src/tools/paintBucketTool.js +50 -0
- package/src/tools/shapeRecognitionTool.js +431 -0
- package/src/tools/webEmbedTool.js +61 -0
- package/dist/react/EventDispatcher-GKNO2KSL.js +0 -464
- package/dist/react/EventDispatcher-GKNO2KSL.js.map +0 -7
- package/dist/react/Frame-RLSMJJSK.js +0 -11
- package/dist/react/IconShape-7OPXAKLK.js +0 -10
- package/dist/react/ImageShape-T7UEHP4A.js +0 -10
- package/dist/react/Line-WWQFBNX5.js +0 -10
- package/dist/react/SceneSerializer-RDISX4X7.js.map +0 -7
- package/dist/react/chunk-AH4PWNEB.js.map +0 -7
- package/dist/react/chunk-EDSKEM6E.js.map +0 -7
- package/dist/react/chunk-KVAQPCUH.js.map +0 -7
- package/dist/react/chunk-U3TB4KRS.js.map +0 -7
- /package/dist/react/{CopyPaste-5OWPTCQ5.js.map → CopyPaste-YUDZ6DHY.js.map} +0 -0
- /package/dist/react/{Frame-RLSMJJSK.js.map → Frame-7D2MXCFF.js.map} +0 -0
- /package/dist/react/{IconShape-7OPXAKLK.js.map → IconShape-ZADX7Y33.js.map} +0 -0
- /package/dist/react/{ImageShape-T7UEHP4A.js.map → ImageShape-UJQN2HI7.js.map} +0 -0
- /package/dist/react/{Line-WWQFBNX5.js.map → Line-APEULMHE.js.map} +0 -0
- /package/dist/react/{Selection-ZL3G6IAO.js.map → Selection-2TSH3J6P.js.map} +0 -0
- /package/dist/react/{UndoRedo-NL7KLR5B.js.map → UndoRedo-IN4SXDG6.js.map} +0 -0
- /package/dist/react/{arrowTool-CNVGM6RX.js.map → arrowTool-OYDWJGQK.js.map} +0 -0
- /package/dist/react/{chunk-4BLMNGTR.js.map → chunk-7CM5XQDN.js.map} +0 -0
- /package/dist/react/{chunk-XFQECTTF.js.map → chunk-APTOPAOY.js.map} +0 -0
- /package/dist/react/{chunk-4TAS67SL.js.map → chunk-EOURRJ55.js.map} +0 -0
- /package/dist/react/{chunk-ETAAQUUV.js.map → chunk-GQUZPON4.js.map} +0 -0
- /package/dist/react/{chunk-GC3KTQDA.js.map → chunk-LJR7EVW2.js.map} +0 -0
- /package/dist/react/{chunk-SZD7DKYJ.js.map → chunk-LSHZYN77.js.map} +0 -0
- /package/dist/react/{chunk-HFQZYCHJ.js.map → chunk-M6TMN76K.js.map} +0 -0
- /package/dist/react/{chunk-V2QTYSBK.js.map → chunk-OP56XPWF.js.map} +0 -0
- /package/dist/react/{chunk-ILGAFR56.js.map → chunk-RGMDWLRJ.js.map} +0 -0
- /package/dist/react/{chunk-OFGJJKPH.js.map → chunk-XFL4R5ZF.js.map} +0 -0
- /package/dist/react/{chunk-GPIYXREE.js.map → chunk-XOWNCYE4.js.map} +0 -0
- /package/dist/react/{chunk-YF3TK7SC.js.map → chunk-XYUFEIZR.js.map} +0 -0
- /package/dist/react/{circleTool-KICNRQCI.js.map → circleTool-LDKGZALF.js.map} +0 -0
- /package/dist/react/{codeTool-NCQ73R6M.js.map → codeTool-4YZO5W7J.js.map} +0 -0
- /package/dist/react/{frameTool-Y6KB67DO.js.map → frameTool-MXNJEXQQ.js.map} +0 -0
- /package/dist/react/{freehandTool-MMULHCQ7.js.map → freehandTool-26D3FOOX.js.map} +0 -0
- /package/dist/react/{iconTool-LSW2DYGB.js.map → iconTool-46O5VEUX.js.map} +0 -0
- /package/dist/react/{imageTool-2DSR32RX.js.map → imageTool-P2RWZRHO.js.map} +0 -0
- /package/dist/react/{lineTool-N5I7IGT7.js.map → lineTool-ZMR27U5L.js.map} +0 -0
- /package/dist/react/{rectangleTool-YUT7ZK2E.js.map → rectangleTool-EJKK6UTX.js.map} +0 -0
- /package/dist/react/{textTool-JMTX7OVT.js.map → textTool-S4XXZHZH.js.map} +0 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const DEFAULT_MARKETPLACE_URL = 'https://sketch.elixpo.com';
|
|
2
|
+
|
|
3
|
+
function decodeBase64Url(value) {
|
|
4
|
+
const base64 = String(value).replaceAll('-', '+').replaceAll('_', '/');
|
|
5
|
+
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4);
|
|
6
|
+
const binary = atob(padded);
|
|
7
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function decryptPublicTemplate(ciphertext, keyValue) {
|
|
11
|
+
const keyBytes = decodeBase64Url(keyValue);
|
|
12
|
+
if (keyBytes.byteLength !== 32) throw new Error('Template key is not AES-256');
|
|
13
|
+
const combined = decodeBase64Url(ciphertext);
|
|
14
|
+
if (combined.byteLength < 28) throw new Error('Template ciphertext is invalid');
|
|
15
|
+
const key = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM', length: 256 }, false, ['decrypt']);
|
|
16
|
+
const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: combined.slice(0, 12) }, key, combined.slice(12));
|
|
17
|
+
return JSON.parse(new TextDecoder().decode(plaintext));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class MarketplaceTemplateProvider {
|
|
21
|
+
constructor({ baseUrl = DEFAULT_MARKETPLACE_URL, fetchImpl = globalThis.fetch } = {}) {
|
|
22
|
+
if (typeof fetchImpl !== 'function') throw new Error('MarketplaceTemplateProvider requires fetch');
|
|
23
|
+
this.baseUrl = String(baseUrl).replace(/\/$/, '');
|
|
24
|
+
this.fetch = fetchImpl;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async search({ query = '', tag = '', limit = 12 } = {}) {
|
|
28
|
+
const url = new URL('/api/templates', this.baseUrl);
|
|
29
|
+
if (query) url.searchParams.set('q', String(query).slice(0, 80));
|
|
30
|
+
if (tag) url.searchParams.set('tag', String(tag).slice(0, 24));
|
|
31
|
+
url.searchParams.set('limit', String(Math.min(24, Math.max(1, Number(limit) || 12))));
|
|
32
|
+
const response = await this.fetch(url, { headers: { accept: 'application/json' } });
|
|
33
|
+
const body = await response.json();
|
|
34
|
+
if (!response.ok) throw new Error(body.error || `Marketplace request failed (${response.status})`);
|
|
35
|
+
return body.templates || [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async load(slug) {
|
|
39
|
+
const safeSlug = String(slug || '').trim();
|
|
40
|
+
if (!/^[a-z0-9-]{1,80}$/.test(safeSlug)) throw new Error('Template slug is invalid');
|
|
41
|
+
const url = new URL(`/api/templates/${encodeURIComponent(safeSlug)}`, this.baseUrl);
|
|
42
|
+
url.searchParams.set('snapshot', '1');
|
|
43
|
+
const response = await this.fetch(url, { headers: { accept: 'application/json' } });
|
|
44
|
+
const body = await response.json();
|
|
45
|
+
if (!response.ok) throw new Error(body.error || `Template request failed (${response.status})`);
|
|
46
|
+
const template = body.template;
|
|
47
|
+
if (!template?.encryptedData || !template?.publicKey) throw new Error('Template snapshot is unavailable');
|
|
48
|
+
return { metadata: { ...template, encryptedData: undefined, publicKey: undefined, encryptedDocData: undefined }, scene: await decryptPublicTemplate(template.encryptedData, template.publicKey) };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
@@ -19,6 +19,7 @@ import Footer from './components/Footer.jsx';
|
|
|
19
19
|
import AppMenu from './components/AppMenu.jsx';
|
|
20
20
|
import RectangleSidebar from './components/sidebars/RectangleSidebar.jsx';
|
|
21
21
|
import CircleSidebar from './components/sidebars/CircleSidebar.jsx';
|
|
22
|
+
import PaintBucketSidebar from './components/sidebars/PaintBucketSidebar.jsx';
|
|
22
23
|
import LineSidebar from './components/sidebars/LineSidebar.jsx';
|
|
23
24
|
import ArrowSidebar from './components/sidebars/ArrowSidebar.jsx';
|
|
24
25
|
import PaintbrushSidebar from './components/sidebars/PaintbrushSidebar.jsx';
|
|
@@ -35,6 +36,7 @@ import CommandPalette from './components/modals/CommandPalette.jsx';
|
|
|
35
36
|
import ExportImageModal from './components/modals/ExportImageModal.jsx';
|
|
36
37
|
import LixScriptModal from './components/modals/LixScriptModal.jsx';
|
|
37
38
|
import GraphModal from './components/modals/GraphModal.jsx';
|
|
39
|
+
import WebEmbedModal from './components/modals/WebEmbedModal.jsx';
|
|
38
40
|
|
|
39
41
|
const SAVE_DEBOUNCE_MS = 1500;
|
|
40
42
|
|
|
@@ -221,6 +223,7 @@ export default function LixSketchCanvas({
|
|
|
221
223
|
<AppMenu />
|
|
222
224
|
<RectangleSidebar />
|
|
223
225
|
<CircleSidebar />
|
|
226
|
+
<PaintBucketSidebar />
|
|
224
227
|
<LineSidebar />
|
|
225
228
|
<ArrowSidebar />
|
|
226
229
|
<PaintbrushSidebar />
|
|
@@ -233,6 +236,7 @@ export default function LixSketchCanvas({
|
|
|
233
236
|
<ExportImageModal />
|
|
234
237
|
<LixScriptModal />
|
|
235
238
|
<GraphModal />
|
|
239
|
+
<WebEmbedModal />
|
|
236
240
|
<ContextMenu />
|
|
237
241
|
<FindBar />
|
|
238
242
|
<ImageSourcePicker />
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
+
import { useEffect, useRef, useState } from 'react'
|
|
3
4
|
import useSketchStore, { TOOLS } from '../store/useSketchStore'
|
|
4
5
|
import useUIStore from '../store/useUIStore'
|
|
5
6
|
|
|
@@ -11,6 +12,17 @@ function getViewModeItems() {
|
|
|
11
12
|
]
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
function getMoreToolItems() {
|
|
16
|
+
return [
|
|
17
|
+
{ tool: TOOLS.DRAW_SHAPE, icon: 'draw-shape', title: 'Draw to shape', key: 'D' },
|
|
18
|
+
{ tool: TOOLS.PAINT_BUCKET, icon: 'bxs-color-fill', title: 'Paint bucket', key: 'B' },
|
|
19
|
+
{ tool: TOOLS.FRAME, icon: 'bx-crop', title: 'Frame', key: 'F' },
|
|
20
|
+
{ tool: TOOLS.LASER, icon: 'bxs-magic-wand', title: 'Laser', key: 'K' },
|
|
21
|
+
{ tool: TOOLS.LASSO, icon: 'bx-shape-polygon', title: 'Lasso selection', key: 'S' },
|
|
22
|
+
{ tool: TOOLS.WEB_EMBED, icon: 'bx-globe', title: 'Web embed', key: 'W' },
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
|
|
14
26
|
function getToolItems() {
|
|
15
27
|
return [
|
|
16
28
|
{ tool: TOOLS.PAN, icon: 'bxs-hand', title: 'Pan (H)', key: 'H' },
|
|
@@ -25,15 +37,27 @@ function getToolItems() {
|
|
|
25
37
|
{ tool: TOOLS.IMAGE, icon: 'bx-image-alt', title: 'Image (9)', key: '9' },
|
|
26
38
|
{ tool: TOOLS.ICON, icon: 'bx-wink-smile', title: 'Icon (I)', key: 'I' },
|
|
27
39
|
'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
40
|
{ tool: TOOLS.ERASER, icon: 'bxs-eraser', title: 'Eraser (E)', key: 'E' },
|
|
41
|
+
'more',
|
|
31
42
|
// AI tool entry removed while the assistant is coming-soon.
|
|
32
43
|
// Restore `{ tool: 'ai', icon: null, title: 'AI', isAI: true }` here
|
|
33
44
|
// when the modal becomes a real feature again.
|
|
34
45
|
]
|
|
35
46
|
}
|
|
36
47
|
|
|
48
|
+
function ToolIcon({ item, className = 'h-5 w-5' }) {
|
|
49
|
+
if (item.icon === 'draw-shape') {
|
|
50
|
+
return (
|
|
51
|
+
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
52
|
+
<path d="M2.5 16.5c2.2-5.8 4.5 3.5 7-2.5 1.1-2.7 2.2-3.8 3.4-3.9" />
|
|
53
|
+
<path d="M15 4.5h6v6h-6z" />
|
|
54
|
+
<path d="m12.5 8 2.5-.5" />
|
|
55
|
+
</svg>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
return <i className={`bx ${item.icon} text-xl`} />
|
|
59
|
+
}
|
|
60
|
+
|
|
37
61
|
export default function Toolbar() {
|
|
38
62
|
const activeTool = useSketchStore((s) => s.activeTool)
|
|
39
63
|
const setActiveTool = useSketchStore((s) => s.setActiveTool)
|
|
@@ -41,12 +65,36 @@ export default function Toolbar() {
|
|
|
41
65
|
const toolLock = useSketchStore((s) => s.toolLock)
|
|
42
66
|
const toggleToolLock = useSketchStore((s) => s.toggleToolLock)
|
|
43
67
|
const toggleAIModal = useUIStore((s) => s.toggleAIModal)
|
|
68
|
+
const [moreOpen, setMoreOpen] = useState(false)
|
|
69
|
+
const [lastMoreItem, setLastMoreItem] = useState(null)
|
|
70
|
+
const moreRef = useRef(null)
|
|
44
71
|
|
|
45
72
|
const items = viewMode ? getViewModeItems() : getToolItems()
|
|
73
|
+
const moreItems = getMoreToolItems()
|
|
74
|
+
const activeMoreItem = moreItems.find((item) => item.tool === activeTool)
|
|
75
|
+
const moreActive = Boolean(activeMoreItem)
|
|
76
|
+
const shownMoreItem = activeMoreItem || lastMoreItem
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!moreOpen) return undefined
|
|
80
|
+
const close = (event) => {
|
|
81
|
+
if (event.key === 'Escape' || (event.type === 'pointerdown' && !moreRef.current?.contains(event.target))) setMoreOpen(false)
|
|
82
|
+
}
|
|
83
|
+
document.addEventListener('pointerdown', close)
|
|
84
|
+
document.addEventListener('keydown', close)
|
|
85
|
+
return () => {
|
|
86
|
+
document.removeEventListener('pointerdown', close)
|
|
87
|
+
document.removeEventListener('keydown', close)
|
|
88
|
+
}
|
|
89
|
+
}, [moreOpen])
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
setMoreOpen(false)
|
|
93
|
+
}, [activeTool, viewMode])
|
|
46
94
|
|
|
47
95
|
return (
|
|
48
96
|
<>
|
|
49
|
-
<div data-canvas-toolbar 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-
|
|
97
|
+
<div data-canvas-toolbar 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-visible`}>
|
|
50
98
|
{/* Tool lock button at the top */}
|
|
51
99
|
{!viewMode && (
|
|
52
100
|
<>
|
|
@@ -76,6 +124,38 @@ export default function Toolbar() {
|
|
|
76
124
|
)
|
|
77
125
|
}
|
|
78
126
|
|
|
127
|
+
if (item === 'more') {
|
|
128
|
+
return (
|
|
129
|
+
<div key="more" ref={moreRef} className="relative">
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
title={shownMoreItem ? `${shownMoreItem.title} — More tools` : 'More tools'}
|
|
133
|
+
aria-label="More tools"
|
|
134
|
+
aria-expanded={moreOpen}
|
|
135
|
+
onClick={() => setMoreOpen((open) => !open)}
|
|
136
|
+
className={`relative flex h-[31px] w-[33px] cursor-pointer items-center justify-center rounded-lg transition-all duration-200 ${moreOpen || moreActive ? 'bg-accent/20 text-accent' : 'text-text-muted hover:bg-surface-hover hover:text-text-primary'}`}
|
|
137
|
+
>
|
|
138
|
+
{shownMoreItem ? <ToolIcon item={shownMoreItem} className="h-[18px] w-[18px]" /> : <i className="bx bx-dots-horizontal-rounded text-xl" />}
|
|
139
|
+
</button>
|
|
140
|
+
{moreOpen && (
|
|
141
|
+
<div className="absolute left-[calc(100%+10px)] top-1/2 z-[1100] w-[190px] -translate-y-1/2 rounded-xl border border-border-light bg-surface-card/95 p-1.5 shadow-xl shadow-black/25 backdrop-blur-lg">
|
|
142
|
+
<p className="px-2 py-1 text-[10px] uppercase tracking-[0.14em] text-text-dim">More tools</p>
|
|
143
|
+
{moreItems.map((moreItem) => {
|
|
144
|
+
const selected = activeTool === moreItem.tool
|
|
145
|
+
return (
|
|
146
|
+
<button key={moreItem.tool} type="button" title={`${moreItem.title} (${moreItem.key})`} onClick={() => { setLastMoreItem(moreItem); setActiveTool(moreItem.tool); setMoreOpen(false) }} className={`flex w-full cursor-pointer items-center gap-3 rounded-lg px-2.5 py-2 text-left text-xs transition ${selected ? 'bg-accent/20 text-accent' : 'text-text-muted hover:bg-surface-hover hover:text-text-primary'}`}>
|
|
147
|
+
<span className="flex h-5 w-5 items-center justify-center"><ToolIcon item={moreItem} className="h-[18px] w-[18px]" /></span>
|
|
148
|
+
<span className="flex-1">{moreItem.title}</span>
|
|
149
|
+
<span className="text-[10px] text-text-dim">{moreItem.key}</span>
|
|
150
|
+
</button>
|
|
151
|
+
)
|
|
152
|
+
})}
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
79
159
|
const isActive = activeTool === item.tool
|
|
80
160
|
|
|
81
161
|
if (item.isAI) {
|
|
@@ -114,10 +194,7 @@ export default function Toolbar() {
|
|
|
114
194
|
: 'text-text-muted hover:text-text-primary hover:bg-surface-hover'
|
|
115
195
|
}`}
|
|
116
196
|
>
|
|
117
|
-
<
|
|
118
|
-
className={`bx ${item.icon} text-xl`}
|
|
119
|
-
style={item.rotate ? { transform: 'rotate(-45deg)' } : undefined}
|
|
120
|
-
/>
|
|
197
|
+
<span style={item.rotate ? { transform: 'rotate(-45deg)' } : undefined}><ToolIcon item={item} /></span>
|
|
121
198
|
{item.key && (
|
|
122
199
|
<span className={`absolute bottom-0.5 right-[-1px] text-[10px] leading-none ${isActive ? 'opacity-60' : 'opacity-35'}`}>
|
|
123
200
|
{item.key}
|
|
@@ -41,11 +41,15 @@ const CANVAS_COMMANDS = [
|
|
|
41
41
|
{ label: 'Arrow', icon: 'bx-right-arrow-alt', section: 'Tools', shortcut: 'A / 5', action: 'tool:arrow' },
|
|
42
42
|
{ label: 'Line', icon: 'bx-minus', section: 'Tools', shortcut: 'L / 6', action: 'tool:line' },
|
|
43
43
|
{ label: 'Freehand', icon: 'bx-pencil', section: 'Tools', shortcut: 'P / 7', action: 'tool:freehand' },
|
|
44
|
+
{ label: 'Draw to shape', icon: 'bx-vector', section: 'Tools', shortcut: 'D', action: 'tool:drawShape' },
|
|
45
|
+
{ label: 'Paint bucket', icon: 'bxs-color-fill', section: 'Tools', shortcut: 'B', action: 'tool:paintBucket' },
|
|
44
46
|
{ label: 'Text', icon: 'bx-text', section: 'Tools', shortcut: 'T / 8', action: 'tool:text' },
|
|
45
47
|
{ label: 'Insert image', icon: 'bx-image-add', section: 'Tools', shortcut: '9', action: 'tool:image' },
|
|
46
48
|
{ label: 'Eraser', icon: 'bx-eraser', section: 'Tools', shortcut: 'E / 0', action: 'tool:eraser' },
|
|
47
49
|
{ label: 'Laser pointer', icon: 'bxs-magic-wand', section: 'Tools', shortcut: 'K', action: 'tool:laser' },
|
|
48
50
|
{ label: 'Frame tool', icon: 'bx-border-all', section: 'Tools', shortcut: 'F', action: 'tool:frame' },
|
|
51
|
+
{ label: 'Lasso selection', icon: 'bx-shape-polygon', section: 'Tools', shortcut: 'S', action: 'tool:lasso' },
|
|
52
|
+
{ label: 'Web embed', icon: 'bx-globe', section: 'Tools', shortcut: 'W', action: 'tool:webEmbed' },
|
|
49
53
|
// AI entries removed — feature is hidden behind a coming-soon screen.
|
|
50
54
|
// Restore "Text to diagram..." / "Mermaid to diagram..." here when the
|
|
51
55
|
// assistant ships.
|
|
@@ -103,6 +107,10 @@ const TOOL_ACTION_MAP = {
|
|
|
103
107
|
'tool:arrow': TOOLS.ARROW,
|
|
104
108
|
'tool:line': TOOLS.LINE,
|
|
105
109
|
'tool:freehand': TOOLS.FREEHAND,
|
|
110
|
+
'tool:drawShape': TOOLS.DRAW_SHAPE,
|
|
111
|
+
'tool:paintBucket': TOOLS.PAINT_BUCKET,
|
|
112
|
+
'tool:lasso': TOOLS.LASSO,
|
|
113
|
+
'tool:webEmbed': TOOLS.WEB_EMBED,
|
|
106
114
|
'tool:text': TOOLS.TEXT,
|
|
107
115
|
'tool:image': TOOLS.IMAGE,
|
|
108
116
|
'tool:eraser': TOOLS.ERASER,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { normalizeWebEmbedUrl } from '../../../core/WebEmbedPolicy.js';
|
|
5
|
+
import useSketchStore, { TOOLS } from '../../store/useSketchStore.js';
|
|
6
|
+
|
|
7
|
+
export default function WebEmbedModal() {
|
|
8
|
+
const [open, setOpen] = useState(false), [url, setUrl] = useState(''), [error, setError] = useState('');
|
|
9
|
+
const inputRef = useRef(null);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const show = () => { setOpen(true); setError(''); requestAnimationFrame(() => inputRef.current?.focus()); };
|
|
12
|
+
window.__showWebEmbedModal = show; window.addEventListener('lixsketch:open-web-embed', show);
|
|
13
|
+
return () => { delete window.__showWebEmbedModal; window.removeEventListener('lixsketch:open-web-embed', show); };
|
|
14
|
+
}, []);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!open) return undefined;
|
|
17
|
+
const escape = (event) => {
|
|
18
|
+
if (event.key === 'Escape') { setOpen(false); useSketchStore.getState().setActiveTool(TOOLS.SELECT); }
|
|
19
|
+
};
|
|
20
|
+
window.addEventListener('keydown', escape);
|
|
21
|
+
return () => window.removeEventListener('keydown', escape);
|
|
22
|
+
}, [open]);
|
|
23
|
+
if (!open) return null;
|
|
24
|
+
let requestedHostname = '';
|
|
25
|
+
try { requestedHostname = new URL(url).hostname; } catch {}
|
|
26
|
+
const whitelistRequestUrl = `https://github.com/elixpo/sketch.elixpo/issues/new?template=web-embed-whitelist.yml&title=${encodeURIComponent(`[Web embed whitelist] ${requestedHostname || 'New hostname'}`)}`;
|
|
27
|
+
const submit = (e) => {
|
|
28
|
+
e.preventDefault(); const normalized = normalizeWebEmbedUrl(url);
|
|
29
|
+
if (!normalized) { setError('This site is not currently supported for web embeds.'); return; }
|
|
30
|
+
window.__placeWebEmbed?.(normalized); setOpen(false); setUrl('');
|
|
31
|
+
};
|
|
32
|
+
return <div className="fixed inset-0 z-[12000] flex items-center justify-center bg-black/55 p-4 backdrop-blur-sm">
|
|
33
|
+
<form onSubmit={submit} className="w-full max-w-xl rounded-2xl border border-border-light bg-surface-card p-6 font-[lixFont] shadow-2xl">
|
|
34
|
+
<div className="mb-5 flex justify-between"><div><h2 className="text-lg text-text-primary">Embed a website</h2><p className="mt-1 text-xs text-text-muted">One secure embed in a movable, resizable frame.</p></div><button type="button" onClick={() => { setOpen(false); useSketchStore.getState().setActiveTool(TOOLS.SELECT); }}><i className="bx bx-x text-2xl" /></button></div>
|
|
35
|
+
<input ref={inputRef} value={url} onChange={(e) => { setUrl(e.target.value); setError(''); }} placeholder="https://www.youtube.com/watch?v=..." className="w-full rounded-xl border border-border-light bg-surface px-4 py-3 text-sm text-text-primary outline-none focus:border-accent" />
|
|
36
|
+
{error && <div className="mt-3 flex items-center justify-between gap-3 rounded-xl border border-border-light bg-surface/60 px-3 py-2.5"><p className="text-xs text-text-muted"><i className="bx bx-info-circle mr-1" />{error}</p><a href={whitelistRequestUrl} target="_blank" rel="noreferrer" className="shrink-0 rounded-lg border border-accent/50 px-3 py-1.5 text-xs text-accent">Request whitelist</a></div>}
|
|
37
|
+
<button type="submit" className="mt-5 w-full rounded-xl bg-accent px-4 py-3 text-sm text-white">Place on canvas</button>
|
|
38
|
+
</form>
|
|
39
|
+
</div>;
|
|
40
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
4
|
+
import useSketchStore, { TOOLS } from '../../store/useSketchStore'
|
|
5
|
+
import ShapeSidebar, { Divider, ToolbarButton } from './ShapeSidebar'
|
|
6
|
+
|
|
7
|
+
const COLORS = ['#f0f0f0', '#ffcccb', '#90ee90', '#add8e6', '#FFE4B5', '#DDA0DD', '#A98DEB', '#2d2d2d']
|
|
8
|
+
const FILLS = [
|
|
9
|
+
{ value: 'solid', label: 'Solid' },
|
|
10
|
+
{ value: 'hachure', label: 'Hachure' },
|
|
11
|
+
{ value: 'cross-hatch', label: 'Cross-hatch' },
|
|
12
|
+
{ value: 'dots', label: 'Dots' },
|
|
13
|
+
{ value: 'none', label: 'Clear fill' },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
export default function PaintBucketSidebar() {
|
|
17
|
+
const activeTool = useSketchStore((state) => state.activeTool)
|
|
18
|
+
const [fillColor, setFillColor] = useState('#A98DEB')
|
|
19
|
+
const [fillStyle, setFillStyle] = useState('solid')
|
|
20
|
+
const sync = useCallback((color, style) => {
|
|
21
|
+
window.paintBucketSettings = window.paintBucketSettings || {}
|
|
22
|
+
window.paintBucketSettings.fillColor = color
|
|
23
|
+
window.paintBucketSettings.fillStyle = style
|
|
24
|
+
}, [])
|
|
25
|
+
useEffect(() => sync(fillColor, fillStyle), [fillColor, fillStyle, sync])
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<ShapeSidebar visible={activeTool === TOOLS.PAINT_BUCKET}>
|
|
29
|
+
<ToolbarButton tooltip="Fill color" preview={<span className="h-4 w-4 rounded-md border border-border-light" style={{ backgroundColor: fillColor }} />}>
|
|
30
|
+
<p className="mb-2 text-xs uppercase tracking-wider text-text-muted">Fill color</p>
|
|
31
|
+
<div className="grid grid-cols-4 gap-1.5">
|
|
32
|
+
{COLORS.map((color) => <button key={color} type="button" aria-label={`Use ${color}`} onClick={() => setFillColor(color)} className={`h-7 w-7 cursor-pointer rounded-md border-[1.5px] transition ${fillColor === color ? 'scale-110 border-accent' : 'border-border-light hover:border-text-dim'}`} style={{ backgroundColor: color }} />)}
|
|
33
|
+
</div>
|
|
34
|
+
<label className="mt-2 flex cursor-pointer items-center gap-2 text-xs text-text-muted">
|
|
35
|
+
<input type="color" value={fillColor} onChange={(event) => setFillColor(event.target.value)} className="h-7 w-9 cursor-pointer rounded border-0 bg-transparent" />
|
|
36
|
+
Custom color
|
|
37
|
+
</label>
|
|
38
|
+
</ToolbarButton>
|
|
39
|
+
<Divider />
|
|
40
|
+
<ToolbarButton icon="bxs-brush" tooltip="Fill style">
|
|
41
|
+
<p className="mb-2 text-xs uppercase tracking-wider text-text-muted">Fill style</p>
|
|
42
|
+
<div className="flex flex-col gap-0.5">
|
|
43
|
+
{FILLS.map((fill) => <button key={fill.value} type="button" onClick={() => setFillStyle(fill.value)} className={`flex cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1.5 text-xs transition ${fillStyle === fill.value ? 'bg-accent text-white' : 'text-text-secondary hover:bg-surface-hover'}`}><span className="h-1.5 w-1.5 rounded-full bg-current" />{fill.label}</button>)}
|
|
44
|
+
</div>
|
|
45
|
+
</ToolbarButton>
|
|
46
|
+
<div className="max-w-[185px] px-2 text-[10px] leading-4 text-text-dim">Click a rectangle or circle to apply the selected fill.</div>
|
|
47
|
+
</ShapeSidebar>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
@@ -41,7 +41,7 @@ export default function PaintbrushSidebar() {
|
|
|
41
41
|
const updateOpacity = useCallback((v) => { setOpacity(v); if (window.updateSelectedFreehandStyle) window.updateSelectedFreehandStyle({ opacity: v }) }, [])
|
|
42
42
|
|
|
43
43
|
return (
|
|
44
|
-
<ShapeSidebar visible={activeTool === TOOLS.FREEHAND || selectedShapeSidebar === 'paintbrush'}>
|
|
44
|
+
<ShapeSidebar visible={activeTool === TOOLS.FREEHAND || activeTool === TOOLS.DRAW_SHAPE || selectedShapeSidebar === 'paintbrush'}>
|
|
45
45
|
<ToolbarButton tooltip={t('sidebar.strokeColor')}
|
|
46
46
|
preview={<span className="w-4 h-4 rounded-md border border-white/20" style={{ backgroundColor: strokeColor }} />}
|
|
47
47
|
>
|
|
@@ -19,6 +19,10 @@ export const TOOLS = {
|
|
|
19
19
|
IMAGE: 'image',
|
|
20
20
|
FRAME: 'frame',
|
|
21
21
|
ICON: 'icon',
|
|
22
|
+
DRAW_SHAPE: 'draw-shape',
|
|
23
|
+
PAINT_BUCKET: 'paint-bucket',
|
|
24
|
+
LASSO: 'lasso',
|
|
25
|
+
WEB_EMBED: 'web-embed',
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
// Action types for undo/redo
|
|
@@ -44,6 +48,10 @@ const TOOL_CURSORS = {
|
|
|
44
48
|
[TOOLS.IMAGE]: 'crosshair',
|
|
45
49
|
[TOOLS.FRAME]: 'crosshair',
|
|
46
50
|
[TOOLS.ICON]: 'crosshair',
|
|
51
|
+
[TOOLS.DRAW_SHAPE]: 'crosshair',
|
|
52
|
+
[TOOLS.PAINT_BUCKET]: 'cell',
|
|
53
|
+
[TOOLS.LASSO]: 'crosshair',
|
|
54
|
+
[TOOLS.WEB_EMBED]: 'crosshair',
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
// Tool -> sidebar mapping
|
|
@@ -56,6 +64,7 @@ const TOOL_SIDEBARS = {
|
|
|
56
64
|
[TOOLS.TEXT]: 'text',
|
|
57
65
|
[TOOLS.CODE]: 'text',
|
|
58
66
|
[TOOLS.FRAME]: 'frame',
|
|
67
|
+
[TOOLS.DRAW_SHAPE]: 'paintbrush',
|
|
59
68
|
}
|
|
60
69
|
|
|
61
70
|
// Keyboard shortcut -> tool mapping
|
|
@@ -81,6 +90,10 @@ export const SHORTCUT_MAP = {
|
|
|
81
90
|
i: TOOLS.ICON,
|
|
82
91
|
f: TOOLS.FRAME,
|
|
83
92
|
k: TOOLS.LASER,
|
|
93
|
+
d: TOOLS.DRAW_SHAPE,
|
|
94
|
+
b: TOOLS.PAINT_BUCKET,
|
|
95
|
+
s: TOOLS.LASSO,
|
|
96
|
+
w: TOOLS.WEB_EMBED,
|
|
84
97
|
}
|
|
85
98
|
|
|
86
99
|
const useSketchStore = create((set, get) => ({
|
package/src/react/toolsEnum.js
CHANGED
package/src/shapes/Frame.js
CHANGED
|
@@ -25,6 +25,10 @@ class Frame {
|
|
|
25
25
|
this.height = height;
|
|
26
26
|
this.rotation = options.rotation || 0;
|
|
27
27
|
this.frameName = options.frameName || "Frame";
|
|
28
|
+
this._frameType = options.frameType || null;
|
|
29
|
+
this._webEmbedURL = options.webEmbedURL || null;
|
|
30
|
+
this._webEmbedForeignObject = null;
|
|
31
|
+
this._webEmbedElement = null;
|
|
28
32
|
this.fillStyle = options.fillStyle || "transparent"; // 'transparent' | 'solid' | 'grid'
|
|
29
33
|
this.fillColor = options.fillColor || "#1e1e28";
|
|
30
34
|
this.gridSize = options.gridSize || 20;
|
|
@@ -81,9 +85,9 @@ class Frame {
|
|
|
81
85
|
|
|
82
86
|
draw() {
|
|
83
87
|
// Clear previous elements
|
|
84
|
-
|
|
85
|
-
this.group.removeChild(
|
|
86
|
-
}
|
|
88
|
+
Array.from(this.group.children).forEach((child) => {
|
|
89
|
+
if (child !== this._webEmbedForeignObject) this.group.removeChild(child);
|
|
90
|
+
});
|
|
87
91
|
this.anchors = [];
|
|
88
92
|
|
|
89
93
|
// Ensure defs exists for grid pattern
|
|
@@ -153,6 +157,12 @@ class Frame {
|
|
|
153
157
|
this.element = frameRect;
|
|
154
158
|
this.group.appendChild(this.element);
|
|
155
159
|
|
|
160
|
+
if (this._frameType === 'web-embed' && this._webEmbedURL) {
|
|
161
|
+
this._updateWebEmbed();
|
|
162
|
+
// Keep the frame outline above the embedded document so it remains selectable.
|
|
163
|
+
this.group.appendChild(this.element);
|
|
164
|
+
}
|
|
165
|
+
|
|
156
166
|
// Add label
|
|
157
167
|
this.addFrameLabel();
|
|
158
168
|
|
|
@@ -208,7 +218,39 @@ class Frame {
|
|
|
208
218
|
}
|
|
209
219
|
}
|
|
210
220
|
|
|
221
|
+
setWebEmbedURL(url) {
|
|
222
|
+
this._webEmbedURL = url;
|
|
223
|
+
this._frameType = 'web-embed';
|
|
224
|
+
this._updateWebEmbed();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
_updateWebEmbed() {
|
|
228
|
+
if (!this._webEmbedURL) return;
|
|
229
|
+
if (!this._webEmbedForeignObject) {
|
|
230
|
+
const foreign = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
|
|
231
|
+
const host = document.createElementNS('http://www.w3.org/1999/xhtml', 'div');
|
|
232
|
+
const embed = document.createElementNS('http://www.w3.org/1999/xhtml', 'embed');
|
|
233
|
+
host.style.cssText = 'width:100%;height:100%;overflow:hidden;background:#fff;border-radius:4px';
|
|
234
|
+
embed.setAttribute('type', 'text/html'); embed.setAttribute('width', '100%'); embed.setAttribute('height', '100%');
|
|
235
|
+
embed.setAttribute('src', this._webEmbedURL); embed.setAttribute('title', this.frameName || 'Web embed');
|
|
236
|
+
host.appendChild(embed); foreign.appendChild(host);
|
|
237
|
+
this._webEmbedForeignObject = foreign; this._webEmbedElement = embed;
|
|
238
|
+
this.group.insertBefore(foreign, this.element || null);
|
|
239
|
+
} else if (this._webEmbedElement?.getAttribute('src') !== this._webEmbedURL) {
|
|
240
|
+
this._webEmbedElement.setAttribute('src', this._webEmbedURL);
|
|
241
|
+
}
|
|
242
|
+
const inset = 2;
|
|
243
|
+
this._webEmbedForeignObject.setAttribute('x', this.x + inset);
|
|
244
|
+
this._webEmbedForeignObject.setAttribute('y', this.y + inset);
|
|
245
|
+
this._webEmbedForeignObject.setAttribute('width', Math.max(0, this.width - inset * 2));
|
|
246
|
+
this._webEmbedForeignObject.setAttribute('height', Math.max(0, this.height - inset * 2));
|
|
247
|
+
if (this.rotation) {
|
|
248
|
+
this._webEmbedForeignObject.setAttribute('transform', `rotate(${this.rotation}, ${this.x + this.width / 2}, ${this.y + this.height / 2})`);
|
|
249
|
+
} else this._webEmbedForeignObject.removeAttribute('transform');
|
|
250
|
+
}
|
|
251
|
+
|
|
211
252
|
addShapeToFrame(shape) {
|
|
253
|
+
if (this._frameType === 'web-embed') return;
|
|
212
254
|
if (shape && !this.containedShapes.includes(shape)) {
|
|
213
255
|
const oldFrame = shape.parentFrame;
|
|
214
256
|
|
|
@@ -275,7 +317,7 @@ class Frame {
|
|
|
275
317
|
|
|
276
318
|
// Check if a shape overlaps with this frame
|
|
277
319
|
isShapeInFrame(shape) {
|
|
278
|
-
if (!shape || shape === this) return false;
|
|
320
|
+
if (this._frameType === 'web-embed' || !shape || shape === this) return false;
|
|
279
321
|
|
|
280
322
|
const shapeX = shape.x || 0;
|
|
281
323
|
const shapeY = shape.y || 0;
|
|
@@ -291,6 +333,7 @@ class Frame {
|
|
|
291
333
|
}
|
|
292
334
|
|
|
293
335
|
updateContainedShapes(applyClipping = true) {
|
|
336
|
+
if (this._frameType === 'web-embed') return;
|
|
294
337
|
// Check all shapes to see if they should be in this frame
|
|
295
338
|
if (typeof shapes === 'undefined' || !Array.isArray(shapes)) return;
|
|
296
339
|
shapes.forEach(shape => {
|
package/src/shapes/ImageShape.js
CHANGED
|
@@ -38,6 +38,8 @@ class ImageShape {
|
|
|
38
38
|
}
|
|
39
39
|
this.group.appendChild(this.element);
|
|
40
40
|
this._aiBadge = null;
|
|
41
|
+
this._aiBadgeBackground = null;
|
|
42
|
+
this._aiBadgeLabel = null;
|
|
41
43
|
this._aiBadgeObserver = null;
|
|
42
44
|
if (this.isAIGenerated) this.showAIBadge();
|
|
43
45
|
svg.appendChild(this.group);
|
|
@@ -48,8 +50,19 @@ class ImageShape {
|
|
|
48
50
|
const ns = 'http://www.w3.org/2000/svg';
|
|
49
51
|
const badge = document.createElementNS(ns, 'g');
|
|
50
52
|
badge.setAttribute('class', 'ai-image-badge');
|
|
51
|
-
badge.setAttribute('aria-label', '
|
|
52
|
-
badge.style.pointerEvents = '
|
|
53
|
+
badge.setAttribute('aria-label', 'This is synthetic media generated by AI.');
|
|
54
|
+
badge.style.pointerEvents = 'all';
|
|
55
|
+
badge.style.cursor = 'help';
|
|
56
|
+
|
|
57
|
+
const title = document.createElementNS(ns, 'title');
|
|
58
|
+
title.textContent = 'This is synthetic media generated by AI.';
|
|
59
|
+
badge.appendChild(title);
|
|
60
|
+
|
|
61
|
+
badge.addEventListener('pointerenter', () => {
|
|
62
|
+
if (typeof window.__showToast === 'function') {
|
|
63
|
+
window.__showToast('This is synthetic media generated by AI.', { duration: 2200 });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
53
66
|
|
|
54
67
|
const background = document.createElementNS(ns, 'rect');
|
|
55
68
|
background.setAttribute('width', '32');
|
|
@@ -73,6 +86,8 @@ class ImageShape {
|
|
|
73
86
|
|
|
74
87
|
this.group.appendChild(badge);
|
|
75
88
|
this._aiBadge = badge;
|
|
89
|
+
this._aiBadgeBackground = background;
|
|
90
|
+
this._aiBadgeLabel = label;
|
|
76
91
|
this.updateAIBadgePosition();
|
|
77
92
|
|
|
78
93
|
if (typeof MutationObserver !== 'undefined') {
|
|
@@ -86,12 +101,40 @@ class ImageShape {
|
|
|
86
101
|
|
|
87
102
|
updateAIBadgePosition() {
|
|
88
103
|
if (!this._aiBadge) return;
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
|
|
104
|
+
const imageWidth = Number.isFinite(this.width) ? Math.abs(this.width) : 32;
|
|
105
|
+
const imageHeight = Number.isFinite(this.height) ? Math.abs(this.height) : 18;
|
|
106
|
+
const inset = Math.max(1, Math.min(6, imageWidth * 0.12, imageHeight * 0.12));
|
|
107
|
+
const x = Number.isFinite(this.x) ? this.x + inset : inset;
|
|
108
|
+
const y = Number.isFinite(this.y) ? this.y + inset : inset;
|
|
109
|
+
const availableScale = Math.min(
|
|
110
|
+
1,
|
|
111
|
+
Math.max(0, imageWidth - inset * 2) / 32,
|
|
112
|
+
Math.max(0, imageHeight - inset * 2) / 18,
|
|
113
|
+
);
|
|
114
|
+
const useDot = availableScale <= 0.75;
|
|
115
|
+
let badgeTransform;
|
|
116
|
+
|
|
117
|
+
if (useDot) {
|
|
118
|
+
const dotSize = Math.max(2, Math.min(8, imageWidth * 0.28, imageHeight * 0.28));
|
|
119
|
+
this._aiBadge.setAttribute('data-variant', 'dot');
|
|
120
|
+
this._aiBadgeBackground?.setAttribute('width', String(dotSize));
|
|
121
|
+
this._aiBadgeBackground?.setAttribute('height', String(dotSize));
|
|
122
|
+
this._aiBadgeBackground?.setAttribute('rx', String(dotSize / 2));
|
|
123
|
+
this._aiBadgeLabel?.setAttribute('display', 'none');
|
|
124
|
+
badgeTransform = `translate(${x}, ${y})`;
|
|
125
|
+
} else {
|
|
126
|
+
this._aiBadge.setAttribute('data-variant', 'label');
|
|
127
|
+
this._aiBadgeBackground?.setAttribute('width', '32');
|
|
128
|
+
this._aiBadgeBackground?.setAttribute('height', '18');
|
|
129
|
+
this._aiBadgeBackground?.setAttribute('rx', '6');
|
|
130
|
+
this._aiBadgeLabel?.removeAttribute('display');
|
|
131
|
+
badgeTransform = `translate(${x}, ${y}) scale(${availableScale})`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
this._aiBadge.setAttribute('transform', badgeTransform);
|
|
92
135
|
const imageTransform = this.element.getAttribute('transform');
|
|
93
136
|
if (imageTransform) {
|
|
94
|
-
this._aiBadge.setAttribute('transform', `${imageTransform}
|
|
137
|
+
this._aiBadge.setAttribute('transform', `${imageTransform} ${badgeTransform}`);
|
|
95
138
|
}
|
|
96
139
|
}
|
|
97
140
|
|