flow / index.html
abeea's picture
Upload 4 files
3a2c9f8 verified
Raw History Blame Contribute Delete
55.5 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>
<!-- Famst Agency SEO: start -->
<meta name="description" content="Universal Narrative Flowchart Creator is a Famst Agency web project by Abdullah Tarar. Famst Agency is an AI &amp; Web Development Agency building websites, e-commerce demos, AI tools, automation, business systems, and SEO-ready digital products.">
<meta name="keywords" content="Universal Narrative Flowchart Creator, Flow, Famst Agency, Famst Agency projects, Abdullah Tarar, Muhammad Abdullah Tarar, Abdullah Tarar developer, Famst Agency AI and Web Development Agency, AI web development agency, website development, custom web app, static website, Hugging Face Space, SEO ready website, Schema.org structured data, image SEO, video SEO, automation tools, ecommerce development, business website development, Pakistan web developer, Phalia Punjab Pakistan developer">
<meta name="author" content="Famst Agency, Abdullah Tarar, Muhammad Abdullah Tarar">
<meta name="creator" content="Abdullah Tarar">
<meta name="publisher" content="Famst Agency">
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1">
<meta name="googlebot" content="index, follow">
<meta name="bingbot" content="index, follow">
<meta name="theme-color" content="#0f766e">
<meta name="coverage" content="Worldwide">
<link rel="canonical" href="https://famstagency.com/projects/flow/index.html">
<link rel="sitemap" type="application/xml" href="sitemap.xml">
<link rel="alternate" type="text/plain" href="llms.txt" title="LLMs.txt">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Famst Agency Projects">
<meta property="og:title" content="Universal Narrative Flowchart Creator | Famst Agency">
<meta property="og:description" content="Universal Narrative Flowchart Creator is a Famst Agency web project by Abdullah Tarar. Famst Agency is an AI &amp; Web Development Agency building websites, e-commerce demos, AI tools, automation, business systems, and SEO-ready digital products.">
<meta property="og:url" content="https://famstagency.com/projects/flow/index.html">
<meta property="og:image" content="https://i.ibb.co/mrpCb5Rd/abdullahtarar.jpg">
<meta property="og:image:alt" content="Universal Narrative Flowchart Creator by Famst Agency and Abdullah Tarar">
<meta property="og:locale" content="en_US">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Universal Narrative Flowchart Creator | Famst Agency">
<meta name="twitter:description" content="Universal Narrative Flowchart Creator is a Famst Agency web project by Abdullah Tarar. Famst Agency is an AI &amp; Web Development Agency building websites, e-commerce demos, AI tools, automation, business systems, and SEO-ready digital products.">
<meta name="twitter:image" content="https://i.ibb.co/mrpCb5Rd/abdullahtarar.jpg">
<meta name="twitter:image:alt" content="Universal Narrative Flowchart Creator by Famst Agency and Abdullah Tarar">
<script type="application/ld+json">
{"@context":"https://schema.org","@graph":[{"@type":["Organization","ProfessionalService"],"@id":"https://famstagency.com/#organization","name":"Famst Agency","alternateName":["Famst","Famst Digital Agency","Famst AI and Web Development Agency"],"url":"https://famstagency.com/","logo":"https://i.ibb.co/mrpCb5Rd/abdullahtarar.jpg","image":"https://i.ibb.co/mrpCb5Rd/abdullahtarar.jpg","description":"Famst Agency is an AI and web development agency founded by Abdullah Tarar. The agency builds websites, e-commerce stores, AI tools, automation, custom business systems, and search-ready digital projects.","slogan":"AI & Web Development Agency","founder":{"@id":"https://famstagency.com/abdulahtararlinktree.html#abdullah-tarar"},"sameAs":["https://www.instagram.com/famstagency/","http://facebook.com/famstagency","https://www.linkedin.com/company/famst-agency","https://x.com/famstagency","https://github.com/famstagency","https://www.youtube.com/@FamstAgency","https://www.tiktok.com/@famstagency"],"knowsAbout":["AI tools","web development","e-commerce development","automation","SEO","Schema.org","business websites","custom software"],"areaServed":["Pakistan","Worldwide"],"contactPoint":{"@type":"ContactPoint","telephone":"+923424844392","contactType":"customer service","areaServed":"Worldwide","availableLanguage":["English","Urdu"]}},{"@type":"Person","@id":"https://famstagency.com/abdulahtararlinktree.html#abdullah-tarar","name":"Abdullah Tarar","alternateName":["Muhammad Abdullah","Muhammad Abdullah Tarar"],"url":"https://famstagency.com/abdulahtararlinktree.html","jobTitle":"Developer and Founder of Famst Agency","worksFor":{"@id":"https://famstagency.com/#organization"},"sameAs":["https://famstagency.com/abdulahtararlinktree.html","https://www.instagram.com/abdullahtarar.3","https://www.linkedin.com/in/muhammadabdullahtarar"],"knowsAbout":["web development","AI tools","automation","e-commerce","SEO","business software","Schema.org structured data"],"description":"Abdullah Tarar is the developer and founder associated with Famst Agency, an AI and web development agency focused on websites, AI tools, automation, and business systems."},{"@type":"WebSite","@id":"https://famstagency.com/#website","url":"https://famstagency.com/","name":"Famst Agency","publisher":{"@id":"https://famstagency.com/#organization"},"inLanguage":"en"},{"@type":["WebPage","CreativeWork"],"@id":"https://famstagency.com/projects/flow/index.html#webpage","url":"https://famstagency.com/projects/flow/index.html","name":"Universal Narrative Flowchart Creator","headline":"Universal Narrative Flowchart Creator","description":"Universal Narrative Flowchart Creator is a Famst Agency web project by Abdullah Tarar. Famst Agency is an AI & Web Development Agency building websites, e-commerce demos, AI tools, automation, business systems, and SEO-ready digital products.","keywords":["Universal Narrative Flowchart Creator","Flow","Famst Agency","Famst Agency projects","Abdullah Tarar","Muhammad Abdullah Tarar","Abdullah Tarar developer","Famst Agency AI and Web Development Agency","AI web development agency","website development","custom web app","static website","Hugging Face Space","SEO ready website","Schema.org structured data","image SEO","video SEO","automation tools","ecommerce development","business website development","Pakistan web developer","Phalia Punjab Pakistan developer"],"isPartOf":{"@id":"https://famstagency.com/#website"},"creator":{"@id":"https://famstagency.com/abdulahtararlinktree.html#abdullah-tarar"},"author":{"@id":"https://famstagency.com/abdulahtararlinktree.html#abdullah-tarar"},"publisher":{"@id":"https://famstagency.com/#organization"},"dateModified":"2026-07-10","inLanguage":"en","about":[{"@id":"https://famstagency.com/#organization"},{"@id":"https://famstagency.com/abdulahtararlinktree.html#abdullah-tarar"}],"mainEntityOfPage":"https://famstagency.com/projects/flow/index.html","image":["https://i.ibb.co/mrpCb5Rd/abdullahtarar.jpg"]},{"@type":"BreadcrumbList","@id":"https://famstagency.com/projects/flow/index.html#breadcrumbs","itemListElement":[{"@type":"ListItem","position":1,"name":"Famst Agency","item":"https://famstagency.com/"},{"@type":"ListItem","position":2,"name":"Universal Narrative Flowchart Creator","item":"https://famstagency.com/projects/flow/index.html"}]}]}
</script>
<!-- Famst Agency SEO: end -->
<!-- Famst Agency Footer Styles: start -->
<style>
.famst-agency-footer{width:100%;margin-top:32px;padding:22px 16px;background:linear-gradient(135deg,#0f172a,#111827);color:#f8fafc;font:500 14px/1.55 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;clear:both;box-sizing:border-box;border-top:1px solid rgba(255,255,255,.12)}
.famst-agency-footer__inner{max-width:1180px;margin:0 auto;display:grid;grid-template-columns:1.3fr .7fr;gap:18px;align-items:center}
.famst-agency-footer p{margin:0;color:#e5e7eb}.famst-agency-footer strong{display:block;color:#fff;font-size:15px;margin-bottom:4px}.famst-agency-footer span{color:#cbd5e1}.famst-agency-footer a{color:#67e8f9;text-decoration:none;font-weight:800}.famst-agency-footer a:hover{text-decoration:underline}.famst-agency-footer__links{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end}.famst-agency-footer__links a{padding:6px 10px;border:1px solid rgba(103,232,249,.28);border-radius:999px;background:rgba(255,255,255,.06);font-size:12px}
@media (max-width:700px){.famst-agency-footer__inner{grid-template-columns:1fr;text-align:center}.famst-agency-footer__links{justify-content:center}}
</style>
<!-- Famst Agency Footer Styles: end -->
</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>
<!-- Famst Agency Footer: start -->
<footer class="famst-agency-footer" aria-label="Famst Agency project credits and profiles">
<div class="famst-agency-footer__inner">
<div>
<strong>Built by <a href="https://famstagency.com/" target="_blank" rel="noopener">Famst Agency</a> - AI &amp; Web Development Agency</strong>
<p>Project SEO, structured data, image/video indexing, and web presentation by <a href="https://famstagency.com/abdulahtararlinktree.html" target="_blank" rel="noopener">Abdullah Tarar</a>, developer and founder connected with Famst Agency.</p>
</div>
<nav class="famst-agency-footer__links" aria-label="Famst Agency profiles">
<a href="https://famstagency.com/" target="_blank" rel="noopener">Website</a>
<a href="https://www.instagram.com/famstagency/" target="_blank" rel="noopener">Instagram</a>
<a href="https://www.linkedin.com/company/famst-agency" target="_blank" rel="noopener">LinkedIn</a>
<a href="https://github.com/famstagency" target="_blank" rel="noopener">GitHub</a>
<a href="https://www.youtube.com/@FamstAgency" target="_blank" rel="noopener">YouTube</a>
<a href="https://famstagency.com/abdulahtararlinktree.html" target="_blank" rel="noopener">Abdullah Tarar</a>
</nav>
</div>
</footer>
<!-- Famst Agency Footer: end -->
</body>
</html>