Report-Generator / templates /graph_editor.html
root
Working CHanges to revesion ; add preact support
e8a57cb
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Link Revision Pages</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="https://unpkg.com/@xyflow/react@12.8.5/dist/style.css">
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@18.2.0",
"react/jsx-runtime": "https://esm.sh/react@18.2.0/jsx-runtime",
"react/jsx-dev-runtime": "https://esm.sh/react@18.2.0/jsx-dev-runtime",
"react-dom": "https://esm.sh/react-dom@18.2.0",
"react-dom/client": "https://esm.sh/react-dom@18.2.0/client",
"@xyflow/react": "https://esm.sh/@xyflow/react@12.8.5?external=react,react-dom,react-dom/client"
}
}
</script>
<style>
html, body {
height: 100%;
overflow: hidden;
background: #f5f6fa;
}
#app {
height: 100%;
}
/* ── Shell ── */
.app-shell {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.app-header {
flex: 0 0 auto;
z-index: 20;
}
.app-body {
flex: 1 1 0;
min-height: 0;
display: grid;
grid-template-columns: 260px 1fr 260px;
gap: 0.75rem;
padding: 0 0.75rem 0.75rem;
overflow: hidden;
}
/* ── Side Panels ── */
.side-panel {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.side-panel .card {
display: flex;
flex-direction: column;
flex: 1 1 0;
min-height: 0;
overflow: hidden;
}
.side-panel .card-header {
flex: 0 0 auto;
}
.side-panel .card-body {
flex: 1 1 0;
min-height: 0;
overflow: hidden;
padding: 0;
}
.scroll-list {
height: 100%;
overflow-y: auto;
overscroll-behavior-y: contain;
-webkit-overflow-scrolling: touch;
padding: 0.75rem;
}
/* ── Center Column ── */
.center-col {
display: flex;
flex-direction: column;
min-height: 0;
gap: 0.75rem;
overflow: hidden;
}
.center-col .canvas-wrap {
flex: 1 1 0;
min-height: 0;
overflow: hidden;
}
.canvas-wrap .card {
height: 100%;
display: flex;
flex-direction: column;
}
.canvas-wrap .card-body {
flex: 1 1 0;
min-height: 0;
padding: 0;
position: relative;
}
.canvas-stage {
width: 100%;
height: 100%;
position: absolute;
inset: 0;
}
/* ── Item Cards ── */
.q-item, .p-item {
border: 1px solid var(--bs-border-color);
border-radius: 0.5rem;
padding: 0.625rem;
background: #fff;
cursor: pointer;
transition: border-color .15s, box-shadow .15s;
}
.q-item:hover, .p-item:hover {
border-color: var(--bs-primary);
}
.q-item.active {
border-color: var(--bs-primary);
box-shadow: 0 0 0 .2rem rgba(13,110,253,.15);
}
.p-item.active {
border-color: var(--bs-success);
box-shadow: 0 0 0 .2rem rgba(25,135,84,.15);
}
.q-item.linked {
background: #f0f7ff;
border-color: #86b7fe;
}
.p-item.on-canvas {
background: #f0fdf4;
border-color: #86efac;
}
.item-thumb {
width: 100%;
max-height: 120px;
object-fit: contain;
border-radius: 0.375rem;
background: #f8f9fa;
}
/* ── Flow Nodes ── */
.node-card {
width: 195px;
border-radius: 0.5rem;
border: 2px solid transparent;
background: #fff;
box-shadow: 0 2px 8px rgba(0,0,0,.08);
overflow: hidden;
font-size: .8125rem;
}
.node-card.node-selected {
border-color: var(--bs-primary);
box-shadow: 0 0 0 3px rgba(13,110,253,.18);
}
.node-header {
padding: 0.375rem 0.625rem;
font-weight: 700;
font-size: .8125rem;
}
.question-node .node-header { background: #e7f1ff; color: #084298; }
.page-node .node-header { background: #d1e7dd; color: #0f5132; }
.node-body {
padding: 0.5rem;
}
.node-thumb {
width: 100%;
max-height: 100px;
object-fit: contain;
border-radius: 0.375rem;
background: #f8f9fa;
}
/* ── Upload Drop Zone ── */
.upload-zone {
border: 2px dashed var(--bs-border-color);
border-radius: 0.75rem;
padding: 2.5rem 1.5rem;
text-align: center;
background: #fbfcfe;
}
/* ── Responsive ── */
@media (max-width: 1200px) {
.app-body { grid-template-columns: 220px 1fr 220px; }
}
@media (max-width: 991px) {
.app-body { grid-template-columns: 190px 1fr 190px; gap: 0.5rem; padding: 0 0.5rem 0.5rem; }
.item-thumb { max-height: 90px; }
}
@media (max-width: 767px) {
html, body { overflow: auto; }
.app-shell { height: auto; min-height: 100vh; }
.app-body {
grid-template-columns: 1fr;
grid-template-rows: auto;
height: auto;
overflow: visible;
}
.side-panel .card,
.canvas-wrap .card {
max-height: 60vh;
}
.center-col { min-height: 50vh; }
}
</style>
</head>
<body>
<div id="app"></div>
<script>
window.GRAPH_DATA = {
sessionId: '{{ session_id }}',
questions: {{ questions | tojson }},
pdfPages: {{ pdf_pages | tojson }},
existingLinks: {{ existing_links | tojson }}
};
</script>
<script type="module">
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import {
ReactFlow,
Background,
Controls,
MiniMap,
Handle,
Position,
MarkerType,
addEdge,
useEdgesState,
useNodesState
} from '@xyflow/react';
const h = React.createElement;
const sessionId = window.GRAPH_DATA.sessionId;
const questions = window.GRAPH_DATA.questions || [];
const pdfPages = window.GRAPH_DATA.pdfPages || [];
const existingLinks = window.GRAPH_DATA.existingLinks || [];
/* ═══════ Custom Nodes ═══════ */
function QuestionNode({ data, selected }) {
return h('div', { className: `node-card question-node ${selected ? 'node-selected' : ''}` },
h('div', { className: 'node-header' }, `Q${data.questionNumber}`),
h('div', { className: 'node-body' },
data.thumbUrl
? h('img', { src: data.thumbUrl, className: 'node-thumb mb-1', alt: '' })
: null,
h('div', { className: 'text-muted small text-truncate' }, data.subject || '—'),
h('div', { className: 'small fw-semibold text-truncate' }, data.chapter || '—'),
h(Handle, { type: 'target', position: Position.Left, style: { width: 9, height: 9, background: '#198754' } }),
h(Handle, { type: 'source', position: Position.Right, style: { width: 9, height: 9, background: '#0d6efd' } })
)
);
}
function PageNode({ data, selected }) {
return h('div', { className: `node-card page-node ${selected ? 'node-selected' : ''}` },
h('div', { className: 'node-header' }, `Page ${data.pageNumber}`),
h('div', { className: 'node-body' },
data.imageUrl
? h('img', { src: data.imageUrl, className: 'node-thumb mb-1', alt: '' })
: null,
h('div', { className: 'text-muted small' }, 'Revision page'),
h(Handle, { type: 'target', position: Position.Left, style: { width: 9, height: 9, background: '#198754' } }),
h(Handle, { type: 'source', position: Position.Right, style: { width: 9, height: 9, background: '#0d6efd' } })
)
);
}
const nodeTypes = { questionNode: QuestionNode, pageNode: PageNode };
/* ═══════ Graph Builders ═══════ */
function buildQuestionNodes(list) {
return list.map((q, i) => ({
id: `q-${q.image_id}`,
type: 'questionNode',
position: { x: 40, y: 40 + i * 230 },
draggable: true,
data: {
imageId: q.image_id,
questionNumber: q.question_number || i + 1,
subject: q.subject || '',
chapter: q.chapter || '',
thumbUrl: q.thumb_url || ''
}
}));
}
function buildInitialGraph(qList, pList, links) {
const qNodes = buildQuestionNodes(qList);
const linkedPageIds = [...new Set(links.map(l => l.page_id).filter(Boolean))];
const pageMap = new Map(pList.map(p => [p.image_id, p]));
const pNodes = linkedPageIds.map((pid, i) => {
const pg = pageMap.get(pid);
if (!pg) return null;
return {
id: `p-${pg.image_id}`,
type: 'pageNode',
position: { x: 400 + (i % 2) * 250, y: 40 + Math.floor(i / 2) * 230 },
draggable: true,
data: { pageImageId: pg.image_id, pageNumber: pg.page_number, imageUrl: pg.image_url }
};
}).filter(Boolean);
const edges = links.map(l => ({
id: `p-${l.page_id}__q-${l.question_image_id}`,
source: `p-${l.page_id}`,
target: `q-${l.question_image_id}`,
type: 'smoothstep',
markerEnd: { type: MarkerType.ArrowClosed, color: '#0d6efd' },
style: { stroke: '#0d6efd', strokeWidth: 2.5 }
})).filter(e => pNodes.some(n => n.id === e.source) && qNodes.some(n => n.id === e.target));
return { nodes: qNodes.concat(pNodes), edges };
}
/* ═══════ App Component ═══════ */
function App() {
const initial = useMemo(() => buildInitialGraph(questions, pdfPages, existingLinks), []);
const [nodes, setNodes, onNodesChange] = useNodesState(initial.nodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initial.edges);
const [selQ, setSelQ] = useState(null);
const [selP, setSelP] = useState(null);
const [directMode, setDirectMode] = useState(true);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [toast, setToast] = useState(null);
const fileRef = useRef(null);
const addedPageIds = useMemo(
() => new Set(nodes.filter(n => n.id.startsWith('p-')).map(n => n.data.pageImageId)),
[nodes]
);
const linkedQIds = useMemo(() => new Set(edges.map(e => e.target)), [edges]);
useEffect(() => {
setNodes(cur => cur.map(n => {
const s = n.id === selQ || n.id === selP;
return n.selected === s ? n : { ...n, selected: s };
}));
}, [selQ, selP, setNodes]);
const flash = useCallback((msg, type = 'info') => {
setToast({ msg, type });
clearTimeout(flash._t);
flash._t = setTimeout(() => setToast(null), 2800);
}, []);
const addPageNode = useCallback((page) => {
const nid = `p-${page.image_id}`;
if (addedPageIds.has(page.image_id)) { setSelP(nid); return nid; }
const cnt = nodes.filter(n => n.id.startsWith('p-')).length;
setNodes(cur => [
...cur.map(n => ({ ...n, selected: false })),
{
id: nid, type: 'pageNode', draggable: true,
position: { x: 400 + (cnt % 2) * 250, y: 40 + Math.floor(cnt / 2) * 230 },
data: { pageImageId: page.image_id, pageNumber: page.page_number, imageUrl: page.image_url }
}
]);
setSelP(nid);
return nid;
}, [addedPageIds, nodes, setNodes]);
const removeSelPage = useCallback(() => {
if (!selP) return;
setNodes(c => c.filter(n => n.id !== selP));
setEdges(c => c.filter(e => e.source !== selP && e.target !== selP));
setSelP(null);
}, [selP, setNodes, setEdges]);
const linkPair = useCallback((pid, qid, msg) => {
if (!pid || !qid) return;
setSelP(pid); setSelQ(qid);
setEdges(cur => {
const eid = `${pid}__${qid}`;
const cleaned = cur.filter(e => e.target !== qid);
return [...cleaned, {
id: eid, source: pid, target: qid, type: 'smoothstep',
markerEnd: { type: MarkerType.ArrowClosed, color: '#0d6efd' },
style: { stroke: '#0d6efd', strokeWidth: 2.5 }
}];
});
flash(msg || 'Link created.', 'success');
}, [setEdges, flash]);
const createLink = useCallback(() => {
if (!selQ || !selP) { flash('Select a question and a page first.', 'warning'); return; }
linkPair(selP, selQ);
}, [selQ, selP, linkPair, flash]);
const onConnect = useCallback(p => { if (p.source && p.target) linkPair(p.source, p.target); }, [linkPair]);
const clearLinks = useCallback(() => { setEdges([]); flash('All links cleared.', 'secondary'); }, [setEdges, flash]);
const handleNodeClick = useCallback((_, n) => {
if (n.id.startsWith('q-')) setSelQ(n.id);
if (n.id.startsWith('p-')) setSelP(n.id);
}, []);
const handleSave = useCallback(async () => {
const links = edges.map(e => {
const src = nodes.find(n => n.id === e.source);
const tgt = nodes.find(n => n.id === e.target);
return { page_id: src?.data?.pageImageId, question_image_id: tgt?.data?.imageId, page_number: src?.data?.pageNumber };
}).filter(l => l.page_id && l.question_image_id);
if (!links.length) { flash('No links to save.', 'warning'); return; }
setSaving(true);
try {
const r = await fetch('/session/graph/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, links }) });
const j = await r.json();
if (!r.ok || !j.success) throw new Error(j.error || 'Save failed');
flash(`Saved ${links.length} link(s).`, 'success');
setTimeout(() => { window.location.href = `/question_entry_v2/${sessionId}`; }, 900);
} catch (err) { flash(err.message, 'danger'); } finally { setSaving(false); }
}, [edges, nodes, flash]);
const uploadPdf = useCallback(async (file) => {
if (!file || file.type !== 'application/pdf') { flash('Select a PDF file.', 'warning'); return; }
setUploading(true);
try {
const fd = new FormData(); fd.append('pdf', file); fd.append('session_id', sessionId);
const r = await fetch('/session/graph/upload_pdf', { method: 'POST', body: fd });
const j = await r.json();
if (!r.ok || !j.success) throw new Error(j.error || 'Upload failed');
flash('Uploaded. Reloading…', 'success');
setTimeout(() => { window.location.href = j.redirect || `/session/${sessionId}/graph`; }, 700);
} catch (err) { flash(err.message, 'danger'); } finally { setUploading(false); }
}, [flash]);
const onFile = useCallback(e => { const f = e.target.files?.[0]; if (f) uploadPdf(f); e.target.value = ''; }, [uploadPdf]);
const tapQuestion = useCallback(qid => {
if (directMode && selP) { linkPair(selP, qid, 'Linked!'); return; }
setSelQ(qid);
}, [directMode, selP, linkPair]);
const tapPage = useCallback(page => {
const nid = addPageNode(page);
if (directMode && selQ) { setTimeout(() => linkPair(`p-${page.image_id}`, selQ, 'Linked!'), 0); return; }
setSelP(`p-${page.image_id}`);
}, [addPageNode, directMode, selQ, linkPair]);
const selectPageDropdown = useCallback(val => {
if (!val) { setSelP(null); return; }
const raw = val.startsWith('p-') ? val.slice(2) : val;
const pg = pdfPages.find(p => String(p.image_id) === String(raw));
if (pg) { addPageNode(pg); setSelP(`p-${pg.image_id}`); }
}, [addPageNode]);
/* ═══ Render ═══ */
return h('div', { className: 'app-shell' },
/* ── Header ── */
h('div', { className: 'app-header bg-white border-bottom px-3 py-2' },
h('div', { className: 'd-flex align-items-center justify-content-between flex-wrap gap-2' },
h('div', null,
h('h1', { className: 'h5 mb-0 fw-bold' },
h('i', { className: 'bi bi-diagram-3 text-primary me-2' }),
'Link Revision Pages'
),
h('small', { className: 'text-body-secondary' },
directMode ? 'Tap a page → then tap a question to link' : 'Select items or drag handles on canvas'
)
),
h('div', { className: 'd-flex gap-2 align-items-center flex-wrap' },
h('div', { className: 'form-check form-switch mb-0 me-2' },
h('input', { className: 'form-check-input', type: 'checkbox', id: 'dm', checked: directMode, onChange: e => setDirectMode(e.target.checked) }),
h('label', { className: 'form-check-label small', htmlFor: 'dm' }, 'Direct link')
),
h('a', { href: `/question_entry_v2/${sessionId}`, className: 'btn btn-sm btn-outline-secondary' },
h('i', { className: 'bi bi-arrow-left me-1' }), 'Back'
),
h('button', { className: 'btn btn-sm btn-outline-success', onClick: () => fileRef.current?.click(), disabled: uploading },
h('i', { className: `bi ${uploading ? 'bi-arrow-repeat' : 'bi-file-earmark-arrow-up'} me-1` }),
uploading ? 'Uploading…' : 'Upload PDF'
),
h('button', { className: 'btn btn-sm btn-primary', onClick: handleSave, disabled: saving || !edges.length },
h('i', { className: 'bi bi-check2-circle me-1' }),
saving ? 'Saving…' : `Save (${edges.length})`
)
)
),
h('input', { ref: fileRef, type: 'file', accept: '.pdf,application/pdf', className: 'd-none', onChange: onFile }),
toast ? h('div', { className: `alert alert-${toast.type} alert-dismissible fade show py-2 mb-0 mt-2 small` },
toast.msg,
h('button', { type: 'button', className: 'btn-close btn-close-sm', onClick: () => setToast(null) })
) : null
),
/* ── Body Grid ── */
h('div', { className: 'app-body pt-2' },
/* Left: Questions */
h('div', { className: 'side-panel' },
h('div', { className: 'card border-0 shadow-sm' },
h('div', { className: 'card-header bg-white border-bottom d-flex align-items-center justify-content-between py-2' },
h('span', { className: 'fw-semibold small' },
h('i', { className: 'bi bi-patch-question text-primary me-1' }),
`Questions (${questions.length})`
),
h('span', { className: 'badge text-bg-primary rounded-pill' }, linkedQIds.size)
),
h('div', { className: 'card-body' },
h('div', { className: 'scroll-list d-grid gap-2' },
questions.map((q, i) => {
const qid = `q-${q.image_id}`;
const linked = linkedQIds.has(qid);
const sel = selQ === qid;
return h('div', {
key: qid,
className: `q-item ${sel ? 'active' : ''} ${linked ? 'linked' : ''}`,
onClick: () => tapQuestion(qid),
role: 'button'
},
h('div', { className: 'd-flex justify-content-between align-items-center mb-1' },
h('span', { className: 'fw-bold small' }, `Q${q.question_number || i + 1}`),
linked
? h('span', { className: 'badge text-bg-success rounded-pill' }, h('i', { className: 'bi bi-link-45deg' }))
: null
),
q.thumb_url ? h('img', { src: q.thumb_url, className: 'item-thumb mb-1', alt: '' }) : null,
h('div', { className: 'text-body-secondary small text-truncate' }, q.subject || 'No subject'),
h('div', { className: 'small text-truncate' }, q.chapter || '—')
);
})
)
)
)
),
/* Center: Toolbar + Canvas */
h('div', { className: 'center-col' },
/* Pairing toolbar */
h('div', { className: 'card border-0 shadow-sm' },
h('div', { className: 'card-body p-2' },
h('div', { className: 'd-flex gap-2 align-items-center flex-wrap' },
h('select', {
className: 'form-select form-select-sm', style: { maxWidth: 160 },
value: selP || '', onChange: e => selectPageDropdown(e.target.value || null)
},
h('option', { value: '' }, '— Page —'),
pdfPages.map(p => h('option', { key: p.image_id, value: `p-${p.image_id}` }, `Page ${p.page_number}`))
),
h('i', { className: 'bi bi-arrow-right text-muted' }),
h('select', {
className: 'form-select form-select-sm', style: { maxWidth: 160 },
value: selQ || '', onChange: e => setSelQ(e.target.value || null)
},
h('option', { value: '' }, '— Question —'),
questions.map((q, i) => h('option', { key: q.image_id, value: `q-${q.image_id}` }, `Q${q.question_number || i + 1}`))
),
h('button', { className: 'btn btn-sm btn-primary', onClick: createLink },
h('i', { className: 'bi bi-link-45deg me-1' }), 'Link'
),
h('div', { className: 'vr d-none d-md-block' }),
selP ? h('button', { className: 'btn btn-sm btn-outline-danger', onClick: removeSelPage },
h('i', { className: 'bi bi-trash3 me-1' }), 'Remove'
) : null,
edges.length ? h('button', { className: 'btn btn-sm btn-outline-secondary', onClick: clearLinks },
h('i', { className: 'bi bi-x-lg me-1' }), 'Clear all'
) : null,
edges.length ? h('span', { className: 'badge text-bg-light ms-auto' }, `${edges.length} link${edges.length > 1 ? 's' : ''}`) : null
)
)
),
/* Canvas */
h('div', { className: 'canvas-wrap' },
h('div', { className: 'card border-0 shadow-sm' },
h('div', { className: 'card-body' },
pdfPages.length === 0
? h('div', { className: 'd-flex align-items-center justify-content-center h-100 p-4' },
h('div', { className: 'upload-zone' },
h('i', { className: 'bi bi-file-earmark-pdf display-4 text-danger d-block mb-3' }),
h('h5', null, 'Upload a Revision PDF'),
h('p', { className: 'text-body-secondary mb-3 small' }, 'Pages will appear on the right panel for linking.'),
h('button', { className: 'btn btn-primary btn-sm', onClick: () => fileRef.current?.click(), disabled: uploading },
uploading ? 'Uploading…' : 'Choose PDF'
)
)
)
: h('div', { className: 'canvas-stage' },
h(ReactFlow, {
nodes, edges, nodeTypes,
onNodesChange, onEdgesChange, onConnect,
onNodeClick: handleNodeClick,
fitView: true,
fitViewOptions: { padding: 0.15 },
proOptions: { hideAttribution: true },
defaultEdgeOptions: {
type: 'smoothstep',
markerEnd: { type: MarkerType.ArrowClosed, color: '#0d6efd' },
style: { stroke: '#0d6efd', strokeWidth: 2.5 }
}
},
h(Background, { gap: 20, size: 1, color: '#dee2e6' }),
h(Controls, { position: 'bottom-left', showInteractive: false }),
h(MiniMap, {
pannable: true, zoomable: true, position: 'bottom-right',
nodeColor: n => n.id.startsWith('q-') ? '#0d6efd' : '#198754',
style: { border: '1px solid #dee2e6', borderRadius: 6 }
})
)
)
)
)
)
),
/* Right: PDF Pages */
h('div', { className: 'side-panel' },
h('div', { className: 'card border-0 shadow-sm' },
h('div', { className: 'card-header bg-white border-bottom d-flex align-items-center justify-content-between py-2' },
h('span', { className: 'fw-semibold small' },
h('i', { className: 'bi bi-file-earmark-pdf text-success me-1' }),
`Pages (${pdfPages.length})`
),
h('button', { className: 'btn btn-sm btn-outline-success py-0 px-2', onClick: () => fileRef.current?.click(), disabled: uploading },
h('i', { className: 'bi bi-arrow-repeat' })
)
),
h('div', { className: 'card-body' },
h('div', { className: 'scroll-list d-grid gap-2' },
pdfPages.length === 0
? h('div', { className: 'text-center text-body-secondary py-4 small' },
h('i', { className: 'bi bi-cloud-upload d-block fs-3 mb-2' }),
'No PDF uploaded yet'
)
: pdfPages.map(page => {
const nid = `p-${page.image_id}`;
const sel = selP === nid;
const added = addedPageIds.has(page.image_id);
return h('div', { key: nid, className: `p-item ${sel ? 'active' : ''} ${added ? 'on-canvas' : ''}` },
h('img', { src: page.image_url, className: 'item-thumb mb-2', alt: '' }),
h('div', { className: 'd-flex justify-content-between align-items-center mb-2' },
h('span', { className: 'fw-semibold small' }, `Page ${page.page_number}`),
added ? h('span', { className: 'badge text-bg-success rounded-pill small' }, 'canvas') : null
),
h('div', { className: 'd-grid gap-1' },
h('button', {
className: `btn btn-sm ${directMode ? 'btn-success' : 'btn-outline-success'}`,
onClick: () => tapPage(page)
},
h('i', { className: `bi ${directMode ? 'bi-link-45deg' : 'bi-plus-circle'} me-1` }),
directMode ? 'Tap to link' : (added ? 'Select' : 'Add to canvas')
)
)
);
})
)
)
)
)
)
);
}
createRoot(document.getElementById('app')).render(h(App));
</script>
</body>
</html>