flow / index.html
abeea's picture
Update index.html
f566599 verified
Raw
History Blame Contribute Delete
45.4 kB
<!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>
<!-- Added dependency for PDF export -->
<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;
}
/* MODIFIED: Changed flex-direction to row to place toolbar beside the canvas */
#app {
display: flex;
flex-direction: row; /* This is the main change */
flex-grow: 1;
}
/* MODIFIED: Adapted toolbar for vertical layout on the right */
#toolbar {
padding: 20px 12px;
background-color: #ffffff;
border-left: 1px solid #e0e0e0; /* Changed from border-bottom */
display: flex;
flex-direction: column; /* Stack controls vertically */
gap: 15px; /* Increased gap for vertical spacing */
align-items: center;
box-shadow: -2px 0 5px rgba(0,0,0,0.1); /* Shadow on the left */
width: 240px; /* Give the toolbar a fixed width */
overflow-y: auto; /* Allow scrolling if controls overflow */
z-index: 1001;
}
/* MODIFIED: Make buttons and groups take full width of the toolbar */
#toolbar button, #toolbar label.button {
padding: 12px 18px; /* Adjusted padding */
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%; /* Make buttons fill the toolbar width */
box-sizing: border-box; /* Include padding in width calculation */
}
#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;
}
/* MODIFIED: Changed button group to be vertical */
.button-group {
display: flex;
flex-direction: column; /* Stack buttons in a group vertically */
gap: 8px;
align-items: center;
border: 1px solid #ddd;
padding: 10px 8px; /* Adjusted padding */
border-radius: 6px;
background-color: #f8f9fa;
width: 100%; /* Make group fill toolbar width */
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>
<!-- MODIFIED: Swapped the order of main-container and toolbar, and removed the top-level status bar -->
<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>
<!-- Moved status bar to be outside the main flex container -->
<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', () => {
// --- SETUP & LIBS ---
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);
// --- CONSTANTS ---
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' };
// --- DOM ELEMENTS ---
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');
// --- STATE ---
let state = {
nodes: new Map(), connections: new Map(), isConnectMode: false, sourceNodeId: null,
targetNodeId: null, selectedObjectId: null, selectedObjectType: null,
isSnapToGrid: false, editingId: null, longPressTimeout: null,
};
// --- NEW: UNDO/REDO ---
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();
// Only save if it's different from the last state
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) { // handle legacy saves without 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;
}
// --- UTILITY & CORE FUNCTIONS ---
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();
}
// --- NODE MANAGEMENT ---
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 { // rectangle
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,
});
// Center text manually within the group
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) {
// This would require deleting and re-adding the shape, which is complex.
// A simpler approach is to handle it during the next load (via save/restore)
// For now, we just save the state, and it will be correct on reload.
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();
}
// --- CONNECTION MANAGEMENT ---
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) {
// This can be improved with edge detection, but for now, center-to-center is fine.
return [from.x, from.y, to.x, to.y];
}
// --- EVENT HANDLERS ---
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();
}
});
// --- TOUCH FEATURES (Original code, verified) ---
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);
});
// --- NEW: SEARCH FUNCTIONALITY ---
searchBox.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase().trim();
// First, reset all appearances
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();
});
// --- MODAL & CONTEXT MENU ---
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();
}
// --- BUTTON & MENU LISTENERS ---
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();
});
// Modal & Context Menu listeners
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)) {
// Change requires redraw, so we save state and reload.
const node = state.nodes.get(state.editingId);
node.shape = newShape;
saveState();
restoreState(history[historyIndex]); // Reload current state with shape change
} else {
alert("Invalid shape. Please choose rectangle, circle, or diamond.");
}
});
// --- KEYBOARD & FILE I/O ---
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(); // Add the loaded state to the history
};
reader.onerror = () => updateStatus('Error reading file.');
reader.readAsText(file);
e.target.value = null; // Reset input
});
// --- EXPORT LISTENERS ---
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.');
});
// --- INITIALIZATION ---
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(); // Create initial history entry
// --- NEW: MINIMAP ---
(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 }); // Scale stage to fit content
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);
// Update minimap when nodes/connections change
const observer = new MutationObserver(updateMinimap);
observer.observe(layer.getCanvas()._canvas, { attributes: true });
updateMinimap();
})();
});
</script>
</body>
</html>