|
|
| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> |
| <title>Universal Narrative Flowchart Creator</title> |
| |
| |
| <script src="https://unpkg.com/konva@8.3.13/konva.min.js"></script> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script> |
|
|
| <style> |
| body { |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |
| margin: 0; |
| overflow: hidden; |
| display: flex; |
| flex-direction: column; |
| height: 100vh; |
| background-color: #f0f2f5; |
| -webkit-user-select: none; |
| -moz-user-select: none; |
| -ms-user-select: none; |
| user-select: none; |
| } |
| |
| #app { |
| display: flex; |
| flex-direction: row; |
| flex-grow: 1; |
| } |
| |
| |
| #toolbar { |
| padding: 20px 12px; |
| background-color: #ffffff; |
| border-left: 1px solid #e0e0e0; |
| display: flex; |
| flex-direction: column; |
| gap: 15px; |
| align-items: center; |
| box-shadow: -2px 0 5px rgba(0,0,0,0.1); |
| width: 240px; |
| overflow-y: auto; |
| z-index: 1001; |
| } |
| |
| |
| #toolbar button, #toolbar label.button { |
| padding: 12px 18px; |
| border: 1px solid transparent; |
| background-color: #007bff; |
| color: white; |
| border-radius: 5px; |
| cursor: pointer; |
| font-size: 14px; |
| transition: all 0.2s ease; |
| white-space: nowrap; |
| width: 100%; |
| box-sizing: border-box; |
| } |
| #toolbar button:hover:not(:disabled) { background-color: #0056b3; box-shadow: 0 2px 8px rgba(0,0,0,0.15); transform: translateY(-1px); } |
| #toolbar button:disabled { background-color: #cccccc; cursor: not-allowed; } |
| #toolbar button.active { background-color: #1a6a38; border-color: #28a745; box-shadow: inset 0 2px 4px rgba(0,0,0,0.2); } |
| #toolbar input[type="file"] { display: none; } |
| #toolbar input[type="search"] { |
| padding: 10px; |
| border: 1px solid #ddd; |
| border-radius: 5px; |
| font-size: 14px; |
| width: 100%; |
| box-sizing: border-box; |
| } |
| |
| |
| .button-group { |
| display: flex; |
| flex-direction: column; |
| gap: 8px; |
| align-items: center; |
| border: 1px solid #ddd; |
| padding: 10px 8px; |
| border-radius: 6px; |
| background-color: #f8f9fa; |
| width: 100%; |
| box-sizing: border-box; |
| } |
| |
| #main-container { flex-grow: 1; position: relative; } |
| #canvas-container { position: absolute; top: 0; left: 0; background-color: #eef2f7; cursor: grab; } |
| #canvas-container:active { cursor: grabbing; } |
| |
| #minimap-container { |
| position: absolute; |
| bottom: 20px; |
| right: 20px; |
| border: 2px solid #007bff; |
| background-color: rgba(255, 255, 255, 0.9); |
| box-shadow: 0 0 15px rgba(0,0,0,0.2); |
| z-index: 1000; |
| overflow: hidden; |
| } |
| |
| #status-bar { padding: 10px 20px; background-color: #343a40; color: #f8f9fa; font-size: 13px; text-align: center; } |
| #context-menu { |
| display: none; position: absolute; background-color: white; border-radius: 8px; |
| box-shadow: 0 5px 15px rgba(0,0,0,0.25); z-index: 2000; list-style: none; |
| margin: 0; padding: 8px 0; min-width: 240px; font-size: 16px; |
| } |
| #context-menu li { padding: 12px 22px; cursor: pointer; transition: background-color 0.15s ease; } |
| #context-menu li:hover { background-color: #007bff; color: white; } |
| #context-menu .separator { height: 1px; background-color: #e0e0e0; margin: 5px 0; } |
| .modal { |
| display: none; position: fixed; z-index: 1000; left: 0; top: 0; |
| width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); |
| justify-content: center; align-items: center; animation: fadeIn 0.3s; |
| } |
| .modal-content { |
| background-color: #fefefe; padding: 30px; border-radius: 8px; |
| width: 90%; max-width: 550px; box-shadow: 0 5px 15px rgba(0,0,0,0.3); |
| animation: slideIn 0.3s ease-out; |
| } |
| .modal-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #eee; padding-bottom: 15px; margin-bottom: 25px; } |
| .modal-header h2 { margin: 0; font-size: 1.6em; color: #333; } |
| .close-button { color: #aaa; font-size: 32px; font-weight: bold; cursor: pointer; transition: color 0.2s; padding: 0 10px;} |
| .close-button:hover, .close-button:focus { color: #000; } |
| .modal-body label { display: block; margin-bottom: 8px; font-weight: bold; color: #555; } |
| .modal-body input, .modal-body textarea, .modal-body select { |
| width: 100%; padding: 12px; margin-bottom: 15px; border: 1px solid #ccc; |
| border-radius: 4px; font-size: 1em; box-sizing: border-box; |
| } |
| .modal-body textarea { resize: vertical; min-height: 100px; } |
| .modal-footer { text-align: right; padding-top: 20px; border-top: 1px solid #eee; margin-top: 20px; } |
| .modal-footer button { padding: 12px 24px; border: none; border-radius: 5px; cursor: pointer; font-size: 1em; margin-left: 10px; } |
| #save-btn-modal { background-color: #007bff; color: white; } |
| #cancel-btn-modal { background-color: #6c757d; color: white; } |
| |
| #custom-fields-container { margin-top: 20px; border-top: 1px dashed #ccc; padding-top: 15px; } |
| .custom-field { display: flex; gap: 10px; margin-bottom: 10px; align-items: center; } |
| .custom-field input { margin-bottom: 0; } |
| .custom-field button { padding: 5px 10px; font-size: 18px; line-height: 1; background-color: #dc3545; color: white; border: none; border-radius: 4px; cursor: pointer; } |
| |
| @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } |
| @keyframes slideIn { from { transform: translateY(-50px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } |
| </style> |
| </head> |
| <body> |
| |
| <div id="app"> |
| <div id="main-container"> |
| <div id="canvas-container"></div> |
| <div id="minimap-container"></div> |
| </div> |
|
|
| <div id="toolbar"> |
| <button id="add-node-btn" title="Add Scene (A)">Add Scene</button> |
| <div class="button-group"> |
| <button id="undo-btn" title="Undo (Ctrl+Z)" disabled>Undo</button> |
| <button id="redo-btn" title="Redo (Ctrl+Y)" disabled>Redo</button> |
| </div> |
| <div class="button-group"> |
| <button id="toggle-connect-mode" title="Connection Mode (C)">Connection Mode</button> |
| <button id="link-scenes-btn" disabled title="Link Scenes (L)">Link</button> |
| </div> |
| <div class="button-group"> |
| <button id="auto-layout-btn" title="Auto-arrange nodes">Auto-Layout</button> |
| <button id="snap-grid-btn" title="Toggle Snap to Grid">Snap Grid: Off</button> |
| </div> |
| <button id="delete-selected-btn" style="background-color: #dc3545;" title="Delete Selected (Del/Backspace)">Delete Selected</button> |
| <button id="save-btn">Save JSON</button> |
| <label for="load-file-input" class="button">Load JSON</label> |
| <input type="file" id="load-file-input" accept=".json"> |
| <div class="button-group"> |
| <button id="export-png-btn">Export PNG</button> |
| <button id="export-svg-btn">Export SVG</button> |
| <button id="export-pdf-btn">Export PDF</button> |
| </div> |
| <input type="search" id="search-box" placeholder="Search nodes..."> |
| </div> |
| </div> |
| |
| |
| <div id="status-bar">Pinch to zoom, two-finger drag to pan.</div> |
|
|
| <ul id="context-menu"> |
| <li id="menu-edit-details">Edit Details...</li> |
| <li id="menu-change-color">Change Color...</li> |
| <li id="menu-change-shape">Change Shape...</li> |
| <li class="separator"></li> |
| <li id="menu-set-as-source">Set as Connection Source</li> |
| <li id="menu-delete-node">Delete</li> |
| </ul> |
|
|
| <div id="details-modal" class="modal"> |
| <div class="modal-content"> |
| <div class="modal-header"> |
| <h2 id="details-modal-title">Edit Scene Details</h2> |
| <span class="close-button">×</span> |
| </div> |
| <div class="modal-body"> |
| <label for="modal-input-name">Name / Label:</label> |
| <input type="text" id="modal-input-name" placeholder="e.g., The Crossroads"> |
| |
| <div id="modal-node-fields"> |
| <label for="modal-input-desc">Description:</label> |
| <textarea id="modal-input-desc" placeholder="A brief description of the events or choices in this scene."></textarea> |
| |
| <div id="custom-fields-container"> |
| <label>Custom Data:</label> |
| <div id="custom-fields-list"></div> |
| <button id="add-custom-field-btn" style="background-color: #28a745; width: 100%; padding: 10px; margin-top: 10px;">+ Add Field</button> |
| </div> |
| </div> |
|
|
| <div id="modal-connection-fields"> |
| <label for="modal-conn-style">Line Style:</label> |
| <select id="modal-conn-style"> |
| <option value="solid">Solid</option> |
| <option value="dashed">Dashed</option> |
| </select> |
| <label for="modal-conn-color">Line Color:</label> |
| <input type="color" id="modal-conn-color" style="padding: 5px; height: 50px;"> |
| </div> |
| </div> |
| <div class="modal-footer"> |
| <button id="cancel-btn-modal">Cancel</button> |
| <button id="save-btn-modal">Save</button> |
| </div> |
| </div> |
| </div> |
|
|
| <script> |
| document.addEventListener('DOMContentLoaded', () => { |
| |
| const { jsPDF } = window.jspdf; |
| const stage = new Konva.Stage({ |
| container: 'canvas-container', |
| width: document.getElementById('main-container').clientWidth, |
| height: document.getElementById('main-container').clientHeight, |
| draggable: true, |
| }); |
| const layer = new Konva.Layer(); |
| stage.add(layer); |
| |
| |
| const NODE_WIDTH = 180, NODE_HEIGHT = 90, TEXT_PADDING = 15, GRID_SIZE = 20; |
| const STROKES = { NORMAL: '#333', SELECTED: '#0056b3', SOURCE: '#28a745', TARGET: '#ffc107', LINE_SELECTED: '#dc3545', HIGHLIGHT: '#E8B923' }; |
| |
| |
| const statusBar = document.getElementById('status-bar'); |
| const connectModeBtn = document.getElementById('toggle-connect-mode'); |
| const linkBtn = document.getElementById('link-scenes-btn'); |
| const deleteBtn = document.getElementById('delete-selected-btn'); |
| const snapGridBtn = document.getElementById('snap-grid-btn'); |
| const contextMenu = document.getElementById('context-menu'); |
| const detailsModal = document.getElementById('details-modal'); |
| const undoBtn = document.getElementById('undo-btn'); |
| const redoBtn = document.getElementById('redo-btn'); |
| const searchBox = document.getElementById('search-box'); |
| |
| |
| let state = { |
| nodes: new Map(), connections: new Map(), isConnectMode: false, sourceNodeId: null, |
| targetNodeId: null, selectedObjectId: null, selectedObjectType: null, |
| isSnapToGrid: false, editingId: null, longPressTimeout: null, |
| }; |
| |
| |
| let history = []; |
| let historyIndex = -1; |
| |
| function captureState() { |
| const currentState = { |
| nodes: Array.from(state.nodes.values()).map(n => ({ |
| id: n.id, x: n.konva.x(), y: n.konva.y(), name: n.name, description: n.description, |
| color: n.color, shape: n.shape, customData: n.customData |
| })), |
| connections: Array.from(state.connections.values()).map(c => ({ |
| id: c.id, from: c.from, to: c.to, text: c.text, style: c.style, color: c.color |
| })) |
| }; |
| return JSON.stringify(currentState); |
| } |
| |
| function saveState() { |
| const currentStateString = captureState(); |
| |
| if (history.length > 0 && currentStateString === history[historyIndex]) { |
| return; |
| } |
| |
| history = history.slice(0, historyIndex + 1); |
| history.push(currentStateString); |
| historyIndex = history.length - 1; |
| updateUndoRedoButtons(); |
| } |
| |
| function restoreState(stateString) { |
| try { |
| const data = JSON.parse(stateString); |
| layer.destroyChildren(); |
| state.nodes.clear(); |
| state.connections.clear(); |
| exitConnectMode(false); |
| |
| data.nodes.forEach(n => addNode(n, false)); |
| if (data.connections) { |
| data.connections.forEach(c => addConnection(c, false)); |
| } |
| |
| layer.add(previewLine); |
| updateStatus('State restored.'); |
| layer.draw(); |
| } catch (err) { |
| console.error("Failed to restore state:", err); |
| updateStatus('Error restoring state: ' + err.message); |
| } |
| } |
| |
| function undo() { |
| if (historyIndex > 0) { |
| historyIndex--; |
| restoreState(history[historyIndex]); |
| updateUndoRedoButtons(); |
| } |
| } |
| |
| function redo() { |
| if (historyIndex < history.length - 1) { |
| historyIndex++; |
| restoreState(history[historyIndex]); |
| updateUndoRedoButtons(); |
| } |
| } |
| |
| function updateUndoRedoButtons() { |
| undoBtn.disabled = historyIndex <= 0; |
| redoBtn.disabled = historyIndex >= history.length - 1; |
| } |
| |
| |
| const updateStatus = (msg) => statusBar.textContent = msg; |
| const generateId = (prefix = 'id') => `${prefix}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; |
| const getCenter = () => ({ x: (-stage.x() + stage.width() / 2) / stage.scaleX(), y: (-stage.y() + stage.height() / 2) / stage.scaleY() }); |
| const formatNodeText = (name, description) => (description ? `${name}\n\n${(description.length > 60 ? description.substring(0, 57) + '...' : description)}` : name); |
| |
| function deselectAll() { |
| if (!state.selectedObjectId) return; |
| const obj = state.selectedObjectType === 'node' ? state.nodes.get(state.selectedObjectId) : state.connections.get(state.selectedObjectId); |
| if (obj) { |
| const shape = obj.konva.findOne(state.selectedObjectType === 'node' ? '.nodeShape' : '.line'); |
| if (shape) { |
| shape.stroke(state.selectedObjectType === 'node' ? STROKES.NORMAL : (obj.color || STROKES.NORMAL)); |
| shape.strokeWidth(state.selectedObjectType === 'node' ? 2 : 3); |
| } |
| } |
| state.selectedObjectId = null; |
| state.selectedObjectType = null; |
| deleteBtn.disabled = true; |
| layer.draw(); |
| } |
| |
| function selectObject(id, type) { |
| deselectAll(); |
| state.selectedObjectId = id; |
| state.selectedObjectType = type; |
| const obj = type === 'node' ? state.nodes.get(id) : state.connections.get(id); |
| if (obj) { |
| const shape = obj.konva.findOne(type === 'node' ? '.nodeShape' : '.line'); |
| shape.stroke(type === 'node' ? STROKES.SELECTED : STROKES.LINE_SELECTED); |
| shape.strokeWidth(5); |
| deleteBtn.disabled = false; |
| } |
| layer.draw(); |
| } |
| |
| |
| function addNode(config = {}, doSaveState = true) { |
| const { x, y, id, name, description, color, shape, customData } = { |
| x: getCenter().x, y: getCenter().y, id: generateId('node'), name: 'New Scene', |
| description: '', color: '#e0f2f7', shape: 'rectangle', customData: {}, ...config |
| }; |
| |
| const nodeGroup = new Konva.Group({ x, y, id, draggable: true, name: 'node' }); |
| |
| let nodeShape; |
| const shapeConfig = { |
| fill: color, stroke: STROKES.NORMAL, strokeWidth: 2, name: 'nodeShape', |
| shadowColor: 'black', shadowBlur: 10, shadowOffset: { x: 0, y: 4 }, shadowOpacity: 0.1, |
| }; |
| |
| if (shape === 'circle') { |
| nodeShape = new Konva.Circle({ ...shapeConfig, radius: NODE_WIDTH / 2.5 }); |
| } else if (shape === 'diamond') { |
| nodeShape = new Konva.Rect({ ...shapeConfig, width: NODE_WIDTH * 0.7, height: NODE_HEIGHT, rotation: 45, offsetX: (NODE_WIDTH*0.7)/2, offsetY: NODE_HEIGHT/2 }); |
| } else { |
| nodeShape = new Konva.Rect({ ...shapeConfig, width: NODE_WIDTH, height: NODE_HEIGHT, cornerRadius: 8 }); |
| } |
| |
| const nodeText = new Konva.Text({ |
| text: formatNodeText(name, description), fontSize: 14, fontFamily: 'sans-serif', fill: '#333', |
| width: NODE_WIDTH - TEXT_PADDING * 2, padding: TEXT_PADDING, align: 'center', verticalAlign: 'middle', |
| listening: false, |
| }); |
| |
| nodeText.position({ x: -nodeText.width()/2, y: -NODE_HEIGHT/2 }); |
| |
| nodeGroup.add(nodeShape, nodeText); |
| layer.add(nodeGroup); |
| state.nodes.set(id, { id, name, description, color, shape, customData, konva: nodeGroup }); |
| |
| nodeGroup.on('tap click', () => handleNodeClick(id)); |
| nodeGroup.on('dbltap dblclick', () => openDetailsModal(id)); |
| nodeGroup.on('dragmove', () => updateConnectionsForNode(id)); |
| nodeGroup.on('dragend', () => { if (state.isSnapToGrid) { snapNode(nodeGroup); } saveState(); }); |
| nodeGroup.on('contextmenu', e => showContextMenu(e.evt, id)); |
| nodeGroup.on('touchstart', e => { clearTimeout(state.longPressTimeout); state.longPressTimeout = setTimeout(() => showContextMenu(e.evt, id), 500); }); |
| nodeGroup.on('touchend touchmove', () => clearTimeout(state.longPressTimeout)); |
| nodeGroup.on('mouseenter', () => stage.container().style.cursor = 'pointer'); |
| nodeGroup.on('mouseleave', () => stage.container().style.cursor = 'grab'); |
| |
| updateStatus(`Scene "${name}" added.`); |
| if (doSaveState) saveState(); |
| layer.draw(); |
| } |
| |
| function updateNodeAppearance(id, { name, description, color, shape }) { |
| const node = state.nodes.get(id); |
| if (!node) return; |
| if (name !== undefined) node.name = name; |
| if (description !== undefined) node.description = description; |
| if (color) { |
| node.color = color; |
| node.konva.findOne('.nodeShape').fill(color); |
| } |
| if(shape) { |
| |
| |
| |
| node.shape = shape; |
| } |
| node.konva.findOne('Text').text(formatNodeText(node.name, node.description)); |
| layer.draw(); |
| } |
| |
| function deleteNode(id, doSaveState = true) { |
| const connectionsToDelete = Array.from(state.connections.values()).filter(c => c.from === id || c.to === id); |
| connectionsToDelete.forEach(c => deleteConnection(c.id, false)); |
| |
| state.nodes.get(id)?.konva.destroy(); |
| state.nodes.delete(id); |
| if(state.selectedObjectId === id) deselectAll(); |
| updateStatus('Scene deleted.'); |
| if (doSaveState) saveState(); |
| layer.draw(); |
| } |
| |
| function snapNode(nodeGroup){ |
| nodeGroup.position({ |
| x: Math.round(nodeGroup.x() / GRID_SIZE) * GRID_SIZE, |
| y: Math.round(nodeGroup.y() / GRID_SIZE) * GRID_SIZE, |
| }); |
| updateConnectionsForNode(nodeGroup.id()); |
| layer.batchDraw(); |
| } |
| |
| |
| let previewLine = new Konva.Arrow({ stroke: STROKES.SOURCE, strokeWidth: 2, lineCap: 'round', dash: [10, 5], visible: false }); |
| layer.add(previewLine); |
| |
| function enterConnectMode(doSaveState = true) { |
| state.isConnectMode = true; |
| connectModeBtn.classList.add('active'); |
| connectModeBtn.textContent = "Connect Mode: ON"; |
| updateStatus('Connection Mode: Select a SOURCE scene.'); |
| } |
| |
| function exitConnectMode(doSaveState = true) { |
| state.isConnectMode = false; |
| state.sourceNodeId = null; |
| state.targetNodeId = null; |
| connectModeBtn.classList.remove('active'); |
| connectModeBtn.textContent = "Connection Mode"; |
| linkBtn.disabled = true; |
| previewLine.visible(false); |
| deselectAll(); |
| updateStatus('Connection Mode OFF.'); |
| layer.draw(); |
| } |
| |
| function setSourceNode(id) { |
| state.sourceNodeId = id; |
| const sourceNode = state.nodes.get(id).konva; |
| const startPoint = sourceNode.position(); |
| previewLine.points([startPoint.x, startPoint.y, startPoint.x, startPoint.y]); |
| previewLine.visible(true); |
| updateStatus(`Source: "${state.nodes.get(id).name}". Select a TARGET scene.`); |
| layer.draw(); |
| } |
| |
| function setTargetNode(id) { |
| if (id === state.sourceNodeId) { |
| updateStatus('Cannot connect a scene to itself. Select a different target.'); |
| return; |
| } |
| state.targetNodeId = id; |
| linkBtn.disabled = false; |
| updateStatus(`Target: "${state.nodes.get(id).name}". Click "Link" or press L.`); |
| } |
| |
| function addConnection(config = {}, doSaveState = true) { |
| const { from, to, text, id, style, color } = { |
| text: '', id: generateId('conn'), style: 'solid', color: STROKES.NORMAL, ...config |
| }; |
| |
| if (Array.from(state.connections.values()).some(c => c.from === from && c.to === to)) { |
| updateStatus('This connection already exists.'); |
| return; |
| } |
| const fromNode = state.nodes.get(from).konva; |
| const toNode = state.nodes.get(to).konva; |
| const points = calculateConnectorPoints(fromNode.position(), toNode.position()); |
| |
| const connGroup = new Konva.Group({id}); |
| const line = new Konva.Arrow({ |
| points, stroke: color, strokeWidth: 3, hitStrokeWidth: 20, |
| pointerLength: 12, pointerWidth: 12, name: 'line', |
| dash: style === 'dashed' ? [10, 5] : [] |
| }); |
| const label = new Konva.Text({ |
| x: (points[0] + points[2]) / 2, y: (points[1] + points[3]) / 2, text, |
| fontSize: 14, fill: '#444', padding: 5, |
| }); |
| connGroup.add(line, label); |
| layer.add(connGroup); |
| connGroup.moveToBottom(); |
| |
| state.connections.set(id, { id, from, to, text, style, color, konva: connGroup }); |
| |
| connGroup.on('tap click', () => selectObject(id, 'connection')); |
| connGroup.on('dbltap dblclick', () => openDetailsModal(id, 'connection')); |
| connGroup.on('mouseenter', () => stage.container().style.cursor = 'pointer'); |
| connGroup.on('mouseleave', () => stage.container().style.cursor = 'grab'); |
| |
| if (doSaveState) saveState(); |
| } |
| |
| function deleteConnection(id, doSaveState = true) { |
| state.connections.get(id)?.konva.destroy(); |
| state.connections.delete(id); |
| if(state.selectedObjectId === id) deselectAll(); |
| updateStatus('Connection deleted.'); |
| if (doSaveState) saveState(); |
| layer.draw(); |
| } |
| |
| function updateConnectionsForNode(nodeId) { |
| const node = state.nodes.get(nodeId); |
| if (!node) return; |
| state.connections.forEach(conn => { |
| if (conn.from === nodeId || conn.to === nodeId) { |
| const fromPos = state.nodes.get(conn.from).konva.position(); |
| const toPos = state.nodes.get(conn.to).konva.position(); |
| const points = calculateConnectorPoints(fromPos, toPos); |
| const line = conn.konva.findOne('Arrow'); |
| const label = conn.konva.findOne('Text'); |
| line.points(points); |
| label.position({ x: (points[0] + points[2]) / 2 - label.width()/2, y: (points[1] + points[3]) / 2 - label.height()/2 }); |
| } |
| }); |
| layer.batchDraw(); |
| } |
| |
| function calculateConnectorPoints(from, to) { |
| |
| return [from.x, from.y, to.x, to.y]; |
| } |
| |
| |
| function handleNodeClick(id) { |
| clearTimeout(state.longPressTimeout); |
| if (state.isConnectMode) { |
| if (!state.sourceNodeId) setSourceNode(id); |
| else setTargetNode(id); |
| } else { |
| selectObject(id, 'node'); |
| } |
| } |
| |
| stage.on('click tap', e => { if (e.target === stage) deselectAll(); }); |
| stage.on('mousemove touchmove', () => { |
| if (state.isConnectMode && state.sourceNodeId) { |
| const sourceNode = state.nodes.get(state.sourceNodeId).konva; |
| const startPoint = sourceNode.position(); |
| const pointer = stage.getPointerPosition(); |
| if(!pointer) return; |
| const mousePos = { x: (pointer.x - stage.x()) / stage.scaleX(), y: (pointer.y - stage.y()) / stage.scaleY()}; |
| previewLine.points([startPoint.x, startPoint.y, mousePos.x, mousePos.y]); |
| layer.batchDraw(); |
| } |
| }); |
| |
| |
| let lastCenter = null; |
| let lastDist = 0; |
| stage.on('touchmove', function (e) { |
| e.evt.preventDefault(); |
| const touch1 = e.evt.touches[0]; |
| const touch2 = e.evt.touches[1]; |
| if (touch1 && touch2) { |
| stage.draggable(false); |
| const p1 = { x: touch1.clientX, y: touch1.clientY }; |
| const p2 = { x: touch2.clientX, y: touch2.clientY }; |
| if (!lastCenter) { lastCenter = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }; return; } |
| const newCenter = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }; |
| const dist = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); |
| if (lastDist > 0) { const scale = stage.scaleX() * (dist / lastDist); stage.scale({ x: scale, y: scale }); } |
| const dx = newCenter.x - lastCenter.x; const dy = newCenter.y - lastCenter.y; |
| stage.move({ x: dx, y: dy }); |
| lastDist = dist; lastCenter = newCenter; |
| layer.draw(); |
| } |
| }); |
| stage.on('touchend', function () { |
| lastDist = 0; lastCenter = null; stage.draggable(true); |
| }); |
| |
| |
| searchBox.addEventListener('input', (e) => { |
| const query = e.target.value.toLowerCase().trim(); |
| |
| state.nodes.forEach(node => node.konva.findOne('.nodeShape').stroke(STROKES.NORMAL).strokeWidth(2)); |
| |
| if (!query) { layer.draw(); return; } |
| |
| state.nodes.forEach(node => { |
| const isMatch = node.name.toLowerCase().includes(query) || node.description.toLowerCase().includes(query); |
| if (isMatch) { |
| node.konva.findOne('.nodeShape').stroke(STROKES.HIGHLIGHT).strokeWidth(6); |
| } |
| }); |
| layer.draw(); |
| }); |
| |
| |
| |
| function showContextMenu(evt, id) { |
| evt.preventDefault(); |
| selectObject(id, 'node'); |
| const touch = evt.touches ? evt.touches[0] : evt; |
| contextMenu.style.display = 'block'; |
| contextMenu.style.left = `${touch.clientX}px`; |
| contextMenu.style.top = `${touch.clientY}px`; |
| state.editingId = id; |
| } |
| |
| function openDetailsModal(id, type = 'node') { |
| state.editingId = id; |
| const obj = type === 'node' ? state.nodes.get(id) : state.connections.get(id); |
| if (!obj) return; |
| |
| document.getElementById('modal-node-fields').style.display = type === 'node' ? 'block' : 'none'; |
| document.getElementById('modal-connection-fields').style.display = type === 'connection' ? 'block' : 'none'; |
| |
| if (type === 'node') { |
| document.getElementById('details-modal-title').textContent = 'Edit Scene Details'; |
| document.getElementById('modal-input-name').value = obj.name; |
| document.getElementById('modal-input-desc').value = obj.description; |
| const customFieldsList = document.getElementById('custom-fields-list'); |
| customFieldsList.innerHTML = ''; |
| if(obj.customData) { |
| for (const key in obj.customData) { addCustomFieldInput(key, obj.customData[key]); } |
| } |
| } else { |
| document.getElementById('details-modal-title').textContent = 'Edit Connection Details'; |
| document.getElementById('modal-input-name').value = obj.text; |
| document.getElementById('modal-conn-style').value = obj.style || 'solid'; |
| document.getElementById('modal-conn-color').value = obj.color || '#333333'; |
| } |
| detailsModal.style.display = 'flex'; |
| } |
| |
| function addCustomFieldInput(key = '', value = '') { |
| const list = document.getElementById('custom-fields-list'); |
| const fieldDiv = document.createElement('div'); |
| fieldDiv.className = 'custom-field'; |
| fieldDiv.innerHTML = ` |
| <input type="text" class="custom-field-key" placeholder="Field Name (e.g., Character)" value="${key}"> |
| <input type="text" class="custom-field-value" placeholder="Field Value (e.g., Alice)" value="${value}"> |
| <button type="button" class="remove-field-btn">-</button> |
| `; |
| list.appendChild(fieldDiv); |
| fieldDiv.querySelector('.remove-field-btn').onclick = () => fieldDiv.remove(); |
| } |
| |
| function closeDetailsModal() { detailsModal.style.display = 'none'; state.editingId = null; } |
| |
| function handleModalSave() { |
| const id = state.editingId; |
| if (!id) return; |
| const type = state.connections.has(id) ? 'connection' : 'node'; |
| |
| if (type === 'node') { |
| const node = state.nodes.get(id); |
| if (!node) return; |
| const newName = document.getElementById('modal-input-name').value; |
| const newDesc = document.getElementById('modal-input-desc').value; |
| node.customData = {}; |
| document.querySelectorAll('#custom-fields-list .custom-field').forEach(div => { |
| const key = div.querySelector('.custom-field-key').value.trim(); |
| const value = div.querySelector('.custom-field-value').value; |
| if (key) node.customData[key] = value; |
| }); |
| updateNodeAppearance(id, { name: newName, description: newDesc }); |
| } else { |
| const conn = state.connections.get(id); |
| if (!conn) return; |
| conn.text = document.getElementById('modal-input-name').value; |
| conn.style = document.getElementById('modal-conn-style').value; |
| conn.color = document.getElementById('modal-conn-color').value; |
| |
| conn.konva.findOne('Text').text(conn.text); |
| const line = conn.konva.findOne('.line'); |
| line.stroke(conn.color); |
| line.dash(conn.style === 'dashed' ? [10, 5] : []); |
| } |
| |
| closeDetailsModal(); |
| saveState(); |
| layer.draw(); |
| } |
| |
| |
| document.getElementById('add-node-btn').addEventListener('click', () => addNode()); |
| undoBtn.addEventListener('click', undo); |
| redoBtn.addEventListener('click', redo); |
| connectModeBtn.addEventListener('click', () => state.isConnectMode ? exitConnectMode() : enterConnectMode()); |
| linkBtn.addEventListener('click', () => { |
| if (state.sourceNodeId && state.targetNodeId) { |
| addConnection({ from: state.sourceNodeId, to: state.targetNodeId }); |
| exitConnectMode(); |
| } |
| }); |
| deleteBtn.addEventListener('click', () => { |
| if (!state.selectedObjectId) return; |
| if (state.selectedObjectType === 'node') deleteNode(state.selectedObjectId); |
| else if (state.selectedObjectType === 'connection') deleteConnection(state.selectedObjectId); |
| }); |
| snapGridBtn.addEventListener('click', () => { |
| state.isSnapToGrid = !state.isSnapToGrid; |
| snapGridBtn.textContent = `Snap Grid: ${state.isSnapToGrid ? 'On' : 'Off'}`; |
| snapGridBtn.classList.toggle('active', state.isSnapToGrid); |
| if(state.isSnapToGrid) { state.nodes.forEach(n => snapNode(n.konva)); saveState(); } |
| }); |
| document.getElementById('auto-layout-btn').addEventListener('click', () => { |
| let y = 50; |
| state.nodes.forEach(node => { |
| node.konva.position({x: stage.width() / 4 - NODE_WIDTH / 2, y}); |
| updateConnectionsForNode(node.id); |
| y += NODE_HEIGHT + 40; |
| }); |
| stage.position({x:0, y:0}); stage.scale({x:1, y:1}); |
| layer.draw(); |
| saveState(); |
| }); |
| |
| |
| window.addEventListener('click', (e) => { if (!contextMenu.contains(e.target)) { contextMenu.style.display = 'none'; } }); |
| detailsModal.querySelector('.close-button').addEventListener('click', closeDetailsModal); |
| detailsModal.querySelector('#cancel-btn-modal').addEventListener('click', closeDetailsModal); |
| document.getElementById('add-custom-field-btn').addEventListener('click', () => addCustomFieldInput()); |
| document.getElementById('save-btn-modal').addEventListener('click', handleModalSave); |
| document.getElementById('menu-edit-details').addEventListener('click', () => openDetailsModal(state.editingId)); |
| document.getElementById('menu-delete-node').addEventListener('click', () => deleteNode(state.editingId)); |
| document.getElementById('menu-set-as-source').addEventListener('click', () => { enterConnectMode(); setSourceNode(state.editingId); }); |
| document.getElementById('menu-change-color').addEventListener('click', () => { |
| const colorInput = document.createElement('input'); colorInput.type = 'color'; |
| colorInput.value = state.nodes.get(state.editingId).color; |
| colorInput.onchange = () => { updateNodeAppearance(state.editingId, { color: colorInput.value }); saveState(); }; |
| colorInput.click(); |
| }); |
| document.getElementById('menu-change-shape').addEventListener('click', () => { |
| const newShape = prompt("Enter new shape (rectangle, circle, diamond):", state.nodes.get(state.editingId).shape); |
| if (newShape && ['rectangle', 'circle', 'diamond'].includes(newShape)) { |
| |
| const node = state.nodes.get(state.editingId); |
| node.shape = newShape; |
| saveState(); |
| restoreState(history[historyIndex]); |
| } else { |
| alert("Invalid shape. Please choose rectangle, circle, or diamond."); |
| } |
| }); |
| |
| |
| window.addEventListener('keydown', e => { |
| const modalOpen = detailsModal.style.display === 'flex'; |
| if (modalOpen || e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; |
| if (e.ctrlKey && e.key.toLowerCase() === 'z') { e.preventDefault(); undo(); } |
| else if (e.ctrlKey && e.key.toLowerCase() === 'y') { e.preventDefault(); redo(); } |
| else if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); deleteBtn.click(); } |
| else if (e.key === 'Escape') { if(state.isConnectMode) exitConnectMode(); else deselectAll(); } |
| else if (e.key.toLowerCase() === 'a') { e.preventDefault(); addNode(); } |
| else if (e.key.toLowerCase() === 'c') { e.preventDefault(); connectModeBtn.click(); } |
| else if (e.key.toLowerCase() === 'l' && !linkBtn.disabled) { e.preventDefault(); linkBtn.click(); } |
| }); |
| |
| document.getElementById('save-btn').addEventListener('click', () => { |
| const blob = new Blob([captureState()], { type: 'application/json' }); |
| const a = document.createElement('a'); a.href = URL.createObjectURL(blob); |
| a.download = `narrative-flowchart-${Date.now()}.json`; a.click(); URL.revokeObjectURL(a.href); |
| updateStatus('Diagram saved.'); |
| }); |
| |
| document.getElementById('load-file-input').addEventListener('change', e => { |
| const file = e.target.files[0]; if (!file) return; |
| const reader = new FileReader(); |
| reader.onload = re => { |
| restoreState(re.target.result); |
| saveState(); |
| }; |
| reader.onerror = () => updateStatus('Error reading file.'); |
| reader.readAsText(file); |
| e.target.value = null; |
| }); |
| |
| |
| document.getElementById('export-png-btn').addEventListener('click', () => { |
| const dataURL = stage.toDataURL({ pixelRatio: 2, mimeType: 'image/png'}); |
| const a = document.createElement('a'); a.href = dataURL; a.download = `narrative-flowchart.png`; a.click(); |
| updateStatus('Exported as PNG.'); |
| }); |
| document.getElementById('export-svg-btn').addEventListener('click', () => { |
| const svgData = layer.toSVG(); |
| const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' }); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); a.href = url; a.download = `narrative-flowchart.svg`; a.click(); URL.revokeObjectURL(url); |
| updateStatus('Exported as SVG.'); |
| }); |
| document.getElementById('export-pdf-btn').addEventListener('click', () => { |
| const dataURL = stage.toDataURL({ pixelRatio: 2 }); |
| const pdf = new jsPDF({ |
| orientation: stage.width() > stage.height() ? 'l' : 'p', unit: 'px', format: [stage.width(), stage.height()] |
| }); |
| pdf.addImage(dataURL, 'PNG', 0, 0, stage.width(), stage.height()); |
| pdf.save('narrative-flowchart.pdf'); |
| updateStatus('Exported as PDF.'); |
| }); |
| |
| |
| |
| window.addEventListener('resize', () => { |
| const container = document.getElementById('main-container'); |
| stage.width(container.clientWidth); |
| stage.height(container.clientHeight); |
| stage.batchDraw(); |
| }); |
| |
| addNode({x: 200, y: 150, name: 'Welcome!', description: 'All features are now integrated. Use Undo/Redo, search, and explore the new options in the context menu and edit modals.'}, false); |
| saveState(); |
| |
| |
| (function setupMinimap() { |
| const minimapContainer = document.getElementById('minimap-container'); |
| const minimapWidth = stage.width() * 0.2; |
| const minimapHeight = stage.height() * 0.2; |
| minimapContainer.style.width = minimapWidth + 'px'; |
| minimapContainer.style.height = minimapHeight + 'px'; |
| |
| const minimapStage = new Konva.Stage({ |
| container: 'minimap-container', |
| width: minimapWidth, |
| height: minimapHeight |
| }); |
| |
| const minimapLayer = layer.clone(); |
| minimapStage.add(minimapLayer); |
| |
| const viewportRect = new Konva.Rect({ |
| stroke: '#0056b3', |
| strokeWidth: 4, |
| draggable: true, |
| dragBoundFunc: function(pos) { |
| const newX = Math.max(0, Math.min(pos.x, minimapWidth - this.width())); |
| const newY = Math.max(0, Math.min(pos.y, minimapHeight - this.height())); |
| return { x: newX, y: newY }; |
| } |
| }); |
| minimapStage.add(viewportRect); |
| |
| function updateMinimap() { |
| const scale = minimapWidth / stage.width(); |
| minimapLayer.scale({ x: scale, y: scale }); |
| minimapStage.scale({ x: scale, y: scale }); |
| const stageRect = stage.getClientRect({skipTransform: false}); |
| minimapStage.position({ x: -stageRect.x * scale, y: -stageRect.y * scale }); |
| |
| const viewportWidth = stage.width() * scale / stage.scaleX(); |
| const viewportHeight = stage.height() * scale / stage.scaleY(); |
| viewportRect.setAttrs({ |
| width: viewportWidth, |
| height: viewportHeight, |
| x: -stage.x() * scale, |
| y: -stage.y() * scale, |
| }); |
| minimapStage.batchDraw(); |
| } |
| |
| viewportRect.on('dragmove', () => { |
| const scale = minimapWidth / stage.width(); |
| stage.x(-viewportRect.x() / scale); |
| stage.y(-viewportRect.y() / scale); |
| stage.batchDraw(); |
| }); |
| |
| stage.on('dragmove zoom wheel', updateMinimap); |
| |
| const observer = new MutationObserver(updateMinimap); |
| observer.observe(layer.getCanvas()._canvas, { attributes: true }); |
| |
| updateMinimap(); |
| })(); |
| }); |
| </script> |
|
|
| </body> |
| </html> |
|
|
|
|