diff --git a/packages/core/src/boxes-editor.js b/packages/core/src/boxes-editor.js index 221208a..a95d81a 100644 --- a/packages/core/src/boxes-editor.js +++ b/packages/core/src/boxes-editor.js @@ -224,11 +224,20 @@ export class BoxesEditor { constructor(container, options = {}) { if (!container) throw new Error('Container element is required'); this.container = container; - this.options = { layout: options.layout || { name: 'preset' }, ...options }; + + // If a pre-loaded template JSON object is provided, extract its fields. + // Explicit options take precedence over template fields when both are supplied. + const tmpl = options.template || {}; + const palette = tmpl.palette || {}; + + this.options = { layout: options.layout || tmpl.lastLayout || { name: 'preset' }, ...options }; this._instanceId = Math.random().toString(36).slice(2, 9); - this.userStylesheet = (options.style || []).map(rule => ({ selector: rule.selector, style: { ...rule.style } })); - this._nodeTypes = (options.nodeTypes || []).map(t => ({ ...t })); - this._edgeTypes = (options.edgeTypes || []).map(t => ({ ...t })); + this.title = options.title ?? tmpl.title ?? ''; + this.description = options.description ?? tmpl.description ?? ''; + const styleSource = options.style ?? tmpl.userStylesheet ?? []; + this.userStylesheet = styleSource.map(rule => ({ selector: rule.selector, style: { ...rule.style } })); + this._nodeTypes = (options.nodeTypes ?? palette.nodeTypes ?? []).map(t => ({ ...t })); + this._edgeTypes = (options.edgeTypes ?? palette.edgeTypes ?? []).map(t => ({ ...t })); this.currentEdgeType = this._edgeTypes[0] || null; this.cy = null; this.eventHandlers = new Map(); @@ -243,7 +252,7 @@ export class BoxesEditor { this._selectedElement = null; this._ctxTarget = null; this._ctxPosition = null; - this.context = { ...(options.context || {}) }; + this.context = { ...(options.context ?? tmpl.context ?? {}) }; this._init(); @@ -289,12 +298,29 @@ export class BoxesEditor { .bxe-pane-title { font-size:12px; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:#888; margin-bottom:8px; } .bxe-pane-label { font-size:12px; font-weight:600; color:#666; margin-bottom:4px; } .bxe-pane-label small { font-weight:normal; color:#999; } -.bxe-palette { display:flex; flex-direction:column; gap:4px; margin-bottom:10px; } +.bxe-palette { display:flex; flex-direction:column; gap:4px; margin-bottom:4px; } .bxe-palette-item { display:flex; align-items:center; gap:8px; padding:5px 8px; border:1px solid #dee2e6; border-radius:5px; cursor:pointer; background:#fff; } .bxe-palette-item:hover { background:#e9f0ff; border-color:#90b8f8; } .bxe-palette-item.selected { background:#dce8ff; border-color:#4d90fe; } -.bxe-palette-label { font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.bxe-palette-label { font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; } +.bxe-palette-actions { display:flex; gap:1px; margin-left:auto; opacity:0; flex-shrink:0; } +.bxe-palette-item:hover .bxe-palette-actions { opacity:1; } +.bxe-palette-act-btn { background:none; border:none; color:#999; cursor:pointer; font-size:12px; line-height:1; padding:0 3px; border-radius:2px; } +.bxe-palette-act-btn:hover { color:#0d6efd; background:#e8f0fe; } +.bxe-palette-act-btn.danger:hover { color:#dc3545; background:#fff0f0; } .bxe-node-swatch { width:20px; height:20px; border:2px solid #999; flex-shrink:0; } +.bxe-type-form { border:1px solid #c8d8f8; border-radius:5px; padding:8px; margin-bottom:6px; background:#f0f6ff; font-size:12px; } +.bxe-type-form-row { display:grid; grid-template-columns:52px 1fr; gap:4px; margin-bottom:4px; align-items:center; } +.bxe-type-form-row label { font-size:11px; color:#666; font-weight:600; } +.bxe-type-form-row input, .bxe-type-form-row select, .bxe-type-form-row textarea { width:100%; padding:2px 4px; border:1px solid #ccc; border-radius:3px; font-size:12px; font-family:inherit; box-sizing:border-box; } +.bxe-type-form-row input[type=color] { padding:1px; height:22px; cursor:pointer; } +.bxe-type-form-row textarea { resize:vertical; min-height:36px; font-family:monospace; } +.bxe-type-form-actions { display:flex; gap:4px; margin-top:6px; } +.bxe-btn-sm { padding:2px 10px; border-radius:3px; font-size:12px; cursor:pointer; } +.bxe-btn-primary { background:#0d6efd; color:#fff; border:1px solid #0d6efd; } +.bxe-btn-primary:hover { background:#0a58ca; border-color:#0a58ca; } +.bxe-btn-secondary { background:#fff; color:#555; border:1px solid #ccc; } +.bxe-btn-secondary:hover { background:#f0f0f0; } .bxe-prop-group { margin-bottom:8px; } .bxe-prop-group > label { display:block; font-size:12px; font-weight:600; color:#666; margin-bottom:2px; } .bxe-input { width:100%; padding:3px 5px; border:1px solid #ccc; border-radius:3px; font-size:12px; } @@ -437,10 +463,22 @@ export class BoxesEditor { this._edgePaletteEl = document.createElement('div'); palettePane.appendChild(this._edgePaletteEl); this._nodePaletteEl.addEventListener('click', (e) => { + const action = e.target.closest('[data-action]')?.dataset.action; + if (action === 'edit-node-type') { this._showNodeTypeForm(e.target.closest('[data-action]').dataset.typeId); return; } + if (action === 'del-node-type') { this._deleteNodeType(e.target.closest('[data-action]').dataset.typeId); return; } + if (action === 'add-node-type') { this._showNodeTypeForm(null); return; } + if (action === 'save-node-type') { this._saveNodeTypeForm(); return; } + if (action === 'cancel-node-type') { this._nodeTypeFormEl.style.display = 'none'; return; } const item = e.target.closest('.bxe-palette-item'); if (item) this._selectNodeType(item.dataset.typeId); }); this._edgePaletteEl.addEventListener('click', (e) => { + const action = e.target.closest('[data-action]')?.dataset.action; + if (action === 'edit-edge-type') { this._showEdgeTypeForm(e.target.closest('[data-action]').dataset.typeId); return; } + if (action === 'del-edge-type') { this._deleteEdgeType(e.target.closest('[data-action]').dataset.typeId); return; } + if (action === 'add-edge-type') { this._showEdgeTypeForm(null); return; } + if (action === 'save-edge-type') { this._saveEdgeTypeForm(); return; } + if (action === 'cancel-edge-type') { this._edgeTypeFormEl.style.display = 'none'; return; } const item = e.target.closest('.bxe-palette-item'); if (item) this.setEdgeType(item.dataset.typeId); }); @@ -713,12 +751,13 @@ export class BoxesEditor { const edgeTypes = this._edgeTypes; if (!this._nodePaletteEl) return; + // ── Node types ── + this._nodePaletteEl.innerHTML = ''; + const nodePalette = document.createElement('div'); + nodePalette.className = 'bxe-palette'; if (!nodeTypes.length) { - this._nodePaletteEl.innerHTML = '
No node types defined
'; + nodePalette.innerHTML = '
No node types defined
'; } else { - this._nodePaletteEl.innerHTML = ''; - const palette = document.createElement('div'); - palette.className = 'bxe-palette'; nodeTypes.forEach((type, i) => { const radius = type.shape === 'ellipse' ? '50%' : type.shape === 'roundrectangle' ? '5px' : '2px'; const bg = type.color || '#e0e0e0'; @@ -732,19 +771,33 @@ export class BoxesEditor { const label = document.createElement('span'); label.className = 'bxe-palette-label'; label.textContent = type.label; + const actions = document.createElement('span'); + actions.className = 'bxe-palette-actions'; + actions.innerHTML = ``; item.appendChild(swatch); item.appendChild(label); - palette.appendChild(item); + item.appendChild(actions); + nodePalette.appendChild(item); }); - this._nodePaletteEl.appendChild(palette); } - + this._nodePaletteEl.appendChild(nodePalette); + this._nodeTypeFormEl = document.createElement('div'); + this._nodeTypeFormEl.className = 'bxe-type-form'; + this._nodeTypeFormEl.style.display = 'none'; + this._nodePaletteEl.appendChild(this._nodeTypeFormEl); + const addNodeBtn = document.createElement('button'); + addNodeBtn.className = 'bxe-btn-add'; + addNodeBtn.dataset.action = 'add-node-type'; + addNodeBtn.textContent = '+ Add node type'; + this._nodePaletteEl.appendChild(addNodeBtn); + + // ── Edge types ── + this._edgePaletteEl.innerHTML = ''; + const edgePalette = document.createElement('div'); + edgePalette.className = 'bxe-palette'; if (!edgeTypes.length) { - this._edgePaletteEl.innerHTML = '
No edge types defined
'; + edgePalette.innerHTML = '
No edge types defined
'; } else { - this._edgePaletteEl.innerHTML = ''; - const palette = document.createElement('div'); - palette.className = 'bxe-palette'; edgeTypes.forEach((type, i) => { const color = type.color || '#666666'; const dashArray = type.lineStyle === 'dashed' ? '6,3' : type.lineStyle === 'dotted' ? '2,3' : 'none'; @@ -755,11 +808,24 @@ export class BoxesEditor { const svgLine = dashArray === 'none' ? `` : ``; + const actions = document.createElement('span'); + actions.className = 'bxe-palette-actions'; + actions.innerHTML = ``; item.innerHTML = `${svgLine}${this._esc(type.label)}`; - palette.appendChild(item); + item.appendChild(actions); + edgePalette.appendChild(item); }); - this._edgePaletteEl.appendChild(palette); } + this._edgePaletteEl.appendChild(edgePalette); + this._edgeTypeFormEl = document.createElement('div'); + this._edgeTypeFormEl.className = 'bxe-type-form'; + this._edgeTypeFormEl.style.display = 'none'; + this._edgePaletteEl.appendChild(this._edgeTypeFormEl); + const addEdgeBtn = document.createElement('button'); + addEdgeBtn.className = 'bxe-btn-add'; + addEdgeBtn.dataset.action = 'add-edge-type'; + addEdgeBtn.textContent = '+ Add edge type'; + this._edgePaletteEl.appendChild(addEdgeBtn); this._currentNodeTypeId = nodeTypes[0]?.id || null; if (edgeTypes[0]) { @@ -767,6 +833,138 @@ export class BoxesEditor { } } + /** Show the inline node type editor form. Pass null typeId to add a new type. */ + _showNodeTypeForm(typeId) { + const type = typeId ? this._nodeTypes.find(t => t.id === typeId) : null; + const isNew = !type; + const id = type?.id ?? ''; + const label = type?.label ?? ''; + const color = type?.color ?? '#cccccc'; + const borderColor = type?.borderColor ?? '#888888'; + const shape = type?.shape ?? 'rectangle'; + const data = type?.data ? JSON.stringify(type.data, null, 2) : '{}'; + this._nodeTypeFormEl.innerHTML = ` +
${isNew ? 'Add node type' : 'Edit node type'}
+
+
+
+
+
+ +
+
+
+ + +
`; + this._nodeTypeFormEl.style.display = 'block'; + this._nodeTypeFormEl.querySelector('[data-field="label"]').focus(); + } + + _saveNodeTypeForm() { + const f = this._nodeTypeFormEl; + const id = f.querySelector('[data-field="id"]').value.trim(); + const label = f.querySelector('[data-field="label"]').value.trim(); + const color = f.querySelector('[data-field="color"]').value; + const borderColor = f.querySelector('[data-field="borderColor"]').value; + const shape = f.querySelector('[data-field="shape"]').value; + const dataRaw = f.querySelector('[data-field="data"]').value.trim(); + const origId = f.querySelector('[data-action="save-node-type"]').dataset.origId; + if (!id) { alert('ID is required'); return; } + if (!label) { alert('Label is required'); return; } + let data = {}; + if (dataRaw && dataRaw !== '{}') { + try { data = JSON.parse(dataRaw); } catch { alert('Data must be valid JSON'); return; } + } + const type = { id, label, color, borderColor, shape, data }; + if (origId) { + const idx = this._nodeTypes.findIndex(t => t.id === origId); + if (idx >= 0) this._nodeTypes[idx] = type; + } else { + if (this._nodeTypes.some(t => t.id === id)) { alert(`A node type with id "${id}" already exists`); return; } + this._nodeTypes.push(type); + } + this._renderPalette(); + this._emit('paletteChanged', { nodeTypes: this.getNodeTypes(), edgeTypes: this.getEdgeTypes() }); + } + + _deleteNodeType(typeId) { + if (!confirm(`Remove node type "${typeId}"?`)) return; + this._nodeTypes = this._nodeTypes.filter(t => t.id !== typeId); + if (this._currentNodeTypeId === typeId) this._currentNodeTypeId = this._nodeTypes[0]?.id || null; + this._renderPalette(); + this._emit('paletteChanged', { nodeTypes: this.getNodeTypes(), edgeTypes: this.getEdgeTypes() }); + } + + /** Show the inline edge type editor form. Pass null typeId to add a new type. */ + _showEdgeTypeForm(typeId) { + const type = typeId ? this._edgeTypes.find(t => t.id === typeId) : null; + const isNew = !type; + const id = type?.id ?? ''; + const label = type?.label ?? ''; + const color = type?.color ?? '#666666'; + const lineStyle = type?.lineStyle ?? 'solid'; + const data = type?.data ? JSON.stringify(type.data, null, 2) : '{}'; + this._edgeTypeFormEl.innerHTML = ` +
${isNew ? 'Add edge type' : 'Edit edge type'}
+
+
+
+
+ +
+
+
+ + +
`; + this._edgeTypeFormEl.style.display = 'block'; + this._edgeTypeFormEl.querySelector('[data-field="label"]').focus(); + } + + _saveEdgeTypeForm() { + const f = this._edgeTypeFormEl; + const id = f.querySelector('[data-field="id"]').value.trim(); + const label = f.querySelector('[data-field="label"]').value.trim(); + const color = f.querySelector('[data-field="color"]').value; + const lineStyle = f.querySelector('[data-field="lineStyle"]').value; + const dataRaw = f.querySelector('[data-field="data"]').value.trim(); + const origId = f.querySelector('[data-action="save-edge-type"]').dataset.origId; + if (!id) { alert('ID is required'); return; } + if (!label) { alert('Label is required'); return; } + let data = {}; + if (dataRaw && dataRaw !== '{}') { + try { data = JSON.parse(dataRaw); } catch { alert('Data must be valid JSON'); return; } + } + const type = { id, label, color, lineStyle, data }; + if (origId) { + const idx = this._edgeTypes.findIndex(t => t.id === origId); + if (idx >= 0) this._edgeTypes[idx] = type; + } else { + if (this._edgeTypes.some(t => t.id === id)) { alert(`An edge type with id "${id}" already exists`); return; } + this._edgeTypes.push(type); + } + this._renderPalette(); + this._emit('paletteChanged', { nodeTypes: this.getNodeTypes(), edgeTypes: this.getEdgeTypes() }); + } + + _deleteEdgeType(typeId) { + if (!confirm(`Remove edge type "${typeId}"?`)) return; + this._edgeTypes = this._edgeTypes.filter(t => t.id !== typeId); + if (this.currentEdgeType?.id === typeId) this.currentEdgeType = this._edgeTypes[0] || null; + this._renderPalette(); + this._emit('paletteChanged', { nodeTypes: this.getNodeTypes(), edgeTypes: this.getEdgeTypes() }); + } + + _selectNodeType(typeId) { this._currentNodeTypeId = typeId; if (this._nodePaletteEl) { @@ -1540,6 +1738,12 @@ export class BoxesEditor { return cls.includes('eh-ghost') || cls.includes('eh-preview'); }; return { + title: this.title, + description: this.description, + palette: { + nodeTypes: this._nodeTypes.map(t => ({ ...t })), + edgeTypes: this._edgeTypes.map(t => ({ ...t })), + }, elements: { nodes: (els.nodes || []).filter(el => !isEhGhost(el)).map(cleanEl), edges: (els.edges || []).filter(el => !isEhGhost(el)).map(cleanEl) @@ -1558,6 +1762,23 @@ export class BoxesEditor { * Import graph data. */ importGraph(graphData) { + if (graphData.title !== undefined) { + this.title = graphData.title; + } + if (graphData.description !== undefined) { + this.description = graphData.description; + } + if (graphData.palette) { + if (graphData.palette.nodeTypes) { + this._nodeTypes = graphData.palette.nodeTypes.map(t => ({ ...t })); + this._currentNodeTypeId = this._nodeTypes[0]?.id || null; + } + if (graphData.palette.edgeTypes) { + this._edgeTypes = graphData.palette.edgeTypes.map(t => ({ ...t })); + this.currentEdgeType = this._edgeTypes[0] || null; + } + this._renderPalette(); + } if (graphData.elements) { this.loadElements(graphData.elements); } @@ -1969,6 +2190,60 @@ export class BoxesEditor { return this._edgeTypes.map(t => ({ ...t })); } + /** Add a new node type to the palette. Re-renders the palette. */ + addNodeType(type) { + if (!type.id) throw new Error('Node type requires an id'); + if (this._nodeTypes.some(t => t.id === type.id)) throw new Error(`Node type "${type.id}" already exists`); + this._nodeTypes.push({ ...type }); + this._renderPalette(); + this._emit('paletteChanged', { nodeTypes: this.getNodeTypes(), edgeTypes: this.getEdgeTypes() }); + } + + /** Update an existing node type by id. Re-renders the palette. */ + updateNodeType(id, updates) { + const idx = this._nodeTypes.findIndex(t => t.id === id); + if (idx < 0) throw new Error(`Node type "${id}" not found`); + this._nodeTypes[idx] = { ...this._nodeTypes[idx], ...updates }; + this._renderPalette(); + this._emit('paletteChanged', { nodeTypes: this.getNodeTypes(), edgeTypes: this.getEdgeTypes() }); + } + + /** Remove a node type by id. Re-renders the palette. */ + removeNodeType(id) { + this._nodeTypes = this._nodeTypes.filter(t => t.id !== id); + if (this._currentNodeTypeId === id) this._currentNodeTypeId = this._nodeTypes[0]?.id || null; + this._renderPalette(); + this._emit('paletteChanged', { nodeTypes: this.getNodeTypes(), edgeTypes: this.getEdgeTypes() }); + } + + /** Add a new edge type to the palette. Re-renders the palette. */ + addEdgeType(type) { + if (!type.id) throw new Error('Edge type requires an id'); + if (this._edgeTypes.some(t => t.id === type.id)) throw new Error(`Edge type "${type.id}" already exists`); + this._edgeTypes.push({ ...type }); + if (!this.currentEdgeType) this.currentEdgeType = this._edgeTypes[0]; + this._renderPalette(); + this._emit('paletteChanged', { nodeTypes: this.getNodeTypes(), edgeTypes: this.getEdgeTypes() }); + } + + /** Update an existing edge type by id. Re-renders the palette. */ + updateEdgeType(id, updates) { + const idx = this._edgeTypes.findIndex(t => t.id === id); + if (idx < 0) throw new Error(`Edge type "${id}" not found`); + this._edgeTypes[idx] = { ...this._edgeTypes[idx], ...updates }; + if (this.currentEdgeType?.id === id) this.currentEdgeType = { ...this._edgeTypes[idx] }; + this._renderPalette(); + this._emit('paletteChanged', { nodeTypes: this.getNodeTypes(), edgeTypes: this.getEdgeTypes() }); + } + + /** Remove an edge type by id. Re-renders the palette. */ + removeEdgeType(id) { + this._edgeTypes = this._edgeTypes.filter(t => t.id !== id); + if (this.currentEdgeType?.id === id) this.currentEdgeType = this._edgeTypes[0] || null; + this._renderPalette(); + this._emit('paletteChanged', { nodeTypes: this.getNodeTypes(), edgeTypes: this.getEdgeTypes() }); + } + /** * Add a node of a named type at an optional position. */ diff --git a/packages/core/src/index.js b/packages/core/src/index.js index 236f87b..9a10307 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -1,5 +1,5 @@ export { BoxesEditor } from './boxes-editor.js'; -export { defaultTemplates } from './templates.js'; +export { defaultTemplates, getTemplate, listTemplates, loadTemplateFromUrl } from './templates.js'; export { exportToTurtle, importFromTurtle, rdfExporter, rdfImporter } from './io/rdf.js'; export { exportToJsonLD, importFromJsonLD, jsonldExporter, jsonldImporter, diff --git a/packages/core/src/templates.js b/packages/core/src/templates.js index e60ebf4..28a6826 100644 --- a/packages/core/src/templates.js +++ b/packages/core/src/templates.js @@ -1,288 +1,16 @@ /** - * Template configurations for common graph types + * Template configurations for common graph types. + * Templates are stored as JSON files and re-exported here. */ -export const defaultTemplates = { - 'owl-ontology': { - name: 'Ontology', - description: 'Template with OWL and SKOS meta-types styling (CMap Ontology edition)', - context: { - 'owl': 'http://www.w3.org/2002/07/owl#', - 'rdfs': 'http://www.w3.org/2000/01/rdf-schema#', - 'rdf': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', - 'skos': 'http://www.w3.org/2004/02/skos/core#', - 'sh': 'http://www.w3.org/ns/shacl#', - // Term definitions: properties whose values are IRI references, not literals. - // These enable JSON-LD @type coercion so that plain string values like - // "owl:Thing" are expanded to full IRIs rather than treated as string literals. - 'rdfs:subClassOf': { '@type': '@id' }, - 'rdfs:domain': { '@type': '@id' }, - 'rdfs:range': { '@type': '@id' }, - 'rdfs:subPropertyOf': { '@type': '@id' }, - 'rdfs:seeAlso': { '@type': '@id' }, - 'rdfs:isDefinedBy': { '@type': '@id' }, - 'owl:equivalentClass': { '@type': '@id' }, - 'owl:equivalentProperty': { '@type': '@id' }, - 'owl:inverseOf': { '@type': '@id' }, - 'owl:onProperty': { '@type': '@id' }, - 'owl:allValuesFrom': { '@type': '@id' }, - 'owl:someValuesFrom': { '@type': '@id' }, - 'owl:imports': { '@type': '@id' }, - 'skos:broader': { '@type': '@id' }, - 'skos:narrower': { '@type': '@id' }, - 'skos:related': { '@type': '@id' }, - 'skos:broadMatch': { '@type': '@id' }, - 'skos:narrowMatch': { '@type': '@id' }, - 'skos:exactMatch': { '@type': '@id' }, - 'skos:closeMatch': { '@type': '@id' }, - 'sh:path': { '@type': '@id' }, - 'sh:node': { '@type': '@id' }, - 'sh:qualifiedValueShape': { '@type': '@id' }, - 'sh:property': { '@type': '@id' }, - 'sh:class': { '@type': '@id' }, - 'sh:datatype': { '@type': '@id' }, - }, - nodeTypes: [ - { id: 'owl:Class', label: 'Class', data: { '@type': 'owl:Class','@id':'', 'skos:definition':'' }, color: '#E6F3FF', borderColor: '#2471A3', shape: 'roundrectangle' }, - { id: 'default', label: 'Instance', data: {'@type':'', '@id':''}, color: '#FFFFFF', borderColor: '#666666', shape: 'ellipse' }, - { id: 'skos:Concept', label: 'Concept', data: { '@type': 'skos:Concept','@id':'', 'skos:definition':'' }, color: '#E6F3FF', borderColor: '#2471A3', shape: 'roundrectangle' }, - { id: 'owl:Ontology', label: 'Ontology', data: { '@type': 'owl:Ontology', '@id':''}, color: '#FFFACD', borderColor: '#B8860B', shape: 'roundrectangle' }, - ], - edgeTypes: [ - { id: 'rdfs:subClassOf', label: 'are', data: { '@id': 'rdfs:subClassOf' }, color: '#555555', lineStyle: 'solid' }, - { id: 'rdf:type', label: 'a', data: { '@id': 'rdf:type' }, color: '#8E44AD', lineStyle: 'solid' }, - { id: 'skos:related', label: 'related', data: {'@id':'skos:related'}, color: '#666666', lineStyle: 'solid' }, - { id: 'skos:broader', label: 'broader', data: {'@id':'skos:broader'}, color: '#777777', lineStyle: 'solid' }, - { id: 'skos:narrower', label: 'narrower', data: {'@id':'skos:narrower'}, color: '#777777', lineStyle: 'solid' }, - { - id: 'owl:ObjectProperty', - label: 'ObjectProperty', - data: { '@type': 'owl:ObjectProperty' }, - color: '#2471A3', - lineStyle: 'dashed', - // Reified property mapping - source_property: 'rdfs:domain', - target_property: 'rdfs:range', - }, - { - id: 'owl:DatatypeProperty', - label: 'DatatypeProperty', - data: { '@type': 'owl:DatatypeProperty' }, - color: '#1E8449', - lineStyle: 'solid', - // Reified property mapping - source_property: 'rdfs:domain', - target_property: 'rdfs:range', - }, - { - id: 'QualifiedPropertyShape', - label: 'Qualified Property Shape', - data: { - '@type': 'sh:PropertyShape', - 'sh:path': '', // This would be the property being qualified - 'sh:qualifiedMinCount': 0, // Default cardinality - }, - color: '#2471A3', - lineStyle: 'dashed', - // Reified property mapping for SHACL Qualified property shapes - target_property: 'sh:qualifiedValueShape', - reverse_source_property: 'sh:property' // This indicates that the source node is connected via sh:property to the QualifiedPropertyShape - }, - { - id: 'PropertyShape', - label: 'Property Shape', - data: { - '@type': 'sh:PropertyShape', - 'sh:path': '', // This would be the property being qualified - }, - color: '#2471A3', - lineStyle: 'dashed', - // Reified property mapping for SHACL Qualified property shapes - target_property: 'sh:node', - reverse_source_property: 'sh:property' // This indicates that the source node is connected via sh:property to the QualifiedPropertyShape - }, - ], - elements: { - nodes: [], - edges: [] - }, - style: [ - // Default node - plain concept box - { - selector: 'node', - style: { - 'shape': 'ellipse', - 'background-color': '#FFFFFF', - 'border-width': 2, - 'border-color': '#666666', - 'width': 'label', - 'height': 'label', - 'padding': '8px', - 'text-valign': 'center', - 'text-halign': 'center', - 'font-size': '13px', - 'color': '#000000', - 'min-width': '60px' - } - }, - // owl:Ontology - yellow box (CMap Ontology style) - { - selector: 'node[\\@type = "owl:Ontology"]', - style: { - 'shape': 'roundrectangle', - 'background-color': '#FFFACD', - 'border-width': 1, - 'border-color': '#B8860B', - 'font-weight': 'bold', - 'color': '#000000' - } - }, - // owl:Class - blue rounded box - { - selector: 'node[\\@type = "owl:Class"]', - style: { - 'shape': 'roundrectangle', - 'background-color': '#E6F3FF', - 'border-width': 1, - 'border-color': '#2471A3', - 'font-size': '14px', - 'font-weight': 'bold', - 'color': '#154360' - } - }, - // rdfs:Class - { - selector: 'node[\\@type = "rdfs:Class"]', - style: { - 'shape': 'roundrectangle', - 'background-color': '#E6F3FF', - 'border-width': 1, - 'border-color': '#2471A3', - 'font-size': '14px', - 'font-weight': 'bold', - 'color': '#154360' - } - }, - // Default edge - { - selector: 'edge', - style: { - 'label': 'data(label)', - 'line-color': '#666666', - 'target-arrow-color': '#666666', - 'target-arrow-shape': 'triangle', - 'curve-style': 'bezier', - 'font-size': '11px', - 'text-background-color': '#FFFFFF', - 'text-background-opacity': 0.8, - 'text-background-padding': '2px', - 'width': 1.5 - } - }, - // owl:ObjectProperty edge - { - selector: 'edge[\\@type = "owl:ObjectProperty"]', - style: { - 'line-color': '#2471A3', - 'target-arrow-color': '#2471A3', - 'width': 2 - } - }, - // owl:DatatypeProperty edge - dashed - { - selector: 'edge[\\@type = "owl:DatatypeProperty"]', - style: { - 'line-color': '#1E8449', - 'target-arrow-color': '#1E8449', - 'line-style': 'dashed', - 'width': 1.5 - } - }, - // rdfs:subClassOf - hollow triangle arrow (inheritance) - { - selector: 'edge[\\@id = "rdfs:subClassOf"]', - style: { - 'line-color': '#555555', - 'target-arrow-color': '#555555', - 'target-arrow-shape': 'triangle', - 'width': 2 - } - }, - // rdf:type edge - { - selector: 'edge[\\@id = "rdf:type"]', - style: { - 'line-color': '#884EA0', - 'target-arrow-color': '#884EA0', - 'line-style': 'dotted', - 'width': 1.5 - } - } - ] - }, - - 'arrows': { - name: 'Arrows', - description: 'Basic graph template similar to Arrows (Neo4j)', - nodeTypes: [ - { id: 'default', label: 'Node', data: {}, color: '#6FB1FC', borderColor: '#3A7CC5', shape: 'ellipse' } - ], - edgeTypes: [ - { id: 'default', label: 'RELATES_TO', data: {}, color: '#6FB1FC', lineStyle: 'solid' } - ], - elements: { - nodes: [], - edges: [] - }, - style: [ - { - selector: 'node', - style: { - 'background-color': '#6FB1FC', - 'shape': 'ellipse', - 'width': '80px', - 'height': '80px', - 'border-width': 3, - 'border-color': '#3A7CC5', - 'color': '#000000', - 'font-size': '13px', - 'text-valign': 'center', - 'text-halign': 'center' - } - }, - { - selector: 'edge', - style: { - 'label': 'data(label)', - 'line-color': '#9DB8D2', - 'target-arrow-color': '#9DB8D2', - 'target-arrow-shape': 'triangle', - 'width': 2, - 'curve-style': 'bezier', - 'font-size': '11px', - 'text-background-color': '#FFFFFF', - 'text-background-opacity': 0.8, - 'text-background-padding': '2px' - } - } - ] - }, +import blankTemplate from './templates/blank.json'; +import arrowsTemplate from './templates/arrows.json'; +import owlOntologyTemplate from './templates/owl-ontology.json'; - 'blank': { - name: 'Blank', - description: 'Empty graph with default styling', - nodeTypes: [ - { id: 'default', label: 'Node', data: {}, color: '#CCCCCC', borderColor: '#888888', shape: 'rectangle' } - ], - edgeTypes: [ - { id: 'default', label: 'edge', data: {}, color: '#666666', lineStyle: 'solid' } - ], - elements: { - nodes: [], - edges: [] - }, - style: [] - } +export const defaultTemplates = { + 'blank': blankTemplate, + 'arrows': arrowsTemplate, + 'owl-ontology': owlOntologyTemplate, }; /** @@ -292,13 +20,24 @@ export function getTemplate(name) { return defaultTemplates[name] || defaultTemplates['blank']; } +/** + * Fetch a template JSON file from a URL and return the parsed object. + * @param {string} url + * @returns {Promise} + */ +export async function loadTemplateFromUrl(url) { + const response = await fetch(url); + if (!response.ok) throw new Error(`HTTP ${response.status} loading template: ${url}`); + return response.json(); +} + /** * List all available templates */ export function listTemplates() { return Object.keys(defaultTemplates).map(key => ({ id: key, - name: defaultTemplates[key].name, - description: defaultTemplates[key].description + title: defaultTemplates[key].title, + description: defaultTemplates[key].description, })); } diff --git a/packages/core/src/templates/arrows.json b/packages/core/src/templates/arrows.json new file mode 100644 index 0000000..fa104a9 --- /dev/null +++ b/packages/core/src/templates/arrows.json @@ -0,0 +1,48 @@ +{ + "version": "1.0.0", + "title": "Arrows", + "description": "Basic graph template similar to Arrows (Neo4j)", + "palette": { + "nodeTypes": [ + { "id": "default", "label": "Node", "data": {}, "color": "#6FB1FC", "borderColor": "#3A7CC5", "shape": "ellipse" } + ], + "edgeTypes": [ + { "id": "default", "label": "RELATES_TO", "data": {}, "color": "#6FB1FC", "lineStyle": "solid" } + ] + }, + "context": {}, + "userStylesheet": [ + { + "selector": "node", + "style": { + "background-color": "#6FB1FC", + "shape": "ellipse", + "width": "80px", + "height": "80px", + "border-width": 3, + "border-color": "#3A7CC5", + "color": "#000000", + "font-size": "13px", + "text-valign": "center", + "text-halign": "center" + } + }, + { + "selector": "edge", + "style": { + "label": "data(label)", + "line-color": "#9DB8D2", + "target-arrow-color": "#9DB8D2", + "target-arrow-shape": "triangle", + "width": 2, + "curve-style": "bezier", + "font-size": "11px", + "text-background-color": "#FFFFFF", + "text-background-opacity": 0.8, + "text-background-padding": "2px" + } + } + ], + "lastLayout": { "name": "preset" }, + "elements": { "nodes": [], "edges": [] } +} diff --git a/packages/core/src/templates/blank.json b/packages/core/src/templates/blank.json new file mode 100644 index 0000000..f99a9f2 --- /dev/null +++ b/packages/core/src/templates/blank.json @@ -0,0 +1,17 @@ +{ + "version": "1.0.0", + "title": "Blank", + "description": "Empty graph with default styling", + "palette": { + "nodeTypes": [ + { "id": "default", "label": "Node", "data": {}, "color": "#CCCCCC", "borderColor": "#888888", "shape": "rectangle" } + ], + "edgeTypes": [ + { "id": "default", "label": "edge", "data": {}, "color": "#666666", "lineStyle": "solid" } + ] + }, + "context": {}, + "userStylesheet": [], + "lastLayout": { "name": "preset" }, + "elements": { "nodes": [], "edges": [] } +} diff --git a/packages/core/src/templates/owl-ontology.json b/packages/core/src/templates/owl-ontology.json new file mode 100644 index 0000000..f4dbe97 --- /dev/null +++ b/packages/core/src/templates/owl-ontology.json @@ -0,0 +1,214 @@ +{ + "version": "1.0.0", + "title": "Ontology or RDF File", + "description": "Template with OWL and SKOS meta-types styling (CMap Ontology edition)", + "palette": { + "nodeTypes": [ + { "id": "owl:Class", "label": "Class", "data": { "@type": "owl:Class", "@id": "", "skos:definition": "" }, "color": "#E6F3FF", "borderColor": "#2471A3", "shape": "roundrectangle" }, + { "id": "default", "label": "Instance", "data": { "@type": "", "@id": "" }, "color": "#FFFFFF", "borderColor": "#666666", "shape": "ellipse" }, + { "id": "skos:Concept","label": "Concept", "data": { "@type": "skos:Concept", "@id": "", "skos:definition": "" }, "color": "#E6F3FF", "borderColor": "#2471A3", "shape": "roundrectangle" }, + { "id": "owl:Ontology","label": "Ontology", "data": { "@type": "owl:Ontology", "@id": "" }, "color": "#FFFACD", "borderColor": "#B8860B", "shape": "roundrectangle" } + ], + "edgeTypes": [ + { "id": "rdfs:subClassOf", "label": "are", "data": { "@id": "rdfs:subClassOf" }, "color": "#555555", "lineStyle": "solid" }, + { "id": "rdf:type", "label": "a", "data": { "@id": "rdf:type" }, "color": "#8E44AD", "lineStyle": "solid" }, + { "id": "skos:related", "label": "related", "data": { "@id": "skos:related" }, "color": "#666666", "lineStyle": "solid" }, + { "id": "skos:broader", "label": "broader", "data": { "@id": "skos:broader" }, "color": "#777777", "lineStyle": "solid" }, + { "id": "skos:narrower", "label": "narrower", "data": { "@id": "skos:narrower" }, "color": "#777777", "lineStyle": "solid" }, + { + "id": "owl:ObjectProperty", + "label": "ObjectProperty", + "data": { "@type": "owl:ObjectProperty" }, + "color": "#2471A3", + "lineStyle": "dashed", + "source_property": "rdfs:domain", + "target_property": "rdfs:range" + }, + { + "id": "owl:DatatypeProperty", + "label": "DatatypeProperty", + "data": { "@type": "owl:DatatypeProperty" }, + "color": "#1E8449", + "lineStyle": "solid", + "source_property": "rdfs:domain", + "target_property": "rdfs:range" + }, + { + "id": "QualifiedPropertyShape", + "label": "Qualified Property Shape", + "data": { + "@type": "sh:PropertyShape", + "sh:path": "", + "sh:qualifiedMinCount": 0 + }, + "color": "#2471A3", + "lineStyle": "dashed", + "target_property": "sh:qualifiedValueShape", + "reverse_source_property": "sh:property" + }, + { + "id": "PropertyShape", + "label": "Property Shape", + "data": { + "@type": "sh:PropertyShape", + "sh:path": "" + }, + "color": "#2471A3", + "lineStyle": "dashed", + "target_property": "sh:node", + "reverse_source_property": "sh:property" + } + ] + }, + "context": { + "owl": "http://www.w3.org/2002/07/owl#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "skos": "http://www.w3.org/2004/02/skos/core#", + "sh": "http://www.w3.org/ns/shacl#", + "rdfs:subClassOf": { "@type": "@id" }, + "rdfs:domain": { "@type": "@id" }, + "rdfs:range": { "@type": "@id" }, + "rdfs:subPropertyOf": { "@type": "@id" }, + "rdfs:seeAlso": { "@type": "@id" }, + "rdfs:isDefinedBy": { "@type": "@id" }, + "owl:equivalentClass": { "@type": "@id" }, + "owl:equivalentProperty": { "@type": "@id" }, + "owl:inverseOf": { "@type": "@id" }, + "owl:onProperty": { "@type": "@id" }, + "owl:allValuesFrom": { "@type": "@id" }, + "owl:someValuesFrom": { "@type": "@id" }, + "owl:imports": { "@type": "@id" }, + "skos:broader": { "@type": "@id" }, + "skos:narrower": { "@type": "@id" }, + "skos:related": { "@type": "@id" }, + "skos:broadMatch": { "@type": "@id" }, + "skos:narrowMatch": { "@type": "@id" }, + "skos:exactMatch": { "@type": "@id" }, + "skos:closeMatch": { "@type": "@id" }, + "sh:path": { "@type": "@id" }, + "sh:node": { "@type": "@id" }, + "sh:qualifiedValueShape": { "@type": "@id" }, + "sh:property": { "@type": "@id" }, + "sh:class": { "@type": "@id" }, + "sh:datatype": { "@type": "@id" } + }, + "userStylesheet": [ + { + "selector": "node", + "style": { + "shape": "ellipse", + "background-color": "#FFFFFF", + "border-width": 2, + "border-color": "#666666", + "width": "label", + "height": "label", + "padding": "8px", + "text-valign": "center", + "text-halign": "center", + "font-size": "13px", + "color": "#000000", + "min-width": "60px" + } + }, + { + "selector": "node[\\@type = \"owl:Ontology\"]", + "style": { + "shape": "roundrectangle", + "background-color": "#FFFACD", + "border-width": 1, + "border-color": "#B8860B", + "font-weight": "bold", + "color": "#000000" + } + }, + { + "selector": "node[\\@type = \"owl:Class\"]", + "style": { + "shape": "roundrectangle", + "background-color": "#E6F3FF", + "border-width": 1, + "border-color": "#2471A3", + "font-size": "14px", + "font-weight": "bold", + "color": "#154360" + } + }, + { + "selector": "node[\\@type = \"rdfs:Class\"]", + "style": { + "shape": "roundrectangle", + "background-color": "#E6F3FF", + "border-width": 1, + "border-color": "#2471A3", + "font-size": "14px", + "font-weight": "bold", + "color": "#154360" + } + }, + { + "selector": "node[\\@type = \"skos:Concept\"]", + "style": { + "shape": "roundrectangle", + "background-color": "#E6F3FF", + "border-width": 1, + "border-color": "#2471A3", + "font-size": "14px", + "font-weight": "bold", + "color": "#154360" + } + }, + { + "selector": "edge", + "style": { + "label": "data(label)", + "line-color": "#666666", + "target-arrow-color": "#666666", + "target-arrow-shape": "triangle", + "curve-style": "bezier", + "font-size": "11px", + "text-background-color": "#FFFFFF", + "text-background-opacity": 0.8, + "text-background-padding": "2px", + "width": 1.5 + } + }, + { + "selector": "edge[\\@type = \"owl:ObjectProperty\"]", + "style": { + "line-color": "#2471A3", + "target-arrow-color": "#2471A3", + "width": 2 + } + }, + { + "selector": "edge[\\@type = \"owl:DatatypeProperty\"]", + "style": { + "line-color": "#1E8449", + "target-arrow-color": "#1E8449", + "line-style": "dashed", + "width": 1.5 + } + }, + { + "selector": "edge[\\@id = \"rdfs:subClassOf\"]", + "style": { + "line-color": "#555555", + "target-arrow-color": "#555555", + "target-arrow-shape": "triangle", + "width": 2 + } + }, + { + "selector": "edge[\\@id = \"rdf:type\"]", + "style": { + "line-color": "#884EA0", + "target-arrow-color": "#884EA0", + "line-style": "dotted", + "width": 1.5 + } + } + ], + "lastLayout": { "name": "preset" }, + "elements": { "nodes": [], "edges": [] } +} diff --git a/packages/core/tests/boxes-editor.test.js b/packages/core/tests/boxes-editor.test.js index f10b3ac..6f24cfe 100644 --- a/packages/core/tests/boxes-editor.test.js +++ b/packages/core/tests/boxes-editor.test.js @@ -144,7 +144,7 @@ describe('BoxesEditor', () => { editor = new BoxesEditor(container); }); - it('should export graph data', () => { + it('should export graph data including palette', () => { editor.addNode({ id: 'n1', label: 'Node 1' }); editor.addEdge('n1', 'n1', { label: 'self' }); @@ -152,6 +152,45 @@ describe('BoxesEditor', () => { expect(exported.elements.nodes).toHaveLength(1); expect(exported.elements.edges).toHaveLength(1); expect(exported.version).toBe('1.0.0'); + expect(exported.palette).toBeDefined(); + expect(Array.isArray(exported.palette.nodeTypes)).toBe(true); + expect(Array.isArray(exported.palette.edgeTypes)).toBe(true); + }); + + it('should restore palette from importGraph', () => { + const graphData = { + elements: { nodes: [], edges: [] }, + palette: { + nodeTypes: [{ id: 'myNode', label: 'My Node', data: {}, color: '#ff0000', shape: 'ellipse' }], + edgeTypes: [{ id: 'myEdge', label: 'My Edge', data: {}, color: '#00ff00', lineStyle: 'solid' }], + } + }; + editor.importGraph(graphData); + const nodeTypes = editor.getNodeTypes(); + const edgeTypes = editor.getEdgeTypes(); + expect(nodeTypes).toHaveLength(1); + expect(nodeTypes[0].id).toBe('myNode'); + expect(edgeTypes).toHaveLength(1); + expect(edgeTypes[0].id).toBe('myEdge'); + }); + + it('should accept template option in constructor', () => { + const template = { + title: 'Test Template', + description: 'For testing', + palette: { + nodeTypes: [{ id: 'tNode', label: 'T Node', data: {}, color: '#aabbcc', shape: 'rectangle' }], + edgeTypes: [{ id: 'tEdge', label: 'T Edge', data: {}, color: '#ccbbaa', lineStyle: 'dashed' }], + }, + context: { ex: 'http://example.org/' }, + userStylesheet: [], + elements: { nodes: [], edges: [] }, + }; + const tmplEditor = new BoxesEditor(container, { template }); + expect(tmplEditor.title).toBe('Test Template'); + expect(tmplEditor.getNodeTypes()[0].id).toBe('tNode'); + expect(tmplEditor.getEdgeTypes()[0].id).toBe('tEdge'); + tmplEditor.destroy(); }); it('should import graph data', () => { diff --git a/packages/core/tests/templates.test.js b/packages/core/tests/templates.test.js index e9c36fd..aea71e7 100644 --- a/packages/core/tests/templates.test.js +++ b/packages/core/tests/templates.test.js @@ -5,25 +5,38 @@ describe('Templates', () => { describe('defaultTemplates', () => { it('should have owl-ontology template', () => { expect(defaultTemplates['owl-ontology']).toBeDefined(); - expect(defaultTemplates['owl-ontology'].name).toBe('Ontology'); + expect(defaultTemplates['owl-ontology'].title).toBe('Ontology or RDF File'); }); it('should have arrows template', () => { expect(defaultTemplates['arrows']).toBeDefined(); - expect(defaultTemplates['arrows'].name).toBe('Arrows'); + expect(defaultTemplates['arrows'].title).toBe('Arrows'); }); it('should have blank template', () => { expect(defaultTemplates['blank']).toBeDefined(); - expect(defaultTemplates['blank'].name).toBe('Blank'); + expect(defaultTemplates['blank'].title).toBe('Blank'); + }); + + it('each template should have palette with nodeTypes and edgeTypes', () => { + for (const [key, t] of Object.entries(defaultTemplates)) { + expect(t.palette, `${key} missing palette`).toBeDefined(); + expect(Array.isArray(t.palette.nodeTypes), `${key} missing palette.nodeTypes`).toBe(true); + expect(Array.isArray(t.palette.edgeTypes), `${key} missing palette.edgeTypes`).toBe(true); + } + }); + + it('each template should have userStylesheet array', () => { + for (const [key, t] of Object.entries(defaultTemplates)) { + expect(Array.isArray(t.userStylesheet), `${key} missing userStylesheet`).toBe(true); + } }); it('owl-ontology should have proper styling', () => { const template = defaultTemplates['owl-ontology']; - expect(template.style.length).toBeGreaterThan(0); - - // Check for owl:Class styling - const owlClassStyle = template.style.find( + expect(template.userStylesheet.length).toBeGreaterThan(0); + + const owlClassStyle = template.userStylesheet.find( s => s.selector.includes('owl:Class') ); expect(owlClassStyle).toBeDefined(); @@ -33,12 +46,12 @@ describe('Templates', () => { describe('getTemplate', () => { it('should return template by name', () => { const template = getTemplate('arrows'); - expect(template.name).toBe('Arrows'); + expect(template.title).toBe('Arrows'); }); it('should return blank template for unknown name', () => { const template = getTemplate('nonexistent'); - expect(template.name).toBe('Blank'); + expect(template.title).toBe('Blank'); }); }); @@ -46,18 +59,18 @@ describe('Templates', () => { it('should list all templates', () => { const templates = listTemplates(); expect(templates.length).toBeGreaterThanOrEqual(3); - + const ids = templates.map(t => t.id); expect(ids).toContain('owl-ontology'); expect(ids).toContain('arrows'); expect(ids).toContain('blank'); }); - it('should include name and description', () => { + it('should include title and description', () => { const templates = listTemplates(); templates.forEach(template => { expect(template.id).toBeDefined(); - expect(template.name).toBeDefined(); + expect(template.title).toBeDefined(); expect(template.description).toBeDefined(); }); }); diff --git a/packages/core/vite.config.js b/packages/core/vite.config.js index 542df31..31fc924 100644 --- a/packages/core/vite.config.js +++ b/packages/core/vite.config.js @@ -1,5 +1,6 @@ import { defineConfig } from 'vite'; import { resolve } from 'path'; +import { copyFileSync, mkdirSync, readdirSync } from 'fs'; function suppressKnownEvalWarnings(warning, warn) { // cytoscape-pdf-export bundles PDFKit via webpack's eval devtool. @@ -8,6 +9,23 @@ function suppressKnownEvalWarnings(warning, warn) { warn(warning); } +/** Copy src/templates/*.json to dist/templates/ so they are fetch-accessible. */ +function copyTemplateFiles() { + return { + name: 'copy-template-files', + writeBundle(options) { + const srcDir = resolve(__dirname, 'src/templates'); + const destDir = resolve(options.dir || 'dist', 'templates'); + mkdirSync(destDir, { recursive: true }); + for (const file of readdirSync(srcDir)) { + if (file.endsWith('.json')) { + copyFileSync(resolve(srcDir, file), resolve(destDir, file)); + } + } + } + }; +} + /** * cytoscape-pdf-export is distributed as a webpack bundle built with webpack's * eval devtool (development mode). When Rollup's CommonJS plugin converts it @@ -50,7 +68,7 @@ function fixWebpackEvalExports() { } export default defineConfig({ - plugins: [fixWebpackEvalExports()], + plugins: [fixWebpackEvalExports(), copyTemplateFiles()], build: { // cytoscape-pdf-export (PDFKit) is split into a separate async chunk but is // inherently large due to embedded font data. Raise the limit accordingly. diff --git a/packages/web/public/app.js b/packages/web/public/app.js index ec84d29..3685e39 100644 --- a/packages/web/public/app.js +++ b/packages/web/public/app.js @@ -1,5 +1,5 @@ import { - BoxesEditor, defaultTemplates, + BoxesEditor, defaultTemplates, loadTemplateFromUrl, rdfImporter, rdfExporter, jsonldImporter, jsonldExporter, rdfXmlImporter, rdfXmlExporter, @@ -24,40 +24,57 @@ registerExporter('rdfxml', rdfXmlExporter); let editor = null; let currentFileName = 'graph.json'; let currentFileHandle = null; // FileSystemFileHandle when opened/saved via File System Access API -let currentTemplateId = 'blank'; const BOXES_FILE_TYPES = [{ description: 'Boxes Graph', accept: { 'application/json': ['.boxes', '.json'] } }]; -function loadTemplates() { +/** + * Render the template grid from an array of template objects. + */ +function renderTemplateGrid(templates) { const grid = document.getElementById('template-grid'); grid.innerHTML = ''; - Object.keys(defaultTemplates).forEach(key => { - const t = defaultTemplates[key]; + templates.forEach(t => { const card = document.createElement('div'); card.className = 'template-card'; - card.innerHTML = `

${t.name}

${t.description}

`; - card.addEventListener('click', () => startWithTemplate(key)); + card.innerHTML = `

${t.title}

${t.description}

`; + card.addEventListener('click', () => startWithTemplate(t)); grid.appendChild(card); }); } -function startWithTemplate(templateId) { - currentTemplateId = templateId; - const currentTemplate = defaultTemplates[templateId] || defaultTemplates['blank']; +/** + * Load templates from the default bundled set plus any additional URLs. + * Extra URLs are fetched at startup; failures are silently skipped. + * @param {string[]} extraUrls - Optional list of JSON template file URLs to load. + */ +async function loadTemplates(extraUrls = []) { + const templates = Object.values(defaultTemplates); + for (const url of extraUrls) { + try { + const t = await loadTemplateFromUrl(url); + templates.push(t); + } catch (err) { + console.warn(`Failed to load template from ${url}:`, err); + } + } + renderTemplateGrid(templates); +} + +/** + * Start editing with a given template object (full graph/template JSON). + * Also accepts a template ID string for backwards compatibility. + */ +function startWithTemplate(templateOrId) { + const template = (typeof templateOrId === 'string') + ? (defaultTemplates[templateOrId] || defaultTemplates['blank']) + : templateOrId; document.getElementById('welcome-screen').classList.add('d-none'); document.getElementById('editor-container').classList.remove('d-none'); if (editor) { editor.destroy(); editor = null; } const container = document.getElementById('editor-container'); - editor = new BoxesEditor(container, { - elements: currentTemplate.elements, - style: currentTemplate.style, - nodeTypes: currentTemplate.nodeTypes || [], - edgeTypes: currentTemplate.edgeTypes || [], - context: currentTemplate.context || {}, - layout: { name: 'preset' } - }); + editor = new BoxesEditor(container, { template, layout: { name: 'preset' } }); } function saveToFile() { @@ -87,7 +104,6 @@ async function _saveWithPicker(handle) { }); } const graphData = editor.exportGraph(); - graphData.templateId = currentTemplateId; const writable = await handle.createWritable(); await writable.write(JSON.stringify(graphData, null, 2)); await writable.close(); @@ -103,7 +119,6 @@ async function _saveWithPicker(handle) { function _saveDownloadFallback() { const graphData = editor.exportGraph(); - graphData.templateId = currentTemplateId; const blob = new Blob([JSON.stringify(graphData, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); @@ -118,8 +133,13 @@ function loadFromFile(file, handle = null) { reader.onload = (e) => { try { const graphData = JSON.parse(e.target.result); - const templateId = graphData.templateId || 'blank'; - startWithTemplate(templateId); + // Start with a blank editor; importGraph will restore palette from the file. + // Fall back to a named template for old files that have templateId but no palette. + if (!graphData.palette && graphData.templateId) { + startWithTemplate(graphData.templateId); + } else { + startWithTemplate('blank'); + } editor.importGraph(graphData); currentFileName = file.name; currentFileHandle = handle; // null when opened via legacy