From 5f57754c4257abd88f5ba67c4a13cf0d53d37b3a Mon Sep 17 00:00:00 2001 From: devkyato Date: Mon, 20 Jul 2026 08:29:02 +0800 Subject: [PATCH 01/10] feat: scaffold compact flowchart editor --- index.html | 100 +++++++++++++++++++++ style.css | 251 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 351 insertions(+) create mode 100644 index.html create mode 100644 style.css diff --git a/index.html b/index.html new file mode 100644 index 0000000..382fc72 --- /dev/null +++ b/index.html @@ -0,0 +1,100 @@ + + + + + + + Flowchart Creator + + + +
+
+ + +
+ + +
+
+ + + +
+
+ + + +
+
+
+ + + + +
+
+ + + + + + +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ Ready + 0 selected + 2400 × 1600 +
+
+ + + diff --git a/style.css b/style.css new file mode 100644 index 0000000..155c5d7 --- /dev/null +++ b/style.css @@ -0,0 +1,251 @@ +:root { + color-scheme: light; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #252a31; + background: #f5f6f8; + font-synthesis: none; + --accent: #2563eb; + --accent-soft: #eff6ff; + --border: #d9dde3; + --muted: #68717d; + --panel: #ffffff; + --toolbar-height: 50px; +} + +* { box-sizing: border-box; } + +html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; } + +button, input, select, textarea { font: inherit; } + +button { + min-height: 28px; + padding: 4px 8px; + color: #343a43; + background: #fff; + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + white-space: nowrap; +} + +button:hover:not(:disabled) { background: #f2f4f7; border-color: #d9dde3; } +button:active:not(:disabled) { background: #e8ebef; } +button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { + outline: 2px solid #93b4ff; + outline-offset: 1px; +} +button:disabled { color: #a9afb8; cursor: default; background: transparent; } +button.active { color: #164db8; background: var(--accent-soft); border-color: #bcd0fb; } + +#app { + display: grid; + grid-template-columns: 164px minmax(0, 1fr) auto; + grid-template-rows: var(--toolbar-height) minmax(0, 1fr) 25px; + width: 100vw; + height: 100vh; +} + +.toolbar { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + padding: 6px 8px; + background: #fff; + border-bottom: 1px solid var(--border); + z-index: 5; +} + +.document-title { + width: 170px; + min-width: 110px; + height: 30px; + padding: 4px 7px; + font-size: 13px; + font-weight: 600; + border: 1px solid transparent; + border-radius: 4px; + text-overflow: ellipsis; +} +.document-title:hover, .document-title:focus { border-color: var(--border); } +.toolbar-separator { width: 1px; height: 24px; margin: 0 2px; background: var(--border); } +.toolbar-spacer { flex: 1; min-width: 4px; } +.tool-group { display: flex; align-items: center; gap: 1px; } +.toolbar button { font-size: 12px; } +.zoom-group button { min-width: 28px; } +#zoom-display { width: 48px; padding-inline: 3px; } + +.shape-panel, .properties-panel { + min-height: 0; + background: var(--panel); + overflow: auto; + z-index: 3; +} +.shape-panel { grid-column: 1; border-right: 1px solid var(--border); } +.properties-panel { grid-column: 3; width: 228px; border-left: 1px solid var(--border); } + +.panel-heading { + position: sticky; + top: 0; + display: flex; + align-items: center; + justify-content: space-between; + height: 36px; + padding: 0 10px; + font-size: 11px; + font-weight: 700; + letter-spacing: .04em; + text-transform: uppercase; + background: rgba(255,255,255,.96); + border-bottom: 1px solid #eceef1; + z-index: 2; +} +.snap-toggle { display: flex; align-items: center; gap: 4px; font-weight: 500; text-transform: none; letter-spacing: 0; color: var(--muted); cursor: pointer; } +.snap-toggle input { margin: 0; accent-color: var(--accent); } + +.shape-list { display: grid; grid-template-columns: repeat(2, 1fr); gap: 4px; padding: 7px; } +.shape-item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + min-width: 0; + height: 66px; + padding: 5px 2px; + color: #4a515b; + background: #fff; + border: 1px solid transparent; + border-radius: 5px; + cursor: grab; + user-select: none; +} +.shape-item:hover { background: #f7f9fc; border-color: #dce2ea; } +.shape-item:active { cursor: grabbing; } +.shape-item svg { width: 42px; height: 30px; overflow: visible; pointer-events: none; } +.shape-item span { width: 100%; overflow: hidden; font-size: 10px; line-height: 1.1; text-align: center; text-overflow: ellipsis; white-space: nowrap; } + +.canvas-shell { + position: relative; + grid-column: 2; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #eef0f3; + outline: none; +} +.canvas-shell[data-mode="select"] { cursor: default; } +.canvas-shell[data-mode="connect"] { cursor: crosshair; } +.canvas-shell[data-mode="text"] { cursor: text; } +.canvas-shell.panning { cursor: grabbing; } +#canvas { display: block; width: 100%; height: 100%; user-select: none; touch-action: none; } +.workspace-bg { fill: #fff; stroke: #cfd4dc; stroke-width: 1; } +.grid { fill: url(#small-grid); pointer-events: none; } +.node { cursor: move; } +.node text, .edge-label { cursor: text; pointer-events: auto; } +.node-shape { vector-effect: non-scaling-stroke; } +.edge-path { fill: none; cursor: pointer; vector-effect: non-scaling-stroke; } +.edge-hit { fill: none; stroke: transparent; stroke-width: 14; cursor: pointer; vector-effect: non-scaling-stroke; } +.edge-label-bg { fill: #fff; opacity: .92; } +.selection-outline { fill: none; stroke: var(--accent); stroke-width: 1.5; stroke-dasharray: 4 3; vector-effect: non-scaling-stroke; pointer-events: none; } +.resize-handle, .endpoint-handle { fill: #fff; stroke: var(--accent); stroke-width: 1.5; vector-effect: non-scaling-stroke; } +.resize-handle { cursor: nwse-resize; } +.resize-handle[data-handle="n"], .resize-handle[data-handle="s"] { cursor: ns-resize; } +.resize-handle[data-handle="e"], .resize-handle[data-handle="w"] { cursor: ew-resize; } +.resize-handle[data-handle="ne"], .resize-handle[data-handle="sw"] { cursor: nesw-resize; } +.port { fill: #fff; stroke: var(--accent); stroke-width: 1.5; cursor: crosshair; vector-effect: non-scaling-stroke; } +.port:hover { fill: var(--accent); } +.marquee { fill: rgba(37,99,235,.08); stroke: var(--accent); stroke-width: 1; stroke-dasharray: 4 3; vector-effect: non-scaling-stroke; pointer-events: none; } +.empty-hint { fill: #89919c; font-size: 14px; pointer-events: none; } +.inline-editor { + position: fixed; + display: none; + min-width: 56px; + min-height: 32px; + padding: 3px 5px; + color: #242932; + background: rgba(255,255,255,.96); + border: 1px solid var(--accent); + border-radius: 3px; + outline: none; + resize: none; + overflow: hidden; + line-height: 1.25; + text-align: center; + z-index: 20; +} + +.properties-content { padding: 9px; font-size: 12px; } +.property-section { padding-bottom: 10px; margin-bottom: 10px; border-bottom: 1px solid #eceef1; } +.property-section:last-child { border-bottom: 0; margin-bottom: 0; } +.property-section-title { margin-bottom: 7px; color: #737b87; font-size: 10px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; } +.property-row { display: grid; grid-template-columns: 70px minmax(0, 1fr); align-items: center; gap: 7px; min-height: 30px; } +.property-row > label { color: #5c6470; } +.property-row input[type="number"], .property-row input[type="text"], .property-row select { + width: 100%; + height: 26px; + min-width: 0; + padding: 2px 5px; + color: #303640; + background: #fff; + border: 1px solid var(--border); + border-radius: 3px; +} +.property-row input[type="color"] { width: 34px; height: 25px; padding: 1px; border: 1px solid var(--border); border-radius: 3px; background: #fff; } +.property-actions, .segment { display: flex; gap: 3px; flex-wrap: wrap; } +.property-actions button, .segment button { flex: 1; min-width: 28px; border-color: var(--border); font-size: 11px; } +.segment button.active { border-color: #bcd0fb; } +.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; } +.mini-field label { display: block; margin-bottom: 3px; color: #737b87; font-size: 10px; } +.mini-field input { width: 100%; height: 26px; padding: 2px 5px; border: 1px solid var(--border); border-radius: 3px; } + +.statusbar { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: 18px; + min-width: 0; + padding: 0 9px; + color: #6d7580; + background: #fff; + border-top: 1px solid var(--border); + font-size: 10px; + z-index: 5; +} +#status-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +#selection-status { margin-left: auto; } + +@media (max-width: 1060px) { + .file-actions button { padding-inline: 5px; } + .toolbar-separator { display: none; } + .document-title { width: 140px; } +} + +@media (max-width: 840px) { + :root { --toolbar-height: 84px; } + #app { grid-template-columns: 1fr; grid-template-rows: var(--toolbar-height) 82px minmax(0, 1fr) 25px; } + .toolbar { align-content: center; flex-wrap: wrap; overflow: hidden; } + .document-title { width: 155px; } + .toolbar-spacer { display: none; } + .shape-panel { grid-column: 1; grid-row: 2; border-right: 0; border-bottom: 1px solid var(--border); overflow-x: auto; overflow-y: hidden; } + .shape-panel .panel-heading { display: none; } + .shape-list { display: flex; width: max-content; height: 81px; padding: 6px; } + .shape-item { width: 70px; height: 68px; } + .canvas-shell { grid-column: 1; grid-row: 3; } + .properties-panel { position: absolute; right: 8px; top: 94px; bottom: 33px; width: min(228px, calc(100vw - 16px)); border: 1px solid var(--border); border-radius: 5px; box-shadow: 0 5px 20px rgba(22,30,43,.12); } + .statusbar { grid-row: 4; } +} + +@media (max-width: 540px) { + :root { --toolbar-height: 116px; } + .toolbar { align-items: flex-start; padding: 5px; } + .toolbar button { font-size: 11px; } + .document-title { flex: 1; min-width: 140px; } + .file-actions { width: 100%; overflow-x: auto; } + .properties-panel { top: 123px; } + #canvas-size-status { display: none; } +} + +@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; } } From 1ee48a85e56265f522d77c2a522b157636b9518f Mon Sep 17 00:00:00 2001 From: devkyato Date: Mon, 20 Jul 2026 08:35:41 +0800 Subject: [PATCH 02/10] feat: implement svg diagram editing engine --- script.js | 1160 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1160 insertions(+) create mode 100644 script.js diff --git a/script.js b/script.js new file mode 100644 index 0000000..6924e9a --- /dev/null +++ b/script.js @@ -0,0 +1,1160 @@ +(() => { + "use strict"; + + const SVG_NS = "http://www.w3.org/2000/svg"; + const CANVAS = { width: 2400, height: 1600, padding: 16 }; + const GRID_SIZE = 20; + const STORAGE_KEY = "flowchart-creator-project-v1"; + const DEFAULT_NODE_STYLE = Object.freeze({ + fill: "#ffffff", + stroke: "#475569", + strokeWidth: 1.5, + strokeStyle: "solid", + opacity: 1, + fontSize: 14, + fontWeight: "400", + italic: false, + textAlign: "center", + textColor: "#1f2937", + cornerRadius: 6 + }); + const DEFAULT_EDGE_STYLE = Object.freeze({ + stroke: "#475569", + strokeWidth: 1.5, + strokeStyle: "solid", + startArrow: "none", + endArrow: "arrow" + }); + const SHAPES = [ + ["terminator", "Start / End"], + ["process", "Process"], + ["input", "Input / Output"], + ["decision", "Decision"], + ["document", "Document"], + ["predefined", "Predefined"], + ["database", "Database"], + ["manual", "Manual Input"], + ["preparation", "Preparation"], + ["onpage", "On-page"], + ["offpage", "Off-page"], + ["delay", "Delay"] + ]; + const DEFAULT_SIZES = { + terminator: [150, 62], process: [160, 76], input: [170, 76], decision: [145, 100], + document: [170, 88], predefined: [170, 76], database: [160, 92], manual: [170, 76], + preparation: [170, 76], onpage: [72, 72], offpage: [110, 92], delay: [150, 76], text: [180, 48] + }; + + const refs = { + shell: document.querySelector("#canvas-shell"), + svg: document.querySelector("#canvas"), + viewport: document.querySelector("#viewport"), + edgeLayer: document.querySelector("#edge-layer"), + nodeLayer: document.querySelector("#node-layer"), + overlayLayer: document.querySelector("#overlay-layer"), + shapeList: document.querySelector("#shape-list"), + properties: document.querySelector("#properties-panel"), + propertiesTitle: document.querySelector("#properties-title"), + propertiesContent: document.querySelector("#properties-content"), + title: document.querySelector("#document-title"), + zoom: document.querySelector("#zoom-display"), + status: document.querySelector("#status-text"), + selectionStatus: document.querySelector("#selection-status"), + snap: document.querySelector("#snap-toggle"), + editor: document.querySelector("#inline-editor"), + fileInput: document.querySelector("#file-input") + }; + + let state = createInitialState(); + let interaction = null; + let spacePressed = false; + let clipboard = null; + let editing = null; + let nudgePending = false; + + function uid(prefix = "item") { + return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; + } + + function clone(value) { + return JSON.parse(JSON.stringify(value)); + } + + function createNode(type, x, y, text = SHAPES.find(([key]) => key === type)?.[1] || "Text") { + const [width, height] = DEFAULT_SIZES[type] || DEFAULT_SIZES.process; + return { + id: uid("node"), type, x, y, width, height, rotation: 0, text, + style: clone(DEFAULT_NODE_STYLE), zIndex: state?.nodes?.length || 0, groupId: null + }; + } + + function createInitialState() { + const next = { + documentTitle: "Untitled Flowchart", + nodes: [], edges: [], selectedIds: [], zoom: 0.82, pan: { x: 160, y: 38 }, + mode: "select", snap: true, history: [], historyIndex: -1 + }; + state = next; + const labels = [ + ["terminator", "START"], + ["input", "Display message:\n“How many hours did you work?”"], + ["input", "Read Hours"], + ["input", "Display message:\n“How much do you get paid per hour?”"], + ["input", "Read Pay Rate"], + ["process", "Multiply Hours by Pay Rate\nStore result in Gross Pay"], + ["input", "Display Gross Pay"], + ["terminator", "END"] + ]; + labels.forEach(([type, text], index) => { + const node = createNode(type, 1120, 55 + index * 185, text); + if (type === "input") node.width = index === 1 || index === 3 ? 320 : 190; + if (type === "process") node.width = 300; + next.nodes.push(node); + if (index) { + next.edges.push({ + id: uid("edge"), fromNodeId: next.nodes[index - 1].id, fromPort: "bottom", + toNodeId: node.id, toPort: "top", type: "straight", label: "", style: clone(DEFAULT_EDGE_STYLE) + }); + } + }); + next.history = [snapshot(next)]; + next.historyIndex = 0; + return next; + } + + function snapshot(source = state) { + return JSON.stringify({ + documentTitle: source.documentTitle, + nodes: source.nodes, + edges: source.edges, + zoom: source.zoom, + pan: source.pan, + snap: source.snap + }); + } + + function commit(message = "Updated") { + const current = snapshot(); + if (state.history[state.historyIndex] === current) return; + state.history = state.history.slice(0, state.historyIndex + 1); + state.history.push(current); + if (state.history.length > 100) state.history.shift(); + state.historyIndex = state.history.length - 1; + setStatus(message); + updateToolbar(); + } + + function restoreHistory(index) { + if (index < 0 || index >= state.history.length) return; + const history = state.history; + const restored = JSON.parse(history[index]); + state = { ...state, ...restored, selectedIds: [], history, historyIndex: index, mode: "select" }; + refs.title.value = state.documentTitle; + refs.snap.checked = state.snap; + finishTextEditing(false); + render(); + } + + function undo() { restoreHistory(state.historyIndex - 1); setStatus("Undo"); } + function redo() { restoreHistory(state.historyIndex + 1); setStatus("Redo"); } + + function svgEl(tag, attributes = {}, text = "") { + const element = document.createElementNS(SVG_NS, tag); + Object.entries(attributes).forEach(([name, value]) => { + if (value !== null && value !== undefined && value !== "") element.setAttribute(name, String(value)); + }); + if (text) element.textContent = text; + return element; + } + + function dashArray(style) { + if (style === "dashed") return "7 5"; + if (style === "dotted") return "2 4"; + return null; + } + + function geometryElements(node, preview = false) { + const { width: w, height: h, type } = node; + const style = node.style || DEFAULT_NODE_STYLE; + const common = { + class: preview ? "" : "node-shape", + fill: style.fill, + stroke: style.stroke, + "stroke-width": style.strokeWidth, + "stroke-dasharray": dashArray(style.strokeStyle), + opacity: style.opacity, + "stroke-linejoin": "round" + }; + const items = []; + if (type === "terminator") items.push(svgEl("rect", { ...common, width: w, height: h, rx: h / 2 })); + else if (type === "process") items.push(svgEl("rect", { ...common, width: w, height: h, rx: style.cornerRadius || 0 })); + else if (type === "input") items.push(svgEl("polygon", { ...common, points: `${w * .13},0 ${w},0 ${w * .87},${h} 0,${h}` })); + else if (type === "decision") items.push(svgEl("polygon", { ...common, points: `${w / 2},0 ${w},${h / 2} ${w / 2},${h} 0,${h / 2}` })); + else if (type === "document") items.push(svgEl("path", { ...common, d: `M0 0H${w}V${h * .78} C${w * .76} ${h * .58},${w * .58} ${h},${w * .34} ${h * .82} C${w * .2} ${h * .72},${w * .1} ${h * .78},0 ${h * .9}Z` })); + else if (type === "predefined") { + items.push(svgEl("rect", { ...common, width: w, height: h, rx: style.cornerRadius || 0 })); + items.push(svgEl("path", { d: `M${w * .12} 0V${h} M${w * .88} 0V${h}`, fill: "none", stroke: style.stroke, "stroke-width": style.strokeWidth, "stroke-dasharray": dashArray(style.strokeStyle), opacity: style.opacity, class: preview ? "" : "node-shape" })); + } else if (type === "database") { + items.push(svgEl("path", { ...common, d: `M0 ${h * .14} C0 ${-h * .02},${w} ${-h * .02},${w} ${h * .14} V${h * .86} C${w} ${h * 1.02},0 ${h * 1.02},0 ${h * .86}Z` })); + items.push(svgEl("ellipse", { cx: w / 2, cy: h * .14, rx: w / 2, ry: h * .14, fill: "none", stroke: style.stroke, "stroke-width": style.strokeWidth, opacity: style.opacity, class: preview ? "" : "node-shape" })); + } else if (type === "manual") items.push(svgEl("polygon", { ...common, points: `${w * .16},0 ${w},0 ${w * .86},${h} 0,${h}` })); + else if (type === "preparation") items.push(svgEl("polygon", { ...common, points: `${w * .15},0 ${w * .85},0 ${w},${h / 2} ${w * .85},${h} ${w * .15},${h} 0,${h / 2}` })); + else if (type === "onpage") items.push(svgEl("ellipse", { ...common, cx: w / 2, cy: h / 2, rx: w / 2, ry: h / 2 })); + else if (type === "offpage") items.push(svgEl("polygon", { ...common, points: `0,0 ${w},0 ${w},${h * .66} ${w / 2},${h} 0,${h * .66}` })); + else if (type === "delay") items.push(svgEl("path", { ...common, d: `M0 0H${w * .52} C${w * 1.12} 0,${w * 1.12} ${h},${w * .52} ${h}H0Z` })); + return items; + } + + function wrappedLines(text, width, height, fontSize) { + const maxChars = Math.max(4, Math.floor((width - 24) / (fontSize * .56))); + const sourceLines = String(text || "").split("\n"); + const result = []; + sourceLines.forEach((source) => { + if (!source) { result.push(""); return; } + const words = source.split(/\s+/); + let line = ""; + words.forEach((word) => { + if (word.length > maxChars && !line) { + for (let index = 0; index < word.length; index += maxChars) result.push(word.slice(index, index + maxChars)); + } else if (!line || `${line} ${word}`.length <= maxChars) line = line ? `${line} ${word}` : word; + else { result.push(line); line = word; } + }); + if (line) result.push(line); + }); + const lineHeight = fontSize * 1.25; + const maxLines = Math.max(1, Math.floor((height - 12) / lineHeight)); + if (result.length > maxLines) { + result.length = maxLines; + const last = result[maxLines - 1]; + result[maxLines - 1] = `${last.slice(0, Math.max(1, maxChars - 1))}…`; + } + return result; + } + + function appendNodeText(group, node) { + const style = node.style || DEFAULT_NODE_STYLE; + const fontSize = Math.max(8, Number(style.fontSize) || 14); + const lines = wrappedLines(node.text, node.width, node.height, fontSize); + const lineHeight = fontSize * 1.25; + const anchor = style.textAlign === "left" ? "start" : style.textAlign === "right" ? "end" : "middle"; + const x = style.textAlign === "left" ? 12 : style.textAlign === "right" ? node.width - 12 : node.width / 2; + const startY = node.height / 2 - ((lines.length - 1) * lineHeight) / 2 + fontSize * .34; + const text = svgEl("text", { + x, y: startY, fill: style.textColor || "#1f2937", "font-size": fontSize, + "font-family": "Inter, ui-sans-serif, system-ui, sans-serif", "font-weight": style.fontWeight, + "font-style": style.italic ? "italic" : "normal", "text-anchor": anchor, + "data-text-node": node.id, "pointer-events": "auto" + }); + lines.forEach((line, index) => text.append(svgEl("tspan", { x, dy: index ? lineHeight : 0 }, line))); + group.append(text); + } + + function renderNodeInto(node, parent) { + const group = svgEl("g", { + class: "node", "data-node-id": node.id, + transform: `translate(${node.x} ${node.y}) rotate(${node.rotation || 0} ${node.width / 2} ${node.height / 2})` + }); + if (node.type !== "text") geometryElements(node).forEach((element) => group.append(element)); + appendNodeText(group, node); + parent.append(group); + } + + function portPoint(node, port) { + if (!node) return { x: 0, y: 0 }; + if (port === "top") return { x: node.x + node.width / 2, y: node.y }; + if (port === "right") return { x: node.x + node.width, y: node.y + node.height / 2 }; + if (port === "bottom") return { x: node.x + node.width / 2, y: node.y + node.height }; + return { x: node.x, y: node.y + node.height / 2 }; + } + + function portVector(port) { + return { top: [0, -1], right: [1, 0], bottom: [0, 1], left: [-1, 0] }[port] || [0, 0]; + } + + function connectorPath(edge, overrideEnd = null) { + const from = state.nodes.find((node) => node.id === edge.fromNodeId); + const to = state.nodes.find((node) => node.id === edge.toNodeId); + const a = portPoint(from, edge.fromPort); + const b = overrideEnd || portPoint(to, edge.toPort); + if (edge.type === "curved") { + const [avx, avy] = portVector(edge.fromPort); + const [bvx, bvy] = portVector(edge.toPort); + const distance = Math.max(50, Math.min(180, Math.hypot(b.x - a.x, b.y - a.y) * .45)); + return `M${a.x} ${a.y} C${a.x + avx * distance} ${a.y + avy * distance},${b.x + bvx * distance} ${b.y + bvy * distance},${b.x} ${b.y}`; + } + if (edge.type === "elbow") { + const horizontalStart = edge.fromPort === "left" || edge.fromPort === "right"; + if (horizontalStart) { + const midX = (a.x + b.x) / 2; + return `M${a.x} ${a.y} H${midX} V${b.y} H${b.x}`; + } + const midY = (a.y + b.y) / 2; + return `M${a.x} ${a.y} V${midY} H${b.x} V${b.y}`; + } + return `M${a.x} ${a.y} L${b.x} ${b.y}`; + } + + function markerValue(kind, end) { + if (!kind || kind === "none") return null; + if (kind === "arrow") return `url(#arrow-${end})`; + if (kind === "circle") return "url(#circle-end)"; + if (kind === "diamond") return "url(#diamond-end)"; + return null; + } + + function edgeLabelPoint(edge) { + const from = state.nodes.find((node) => node.id === edge.fromNodeId); + const to = state.nodes.find((node) => node.id === edge.toNodeId); + const a = portPoint(from, edge.fromPort); + const b = portPoint(to, edge.toPort); + return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; + } + + function renderEdgeInto(edge, parent) { + if (!state.nodes.some((node) => node.id === edge.fromNodeId) || !state.nodes.some((node) => node.id === edge.toNodeId)) return; + const group = svgEl("g", { class: "edge", "data-edge-id": edge.id }); + const style = edge.style || DEFAULT_EDGE_STYLE; + const d = connectorPath(edge); + group.append(svgEl("path", { class: "edge-hit", d })); + group.append(svgEl("path", { + class: "edge-path", d, stroke: style.stroke, "stroke-width": style.strokeWidth, + "stroke-dasharray": dashArray(style.strokeStyle), "marker-start": markerValue(style.startArrow, "start"), + "marker-end": markerValue(style.endArrow, "end") + })); + if (edge.label) { + const point = edgeLabelPoint(edge); + const width = Math.max(34, edge.label.length * 7 + 10); + group.append(svgEl("rect", { class: "edge-label-bg", x: point.x - width / 2, y: point.y - 12, width, height: 22, rx: 3 })); + group.append(svgEl("text", { class: "edge-label", x: point.x, y: point.y + 4, "text-anchor": "middle", "font-size": 12, fill: "#334155", "data-text-edge": edge.id }, edge.label)); + } + parent.append(group); + } + + function render() { + refs.viewport.setAttribute("transform", `translate(${state.pan.x} ${state.pan.y}) scale(${state.zoom})`); + refs.edgeLayer.replaceChildren(); + refs.nodeLayer.replaceChildren(); + refs.overlayLayer.replaceChildren(); + state.edges.forEach((edge) => renderEdgeInto(edge, refs.edgeLayer)); + [...state.nodes].sort((a, b) => a.zIndex - b.zIndex).forEach((node) => renderNodeInto(node, refs.nodeLayer)); + renderOverlays(); + refs.zoom.textContent = `${Math.round(state.zoom * 100)}%`; + refs.selectionStatus.textContent = `${state.selectedIds.length} selected`; + refs.shell.dataset.mode = state.mode; + renderProperties(); + updateToolbar(); + } + + function renderOverlays() { + if (!state.nodes.length && !state.edges.length) { + refs.overlayLayer.append(svgEl("text", { class: "empty-hint", x: CANVAS.width / 2, y: CANVAS.height / 2, "text-anchor": "middle" }, "Add a shape or double-click to add text")); + } + const selectedNodes = state.nodes.filter((node) => state.selectedIds.includes(node.id)); + selectedNodes.forEach((node) => renderNodeOverlay(node)); + if (state.mode === "connect") { + state.nodes.filter((node) => !state.selectedIds.includes(node.id)).forEach((node) => renderPorts(node)); + } + const selectedEdge = state.edges.find((edge) => state.selectedIds.includes(edge.id)); + if (selectedEdge && state.selectedIds.length === 1) renderEdgeOverlay(selectedEdge); + if (interaction?.type === "marquee") { + const box = normalizedRect(interaction.startWorld, interaction.currentWorld); + refs.overlayLayer.append(svgEl("rect", { class: "marquee", ...box })); + } + if (interaction?.type === "connect") { + const edge = { fromNodeId: interaction.nodeId, fromPort: interaction.port, toNodeId: interaction.nodeId, toPort: interaction.port, type: "straight" }; + refs.overlayLayer.append(svgEl("path", { d: connectorPath(edge, interaction.currentWorld), fill: "none", stroke: "#2563eb", "stroke-width": 1.5, "stroke-dasharray": "5 4", "marker-end": "url(#arrow-end)", "vector-effect": "non-scaling-stroke" })); + } + if (interaction?.type === "reconnect") { + const edge = state.edges.find((item) => item.id === interaction.edgeId); + if (edge) { + const fixedNode = state.nodes.find((node) => node.id === (interaction.end === "from" ? edge.toNodeId : edge.fromNodeId)); + const fixedPort = interaction.end === "from" ? edge.toPort : edge.fromPort; + const fixed = portPoint(fixedNode, fixedPort); + const a = interaction.end === "from" ? interaction.currentWorld : fixed; + const b = interaction.end === "from" ? fixed : interaction.currentWorld; + refs.overlayLayer.append(svgEl("path", { d: `M${a.x} ${a.y} L${b.x} ${b.y}`, fill: "none", stroke: "#2563eb", "stroke-width": 1.5, "stroke-dasharray": "5 4", "vector-effect": "non-scaling-stroke" })); + } + } + } + + function renderNodeOverlay(node) { + refs.overlayLayer.append(svgEl("rect", { + class: "selection-outline", x: node.x - 3, y: node.y - 3, width: node.width + 6, height: node.height + 6, rx: 2 + })); + if (state.selectedIds.length === 1) { + const handles = { + nw: [node.x, node.y], n: [node.x + node.width / 2, node.y], ne: [node.x + node.width, node.y], + e: [node.x + node.width, node.y + node.height / 2], se: [node.x + node.width, node.y + node.height], + s: [node.x + node.width / 2, node.y + node.height], sw: [node.x, node.y + node.height], w: [node.x, node.y + node.height / 2] + }; + Object.entries(handles).forEach(([handle, [cx, cy]]) => refs.overlayLayer.append(svgEl("rect", { + class: "resize-handle", "data-resize-id": node.id, "data-handle": handle, x: cx - 4, y: cy - 4, width: 8, height: 8 + }))); + } + renderPorts(node); + } + + function renderPorts(node) { + ["top", "right", "bottom", "left"].forEach((port) => { + const point = portPoint(node, port); + refs.overlayLayer.append(svgEl("circle", { class: "port", "data-port-node": node.id, "data-port": port, cx: point.x, cy: point.y, r: 5 })); + }); + } + + function renderEdgeOverlay(edge) { + const from = portPoint(state.nodes.find((node) => node.id === edge.fromNodeId), edge.fromPort); + const to = portPoint(state.nodes.find((node) => node.id === edge.toNodeId), edge.toPort); + refs.overlayLayer.append(svgEl("circle", { class: "endpoint-handle", "data-edge-end": "from", "data-edge-id": edge.id, cx: from.x, cy: from.y, r: 5 })); + refs.overlayLayer.append(svgEl("circle", { class: "endpoint-handle", "data-edge-end": "to", "data-edge-id": edge.id, cx: to.x, cy: to.y, r: 5 })); + } + + function createShapePalette() { + SHAPES.forEach(([type, label]) => { + const item = document.createElement("div"); + item.className = "shape-item"; + item.tabIndex = 0; + item.draggable = true; + item.dataset.shape = type; + item.title = `Add ${label}`; + item.setAttribute("role", "button"); + const preview = svgEl("svg", { viewBox: "0 0 70 46", "aria-hidden": "true" }); + const node = { type, width: 64, height: 40, style: { ...DEFAULT_NODE_STYLE, strokeWidth: 1.2 } }; + const group = svgEl("g", { transform: "translate(3 3)" }); + geometryElements(node, true).forEach((element) => group.append(element)); + preview.append(group); + const text = document.createElement("span"); + text.textContent = label; + item.append(preview, text); + refs.shapeList.append(item); + }); + } + + function addNode(type, point) { + const [width, height] = DEFAULT_SIZES[type] || DEFAULT_SIZES.process; + const node = createNode(type, clamp(point.x - width / 2, CANVAS.padding, CANVAS.width - width - CANVAS.padding), clamp(point.y - height / 2, CANVAS.padding, CANVAS.height - height - CANVAS.padding)); + if (state.snap) { node.x = snap(node.x); node.y = snap(node.y); } + state.nodes.push(node); + state.selectedIds = [node.id]; + commit(`Added ${SHAPES.find(([key]) => key === type)?.[1] || "shape"}`); + render(); + return node; + } + + function visibleCenter() { + const rect = refs.svg.getBoundingClientRect(); + return screenToWorld(rect.left + rect.width / 2, rect.top + rect.height / 2); + } + + function screenToWorld(clientX, clientY) { + const rect = refs.svg.getBoundingClientRect(); + return { x: (clientX - rect.left - state.pan.x) / state.zoom, y: (clientY - rect.top - state.pan.y) / state.zoom }; + } + + function worldToScreen(x, y) { + const rect = refs.svg.getBoundingClientRect(); + return { x: rect.left + state.pan.x + x * state.zoom, y: rect.top + state.pan.y + y * state.zoom }; + } + + function normalizedRect(a, b) { + return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), width: Math.abs(b.x - a.x), height: Math.abs(b.y - a.y) }; + } + + function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } + function snap(value) { return Math.round(value / GRID_SIZE) * GRID_SIZE; } + + function setMode(mode) { + state.mode = mode; + document.querySelectorAll("[data-mode]").forEach((button) => button.classList.toggle("active", button.dataset.mode === mode)); + refs.shell.dataset.mode = mode; + setStatus(mode === "connect" ? "Drag between connection points" : mode === "text" ? "Click to add text" : "Select tool"); + render(); + } + + function selectNode(nodeId, additive = false) { + const node = state.nodes.find((item) => item.id === nodeId); + let ids = [nodeId]; + if (node?.groupId && !additive) ids = state.nodes.filter((item) => item.groupId === node.groupId).map((item) => item.id); + if (additive) { + state.selectedIds = state.selectedIds.includes(nodeId) ? state.selectedIds.filter((id) => id !== nodeId) : [...state.selectedIds, ...ids.filter((id) => !state.selectedIds.includes(id))]; + } else if (!state.selectedIds.includes(nodeId)) state.selectedIds = ids; + } + + function onPointerDown(event) { + if (event.button !== 0 && event.button !== 1) return; + refs.shell.focus({ preventScroll: true }); + const world = screenToWorld(event.clientX, event.clientY); + const resize = event.target.closest?.("[data-resize-id]"); + const port = event.target.closest?.("[data-port-node]"); + const endpoint = event.target.closest?.("[data-edge-end]"); + const nodeElement = event.target.closest?.("[data-node-id]"); + const edgeElement = event.target.closest?.("[data-edge-id]"); + + if (spacePressed || event.button === 1) { + interaction = { type: "pan", startClient: { x: event.clientX, y: event.clientY }, initialPan: { ...state.pan } }; + refs.shell.classList.add("panning"); + } else if (resize) { + const node = state.nodes.find((item) => item.id === resize.dataset.resizeId); + interaction = { type: "resize", nodeId: node.id, handle: resize.dataset.handle, startWorld: world, initial: clone(node) }; + } else if (endpoint) { + interaction = { type: "reconnect", edgeId: endpoint.dataset.edgeId, end: endpoint.dataset.edgeEnd, currentWorld: world }; + } else if (port && (state.mode === "connect" || state.selectedIds.includes(port.dataset.portNode))) { + interaction = { type: "connect", nodeId: port.dataset.portNode, port: port.dataset.port, startWorld: world, currentWorld: world }; + } else if (nodeElement) { + const nodeId = nodeElement.dataset.nodeId; + selectNode(nodeId, event.shiftKey); + const initial = new Map(state.nodes.filter((node) => state.selectedIds.includes(node.id)).map((node) => [node.id, { x: node.x, y: node.y }])); + interaction = { type: "move", startWorld: world, initial, changed: false }; + render(); + } else if (edgeElement) { + const edgeId = edgeElement.dataset.edgeId; + state.selectedIds = event.shiftKey ? [...new Set([...state.selectedIds, edgeId])] : [edgeId]; + render(); + } else if (state.mode === "text") { + const node = addNode("text", world); + beginTextEditing("node", node.id); + } else { + if (!event.shiftKey) state.selectedIds = []; + interaction = { type: "marquee", startWorld: world, currentWorld: world, additive: event.shiftKey }; + render(); + } + if (interaction) refs.svg.setPointerCapture(event.pointerId); + event.preventDefault(); + } + + function onPointerMove(event) { + if (!interaction) return; + const world = screenToWorld(event.clientX, event.clientY); + if (interaction.type === "pan") { + state.pan.x = interaction.initialPan.x + event.clientX - interaction.startClient.x; + state.pan.y = interaction.initialPan.y + event.clientY - interaction.startClient.y; + applyViewport(); + return; + } + if (interaction.type === "move") { + let dx = world.x - interaction.startWorld.x; + let dy = world.y - interaction.startWorld.y; + interaction.initial.forEach((position, id) => { + const node = state.nodes.find((item) => item.id === id); + if (!node) return; + let x = position.x + dx; + let y = position.y + dy; + if (state.snap) { x = snap(x); y = snap(y); } + node.x = clamp(x, CANVAS.padding, CANVAS.width - node.width - CANVAS.padding); + node.y = clamp(y, CANVAS.padding, CANVAS.height - node.height - CANVAS.padding); + }); + interaction.changed = Math.abs(dx) > .5 || Math.abs(dy) > .5; + render(); + return; + } + if (interaction.type === "resize") { + resizeNode(interaction, world); + render(); + return; + } + interaction.currentWorld = world; + renderOverlaysOnly(); + } + + function resizeNode(active, world) { + const node = state.nodes.find((item) => item.id === active.nodeId); + if (!node) return; + const minWidth = node.type === "text" ? 60 : 48; + const minHeight = node.type === "text" ? 28 : 36; + const dx = world.x - active.startWorld.x; + const dy = world.y - active.startWorld.y; + let { x, y, width, height } = active.initial; + if (active.handle.includes("e")) width = Math.max(minWidth, active.initial.width + dx); + if (active.handle.includes("s")) height = Math.max(minHeight, active.initial.height + dy); + if (active.handle.includes("w")) { width = Math.max(minWidth, active.initial.width - dx); x = active.initial.x + active.initial.width - width; } + if (active.handle.includes("n")) { height = Math.max(minHeight, active.initial.height - dy); y = active.initial.y + active.initial.height - height; } + if (state.snap) { x = snap(x); y = snap(y); width = Math.max(minWidth, snap(width)); height = Math.max(minHeight, snap(height)); } + node.x = clamp(x, CANVAS.padding, CANVAS.width - minWidth - CANVAS.padding); + node.y = clamp(y, CANVAS.padding, CANVAS.height - minHeight - CANVAS.padding); + node.width = Math.min(width, CANVAS.width - node.x - CANVAS.padding); + node.height = Math.min(height, CANVAS.height - node.y - CANVAS.padding); + } + + function onPointerUp(event) { + if (!interaction) return; + const active = interaction; + const world = screenToWorld(event.clientX, event.clientY); + interaction = null; + refs.shell.classList.remove("panning"); + if (active.type === "move" && active.changed) commit("Moved selection"); + else if (active.type === "resize") commit("Resized shape"); + else if (active.type === "marquee") completeMarquee(active, world); + else if (active.type === "connect") completeConnection(active, world); + else if (active.type === "reconnect") completeReconnect(active, world); + render(); + } + + function completeMarquee(active, world) { + const box = normalizedRect(active.startWorld, world); + const hits = state.nodes.filter((node) => node.x >= box.x && node.y >= box.y && node.x + node.width <= box.x + box.width && node.y + node.height <= box.y + box.height).map((node) => node.id); + state.selectedIds = active.additive ? [...new Set([...state.selectedIds, ...hits])] : hits; + } + + function nearestPort(world, excludeNodeId = null) { + let best = null; + state.nodes.forEach((node) => { + if (node.id === excludeNodeId) return; + ["top", "right", "bottom", "left"].forEach((port) => { + const point = portPoint(node, port); + const distance = Math.hypot(point.x - world.x, point.y - world.y); + if (distance <= 34 / state.zoom && (!best || distance < best.distance)) best = { nodeId: node.id, port, distance }; + }); + }); + return best; + } + + function completeConnection(active, world) { + const target = nearestPort(world, active.nodeId); + if (!target) { setStatus("Connector cancelled"); return; } + const edge = { + id: uid("edge"), fromNodeId: active.nodeId, fromPort: active.port, + toNodeId: target.nodeId, toPort: target.port, type: "straight", label: "", style: clone(DEFAULT_EDGE_STYLE) + }; + state.edges.push(edge); + state.selectedIds = [edge.id]; + commit("Created connector"); + } + + function completeReconnect(active, world) { + const edge = state.edges.find((item) => item.id === active.edgeId); + if (!edge) return; + const exclude = active.end === "from" ? edge.toNodeId : edge.fromNodeId; + const target = nearestPort(world, exclude); + if (!target) { setStatus("Endpoint unchanged"); return; } + if (active.end === "from") { edge.fromNodeId = target.nodeId; edge.fromPort = target.port; } + else { edge.toNodeId = target.nodeId; edge.toPort = target.port; } + commit("Reconnected endpoint"); + } + + function renderOverlaysOnly() { + refs.overlayLayer.replaceChildren(); + renderOverlays(); + } + + function applyViewport() { + refs.viewport.setAttribute("transform", `translate(${state.pan.x} ${state.pan.y}) scale(${state.zoom})`); + refs.zoom.textContent = `${Math.round(state.zoom * 100)}%`; + } + + function zoomAt(factor, clientX, clientY) { + const rect = refs.svg.getBoundingClientRect(); + const cx = clientX ?? rect.left + rect.width / 2; + const cy = clientY ?? rect.top + rect.height / 2; + const world = screenToWorld(cx, cy); + const next = clamp(state.zoom * factor, .2, 3); + state.zoom = Math.round(next * 100) / 100; + state.pan.x = cx - rect.left - world.x * state.zoom; + state.pan.y = cy - rect.top - world.y * state.zoom; + render(); + } + + function fitToScreen() { + const rect = refs.svg.getBoundingClientRect(); + const bounds = diagramBounds(); + const pad = 70; + state.zoom = clamp(Math.min((rect.width - pad * 2) / bounds.width, (rect.height - pad * 2) / bounds.height), .2, 1.5); + state.pan.x = (rect.width - bounds.width * state.zoom) / 2 - bounds.x * state.zoom; + state.pan.y = (rect.height - bounds.height * state.zoom) / 2 - bounds.y * state.zoom; + render(); + setStatus("Fit diagram to screen"); + } + + function diagramBounds() { + if (!state.nodes.length) return { x: 0, y: 0, width: CANVAS.width, height: CANVAS.height }; + const minX = Math.min(...state.nodes.map((node) => node.x)); + const minY = Math.min(...state.nodes.map((node) => node.y)); + const maxX = Math.max(...state.nodes.map((node) => node.x + node.width)); + const maxY = Math.max(...state.nodes.map((node) => node.y + node.height)); + return { x: minX, y: minY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) }; + } + + function beginTextEditing(kind, id) { + finishTextEditing(true); + const item = kind === "node" ? state.nodes.find((node) => node.id === id) : state.edges.find((edge) => edge.id === id); + if (!item) return; + editing = { kind, id, original: item.text ?? item.label ?? "", cancelled: false }; + let box; + let style = DEFAULT_NODE_STYLE; + if (kind === "node") { + const topLeft = worldToScreen(item.x, item.y); + box = { left: topLeft.x, top: topLeft.y, width: item.width * state.zoom, height: item.height * state.zoom }; + style = item.style || style; + } else { + const point = edgeLabelPoint(item); + const screen = worldToScreen(point.x, point.y); + box = { left: screen.x - 90, top: screen.y - 22, width: 180, height: 44 }; + } + Object.assign(refs.editor.style, { + display: "block", left: `${box.left}px`, top: `${box.top}px`, width: `${Math.max(70, box.width)}px`, + height: `${Math.max(34, box.height)}px`, fontSize: `${Math.max(11, (style.fontSize || 14) * state.zoom)}px`, + fontWeight: style.fontWeight, fontStyle: style.italic ? "italic" : "normal", textAlign: style.textAlign || "center" + }); + refs.editor.value = kind === "node" ? item.text : item.label; + refs.editor.focus(); + refs.editor.select(); + } + + function finishTextEditing(save = true) { + if (!editing) return; + const active = editing; + editing = null; + refs.editor.style.display = "none"; + const item = active.kind === "node" ? state.nodes.find((node) => node.id === active.id) : state.edges.find((edge) => edge.id === active.id); + if (!item) return; + const value = active.cancelled || !save ? active.original : refs.editor.value; + if (active.kind === "node") item.text = value; + else item.label = value; + if (save && !active.cancelled && value !== active.original) commit("Edited text"); + render(); + } + + function selectedNodes() { return state.nodes.filter((node) => state.selectedIds.includes(node.id)); } + function selectedEdges() { return state.edges.filter((edge) => state.selectedIds.includes(edge.id)); } + + function deleteSelection() { + if (!state.selectedIds.length) return; + const nodeIds = new Set(selectedNodes().map((node) => node.id)); + state.nodes = state.nodes.filter((node) => !nodeIds.has(node.id)); + state.edges = state.edges.filter((edge) => !state.selectedIds.includes(edge.id) && !nodeIds.has(edge.fromNodeId) && !nodeIds.has(edge.toNodeId)); + state.selectedIds = []; + commit("Deleted selection"); + render(); + } + + function copySelection() { + const nodes = selectedNodes(); + if (!nodes.length) return; + const ids = new Set(nodes.map((node) => node.id)); + clipboard = { nodes: clone(nodes), edges: clone(state.edges.filter((edge) => ids.has(edge.fromNodeId) && ids.has(edge.toNodeId))) }; + setStatus(`Copied ${nodes.length} object${nodes.length === 1 ? "" : "s"}`); + } + + function pasteSelection() { + if (!clipboard?.nodes?.length) return; + const idMap = new Map(); + const groupMap = new Map(); + const nodes = clipboard.nodes.map((source) => { + const node = clone(source); + idMap.set(node.id, uid("node")); + node.id = idMap.get(node.id); + node.x = clamp(node.x + 30, CANVAS.padding, CANVAS.width - node.width - CANVAS.padding); + node.y = clamp(node.y + 30, CANVAS.padding, CANVAS.height - node.height - CANVAS.padding); + if (node.groupId) { + if (!groupMap.has(node.groupId)) groupMap.set(node.groupId, uid("group")); + node.groupId = groupMap.get(node.groupId); + } + node.zIndex = state.nodes.length + 1; + return node; + }); + const edges = clipboard.edges.map((source) => ({ ...clone(source), id: uid("edge"), fromNodeId: idMap.get(source.fromNodeId), toNodeId: idMap.get(source.toNodeId) })); + state.nodes.push(...nodes); + state.edges.push(...edges); + state.selectedIds = nodes.map((node) => node.id); + clipboard = { nodes: clone(nodes), edges: clone(edges) }; + commit("Pasted selection"); + render(); + } + + function duplicateSelection() { copySelection(); pasteSelection(); } + + function moveSelection(dx, dy) { + const nodes = selectedNodes(); + if (!nodes.length) return; + nodes.forEach((node) => { + node.x = clamp(node.x + dx, CANVAS.padding, CANVAS.width - node.width - CANVAS.padding); + node.y = clamp(node.y + dy, CANVAS.padding, CANVAS.height - node.height - CANVAS.padding); + }); + nudgePending = true; + render(); + } + + function clearCanvas() { + if (!state.nodes.length && !state.edges.length) return; + state.nodes = []; + state.edges = []; + state.selectedIds = []; + commit("Cleared canvas — undo is available"); + render(); + } + + function renderProperties() { + const nodes = selectedNodes(); + const edges = selectedEdges(); + refs.properties.hidden = !state.selectedIds.length; + if (!state.selectedIds.length) return; + if (nodes.length > 1 && !edges.length) return renderMultiProperties(nodes); + if (nodes.length === 1 && !edges.length) return renderNodeProperties(nodes[0]); + if (edges.length === 1 && !nodes.length) return renderEdgeProperties(edges[0]); + refs.propertiesTitle.textContent = "Selection"; + refs.propertiesContent.innerHTML = `
Mixed objects selected
`; + } + + function option(value, label, current) { return ``; } + + function renderNodeProperties(node) { + const style = node.style; + refs.propertiesTitle.textContent = node.type === "text" ? "Text" : "Shape"; + refs.propertiesContent.innerHTML = ` + ${node.type === "text" ? "" : `
Appearance
+
+
+
+
+
+ ${["process", "terminator"].includes(node.type) ? `
` : ""} +
`} +
Text
+
+
+
+
+
+
Position & size
+
+
+
Layer
+
`; + } + + function renderEdgeProperties(edge) { + const style = edge.style; + refs.propertiesTitle.textContent = "Connector"; + refs.propertiesContent.innerHTML = ` +
Line
+
+
+
+
+
+
+
+
Label
+
`; + } + + function arrowOptions(current) { + return [["none", "None"], ["arrow", "Arrow"], ["circle", "Circle"], ["diamond", "Diamond"]].map(([value, label]) => option(value, label, current)).join(""); + } + + function renderMultiProperties(nodes) { + refs.propertiesTitle.textContent = `${nodes.length} Shapes`; + refs.propertiesContent.innerHTML = ` +
Align
+
Distribute
+
Group
+
`; + } + + function escapeHtml(value) { + return String(value || "").replace(/[&<>"]/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[character])); + } + + function handlePropertyChange(event) { + const node = selectedNodes()[0]; + const edge = selectedEdges()[0]; + if (event.target.dataset.prop && node) { + const key = event.target.dataset.prop; + node.style[key] = event.target.type === "number" ? Number(event.target.value) : event.target.value; + } else if (event.target.dataset.nodeProp && node) { + const key = event.target.dataset.nodeProp; + let value = Number(event.target.value); + if (["width", "height"].includes(key)) value = Math.max(key === "width" ? 40 : 28, value); + node[key] = value; + node.x = clamp(node.x, CANVAS.padding, CANVAS.width - node.width - CANVAS.padding); + node.y = clamp(node.y, CANVAS.padding, CANVAS.height - node.height - CANVAS.padding); + } else if (event.target.dataset.edgeProp && edge) { + edge[event.target.dataset.edgeProp] = event.target.value; + } else if (event.target.dataset.edgeStyle && edge) { + const key = event.target.dataset.edgeStyle; + edge.style[key] = event.target.type === "number" ? Number(event.target.value) : event.target.value; + } else return; + commit("Changed properties"); + render(); + } + + function handlePropertiesClick(event) { + const propButton = event.target.closest("[data-prop-button]"); + const arrange = event.target.closest("[data-arrange]"); + const action = event.target.closest("[data-panel-action]"); + if (propButton) { + const node = selectedNodes()[0]; + if (!node) return; + const key = propButton.dataset.propButton; + node.style[key] = propButton.dataset.value === "true" ? true : propButton.dataset.value === "false" ? false : propButton.dataset.value; + commit("Changed text style"); render(); + } else if (arrange) arrangeSelection(arrange.dataset.arrange); + else if (action) handlePanelAction(action.dataset.panelAction); + } + + function arrangeSelection(kind) { + const nodes = selectedNodes(); + if (nodes.length < 2) return; + const minX = Math.min(...nodes.map((node) => node.x)); + const maxX = Math.max(...nodes.map((node) => node.x + node.width)); + const minY = Math.min(...nodes.map((node) => node.y)); + const maxY = Math.max(...nodes.map((node) => node.y + node.height)); + if (kind === "left") nodes.forEach((node) => { node.x = minX; }); + if (kind === "center") nodes.forEach((node) => { node.x = (minX + maxX - node.width) / 2; }); + if (kind === "right") nodes.forEach((node) => { node.x = maxX - node.width; }); + if (kind === "top") nodes.forEach((node) => { node.y = minY; }); + if (kind === "middle") nodes.forEach((node) => { node.y = (minY + maxY - node.height) / 2; }); + if (kind === "bottom") nodes.forEach((node) => { node.y = maxY - node.height; }); + if (kind === "horizontal") distribute(nodes, "x", "width"); + if (kind === "vertical") distribute(nodes, "y", "height"); + commit(`${kind[0].toUpperCase()}${kind.slice(1)} arrangement`); + render(); + } + + function distribute(nodes, axis, sizeKey) { + if (nodes.length < 3) return; + const sorted = [...nodes].sort((a, b) => a[axis] - b[axis]); + const first = sorted[0][axis]; + const lastEnd = sorted.at(-1)[axis] + sorted.at(-1)[sizeKey]; + const totalSize = sorted.reduce((sum, node) => sum + node[sizeKey], 0); + const gap = (lastEnd - first - totalSize) / (sorted.length - 1); + let cursor = first; + sorted.forEach((node) => { node[axis] = cursor; cursor += node[sizeKey] + gap; }); + } + + function handlePanelAction(action) { + if (action === "delete") return deleteSelection(); + if (action === "duplicate") return duplicateSelection(); + if (action === "group") { + const groupId = uid("group"); selectedNodes().forEach((node) => { node.groupId = groupId; }); commit("Grouped selection"); render(); return; + } + if (action === "ungroup") { selectedNodes().forEach((node) => { node.groupId = null; }); commit("Ungrouped selection"); render(); return; } + changeLayer(action); + } + + function changeLayer(action) { + const node = selectedNodes()[0]; + if (!node) return; + const sorted = [...state.nodes].sort((a, b) => a.zIndex - b.zIndex); + const index = sorted.findIndex((item) => item.id === node.id); + if (action === "front") sorted.splice(index, 1), sorted.push(node); + if (action === "back") sorted.splice(index, 1), sorted.unshift(node); + if (action === "forward" && index < sorted.length - 1) [sorted[index], sorted[index + 1]] = [sorted[index + 1], sorted[index]]; + if (action === "backward" && index > 0) [sorted[index], sorted[index - 1]] = [sorted[index - 1], sorted[index]]; + sorted.forEach((item, position) => { item.zIndex = position; }); + commit("Changed layer order"); render(); + } + + function projectData() { + return { + version: 1, documentTitle: state.documentTitle, canvas: clone(CANVAS), nodes: clone(state.nodes), + edges: clone(state.edges), zoom: state.zoom, pan: clone(state.pan), snap: state.snap + }; + } + + function normalizeProject(data) { + if (!data || !Array.isArray(data.nodes) || !Array.isArray(data.edges)) throw new Error("Invalid project file"); + return { + documentTitle: String(data.documentTitle || "Untitled Flowchart"), + nodes: data.nodes.map((node, index) => ({ ...createNode(node.type || "process", 0, 0, ""), ...node, style: { ...DEFAULT_NODE_STYLE, ...(node.style || {}) }, zIndex: Number(node.zIndex ?? index) })), + edges: data.edges.map((edge) => ({ ...edge, id: edge.id || uid("edge"), type: edge.type || "straight", label: edge.label || "", style: { ...DEFAULT_EDGE_STYLE, ...(edge.style || {}) } })), + zoom: clamp(Number(data.zoom) || 1, .2, 3), pan: { x: Number(data.pan?.x) || 0, y: Number(data.pan?.y) || 0 }, snap: data.snap !== false + }; + } + + function loadProject(data, message = "Loaded project") { + const normalized = normalizeProject(data); + state = { ...state, ...normalized, selectedIds: [], history: [], historyIndex: -1, mode: "select" }; + state.history = [snapshot()]; state.historyIndex = 0; + refs.title.value = state.documentTitle; refs.snap.checked = state.snap; + render(); setStatus(message); + } + + function saveLocal() { + localStorage.setItem(STORAGE_KEY, JSON.stringify(projectData())); + setStatus("Saved in this browser"); + } + + function loadLocal() { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) { setStatus("No saved project in this browser"); return; } + try { loadProject(JSON.parse(raw), "Loaded saved project"); } catch { setStatus("The saved project could not be loaded"); } + } + + function safeFileName(extension) { + const name = state.documentTitle.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "flowchart"; + return `${name}.${extension}`; + } + + function downloadBlob(blob, fileName) { + const link = document.createElement("a"); + const url = URL.createObjectURL(blob); + link.href = url; link.download = fileName; link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } + + function downloadJson() { + downloadBlob(new Blob([JSON.stringify(projectData(), null, 2)], { type: "application/json" }), safeFileName("json")); + setStatus("Downloaded project JSON"); + } + + function exportSvgString() { + const bounds = diagramBounds(); + const padding = 32; + const root = svgEl("svg", { + xmlns: SVG_NS, viewBox: `${bounds.x - padding} ${bounds.y - padding} ${bounds.width + padding * 2} ${bounds.height + padding * 2}`, + width: Math.ceil(bounds.width + padding * 2), height: Math.ceil(bounds.height + padding * 2) + }); + const defs = svgEl("defs"); + defs.innerHTML = ``; + root.append(defs); + const edgeGroup = svgEl("g"); + state.edges.forEach((edge) => renderEdgeInto(edge, edgeGroup)); + edgeGroup.querySelectorAll(".edge-hit").forEach((hit) => hit.remove()); + root.append(edgeGroup); + const nodeGroup = svgEl("g"); + [...state.nodes].sort((a, b) => a.zIndex - b.zIndex).forEach((node) => renderNodeInto(node, nodeGroup)); + root.append(nodeGroup); + return `\n${new XMLSerializer().serializeToString(root)}`; + } + + function exportSvg() { + if (!state.nodes.length) { setStatus("Add an object before exporting"); return; } + downloadBlob(new Blob([exportSvgString()], { type: "image/svg+xml;charset=utf-8" }), safeFileName("svg")); + setStatus("Exported SVG"); + } + + function exportPng() { + if (!state.nodes.length) { setStatus("Add an object before exporting"); return; } + const svg = exportSvgString(); + const image = new Image(); + const url = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" })); + image.onload = () => { + const scale = Math.min(3, 2400 / Math.max(image.width, 1)); + const canvas = document.createElement("canvas"); + canvas.width = Math.ceil(image.width * scale); canvas.height = Math.ceil(image.height * scale); + const context = canvas.getContext("2d"); + context.fillStyle = "#ffffff"; context.fillRect(0, 0, canvas.width, canvas.height); + context.drawImage(image, 0, 0, canvas.width, canvas.height); + canvas.toBlob((blob) => { if (blob) downloadBlob(blob, safeFileName("png")); }, "image/png"); + URL.revokeObjectURL(url); setStatus("Exported PNG"); + }; + image.onerror = () => { URL.revokeObjectURL(url); setStatus("PNG export failed"); }; + image.src = url; + } + + function handleToolbar(action) { + const actions = { + undo, redo, delete: deleteSelection, duplicate: duplicateSelection, clear: clearCanvas, + "zoom-out": () => zoomAt(1 / 1.15), "zoom-in": () => zoomAt(1.15), "zoom-reset": () => { state.zoom = 1; render(); }, + fit: fitToScreen, save: saveLocal, load: loadLocal, "download-json": downloadJson, + "import-json": () => refs.fileInput.click(), "export-svg": exportSvg, "export-png": exportPng + }; + actions[action]?.(); + } + + function updateToolbar() { + const disabled = { + undo: state.historyIndex <= 0, redo: state.historyIndex >= state.history.length - 1, + delete: !state.selectedIds.length, duplicate: !selectedNodes().length + }; + Object.entries(disabled).forEach(([action, value]) => { + const button = document.querySelector(`[data-action="${action}"]`); + if (button) button.disabled = value; + }); + } + + function setStatus(message) { refs.status.textContent = message; } + + function onKeyDown(event) { + const typing = /INPUT|TEXTAREA|SELECT/.test(event.target.tagName); + if (event.code === "Space" && !typing) { spacePressed = true; event.preventDefault(); } + if (typing) return; + const command = event.ctrlKey || event.metaKey; + if (command && event.key.toLowerCase() === "z") { event.preventDefault(); event.shiftKey ? redo() : undo(); } + else if (command && event.key.toLowerCase() === "y") { event.preventDefault(); redo(); } + else if (command && event.key.toLowerCase() === "d") { event.preventDefault(); duplicateSelection(); } + else if (command && event.key.toLowerCase() === "c") { event.preventDefault(); copySelection(); } + else if (command && event.key.toLowerCase() === "v") { event.preventDefault(); pasteSelection(); } + else if (command && event.key.toLowerCase() === "s") { event.preventDefault(); saveLocal(); } + else if (["Delete", "Backspace"].includes(event.key)) { event.preventDefault(); deleteSelection(); } + else if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) { + event.preventDefault(); + const distance = event.shiftKey ? 10 : 1; + moveSelection(event.key === "ArrowLeft" ? -distance : event.key === "ArrowRight" ? distance : 0, event.key === "ArrowUp" ? -distance : event.key === "ArrowDown" ? distance : 0); + } else if (!command && event.key.toLowerCase() === "v") setMode("select"); + else if (!command && event.key.toLowerCase() === "c") setMode("connect"); + else if (!command && event.key.toLowerCase() === "t") setMode("text"); + } + + function onKeyUp(event) { + if (event.code === "Space") spacePressed = false; + if (nudgePending && event.key.startsWith("Arrow")) { nudgePending = false; commit("Moved selection"); } + } + + function bindEvents() { + document.querySelector(".toolbar").addEventListener("click", (event) => { + const mode = event.target.closest("[data-mode]"); + const action = event.target.closest("[data-action]"); + if (mode) setMode(mode.dataset.mode); + if (action) handleToolbar(action.dataset.action); + }); + refs.shapeList.addEventListener("click", (event) => { + const item = event.target.closest("[data-shape]"); + if (item) addNode(item.dataset.shape, visibleCenter()); + }); + refs.shapeList.addEventListener("keydown", (event) => { + if (["Enter", " "].includes(event.key) && event.target.dataset.shape) { event.preventDefault(); addNode(event.target.dataset.shape, visibleCenter()); } + }); + refs.shapeList.addEventListener("dragstart", (event) => { + const item = event.target.closest("[data-shape]"); + if (item) event.dataTransfer.setData("text/x-flowchart-shape", item.dataset.shape); + }); + refs.svg.addEventListener("dragover", (event) => { if (event.dataTransfer.types.includes("text/x-flowchart-shape")) event.preventDefault(); }); + refs.svg.addEventListener("drop", (event) => { const type = event.dataTransfer.getData("text/x-flowchart-shape"); if (type) { event.preventDefault(); addNode(type, screenToWorld(event.clientX, event.clientY)); } }); + refs.svg.addEventListener("pointerdown", onPointerDown); + refs.svg.addEventListener("pointermove", onPointerMove); + refs.svg.addEventListener("pointerup", onPointerUp); + refs.svg.addEventListener("pointercancel", onPointerUp); + refs.svg.addEventListener("dblclick", (event) => { + const node = event.target.closest?.("[data-node-id]"); + const edgeText = event.target.closest?.("[data-text-edge]"); + if (node) beginTextEditing("node", node.dataset.nodeId); + else if (edgeText) beginTextEditing("edge", edgeText.dataset.textEdge); + else if (event.target.closest?.(".workspace-bg, .grid")) { + const textNode = addNode("text", screenToWorld(event.clientX, event.clientY)); beginTextEditing("node", textNode.id); + } + event.preventDefault(); + }); + refs.svg.addEventListener("wheel", (event) => { + if (!event.ctrlKey && !event.metaKey) return; + event.preventDefault(); zoomAt(event.deltaY < 0 ? 1.1 : 1 / 1.1, event.clientX, event.clientY); + }, { passive: false }); + refs.editor.addEventListener("keydown", (event) => { + if (event.key === "Escape") { event.preventDefault(); editing.cancelled = true; finishTextEditing(false); } + if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { event.preventDefault(); finishTextEditing(true); } + }); + refs.editor.addEventListener("blur", () => finishTextEditing(true)); + refs.propertiesContent.addEventListener("change", handlePropertyChange); + refs.propertiesContent.addEventListener("click", handlePropertiesClick); + refs.title.addEventListener("change", () => { state.documentTitle = refs.title.value.trim() || "Untitled Flowchart"; refs.title.value = state.documentTitle; commit("Renamed document"); }); + refs.snap.addEventListener("change", () => { state.snap = refs.snap.checked; commit(state.snap ? "Snap enabled" : "Snap disabled"); render(); }); + refs.fileInput.addEventListener("change", async () => { + const file = refs.fileInput.files[0]; refs.fileInput.value = ""; + if (!file) return; + try { loadProject(JSON.parse(await file.text()), "Imported project"); } catch { setStatus("That file is not a valid project"); } + }); + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + window.addEventListener("blur", () => { spacePressed = false; }); + new ResizeObserver(() => { + const rect = refs.svg.getBoundingClientRect(); + refs.svg.setAttribute("viewBox", `0 0 ${Math.max(1, rect.width)} ${Math.max(1, rect.height)}`); + }).observe(refs.shell); + } + + createShapePalette(); + bindEvents(); + refs.title.value = state.documentTitle; + refs.snap.checked = state.snap; + setMode("select"); + requestAnimationFrame(fitToScreen); +})(); From df13ea53a29eb999b85fc1ea51fbc0fa323ef8ce Mon Sep 17 00:00:00 2001 From: devkyato Date: Mon, 20 Jul 2026 08:42:09 +0800 Subject: [PATCH 03/10] fix: restore editor startup rendering --- script.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/script.js b/script.js index 6924e9a..9ac5205 100644 --- a/script.js +++ b/script.js @@ -84,7 +84,7 @@ const [width, height] = DEFAULT_SIZES[type] || DEFAULT_SIZES.process; return { id: uid("node"), type, x, y, width, height, rotation: 0, text, - style: clone(DEFAULT_NODE_STYLE), zIndex: state?.nodes?.length || 0, groupId: null + style: clone(DEFAULT_NODE_STYLE), zIndex: 0, groupId: null }; } @@ -94,12 +94,11 @@ nodes: [], edges: [], selectedIds: [], zoom: 0.82, pan: { x: 160, y: 38 }, mode: "select", snap: true, history: [], historyIndex: -1 }; - state = next; const labels = [ ["terminator", "START"], - ["input", "Display message:\n“How many hours did you work?”"], + ["input", "Display message:\n\"How many hours did you work?\""], ["input", "Read Hours"], - ["input", "Display message:\n“How much do you get paid per hour?”"], + ["input", "Display message:\n\"How much do you get paid per hour?\""], ["input", "Read Pay Rate"], ["process", "Multiply Hours by Pay Rate\nStore result in Gross Pay"], ["input", "Display Gross Pay"], From 68f8b7df631c7f2318749b6367e3c54921988099 Mon Sep 17 00:00:00 2001 From: devkyato Date: Mon, 20 Jul 2026 08:45:35 +0800 Subject: [PATCH 04/10] chore: add zero-dependency project tooling --- .gitignore | 5 +++++ package-lock.json | 16 ++++++++++++++++ package.json | 22 ++++++++++++++++++++++ scripts/serve.mjs | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+) create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/serve.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c1f30a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.tmp/ +node_modules/ +*.log +.DS_Store +Thumbs.db diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6fd3207 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,16 @@ +{ + "name": "flowchart-creator", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "flowchart-creator", + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=22" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..23a527e --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "flowchart-creator", + "version": "1.0.0", + "private": true, + "description": "A compact, dependency-free SVG flowchart editor.", + "type": "module", + "scripts": { + "start": "node scripts/serve.mjs", + "check": "node --check script.js", + "test": "node --check script.js && node tests/browser-smoke.mjs" + }, + "engines": { + "node": ">=22" + }, + "keywords": [ + "flowchart", + "diagram", + "svg", + "vanilla-javascript" + ], + "license": "MIT" +} diff --git a/scripts/serve.mjs b/scripts/serve.mjs new file mode 100644 index 0000000..7cb8562 --- /dev/null +++ b/scripts/serve.mjs @@ -0,0 +1,33 @@ +import { createReadStream } from "node:fs"; +import { stat } from "node:fs/promises"; +import { createServer } from "node:http"; +import { dirname, extname, join, normalize } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const port = Number(process.env.PORT) || 4173; +const types = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png" }; + +const server = createServer(async (request, response) => { + const pathname = decodeURIComponent(new URL(request.url, "http://localhost").pathname); + const relative = pathname === "/" ? "index.html" : pathname.slice(1); + const filePath = normalize(join(root, relative)); + + if (!filePath.startsWith(root)) { + response.writeHead(403).end("Forbidden"); + return; + } + + try { + const details = await stat(filePath); + if (!details.isFile()) throw new Error("Not a file"); + response.writeHead(200, { "Content-Type": `${types[extname(filePath)] || "application/octet-stream"}; charset=utf-8`, "Cache-Control": "no-store" }); + createReadStream(filePath).pipe(response); + } catch { + response.writeHead(404).end("Not found"); + } +}); + +server.listen(port, "127.0.0.1", () => { + console.log(`Flowchart Creator: http://127.0.0.1:${port}`); +}); From 2a57838b1737164ea561bf39a8dbd88008efb504 Mon Sep 17 00:00:00 2001 From: devkyato Date: Mon, 20 Jul 2026 08:45:58 +0800 Subject: [PATCH 05/10] test: cover critical editor workflows in browser --- index.html | 1 + tests/browser-smoke.mjs | 180 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 tests/browser-smoke.mjs diff --git a/index.html b/index.html index 382fc72..d334a45 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,7 @@ Flowchart Creator + diff --git a/tests/browser-smoke.mjs b/tests/browser-smoke.mjs new file mode 100644 index 0000000..bfd09e4 --- /dev/null +++ b/tests/browser-smoke.mjs @@ -0,0 +1,180 @@ +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const browserCandidates = process.platform === "win32" + ? [ + process.env.PROGRAMFILES && join(process.env.PROGRAMFILES, "Google/Chrome/Application/chrome.exe"), + process.env["PROGRAMFILES(X86)"] && join(process.env["PROGRAMFILES(X86)"], "Microsoft/Edge/Application/msedge.exe") + ] + : ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser"]; +const browserPath = browserCandidates.find((candidate) => candidate && existsSync(candidate)); + +if (!browserPath) { + console.error("Browser smoke test requires Chrome, Edge, or Chromium."); + process.exit(1); +} + +function freePort() { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const { port } = server.address(); + server.close(() => resolve(port)); + }); + }); +} + +async function waitForJson(url, attempts = 80) { + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const response = await fetch(url); + if (response.ok) return response.json(); + } catch {} + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Timed out waiting for ${url}`); +} + +class DevToolsClient { + constructor(url) { + this.url = url; + this.sequence = 0; + this.pending = new Map(); + this.listeners = new Map(); + this.errors = []; + } + + async connect() { + this.socket = new WebSocket(this.url); + this.socket.addEventListener("message", ({ data }) => this.onMessage(JSON.parse(data))); + await new Promise((resolve, reject) => { + this.socket.addEventListener("open", resolve, { once: true }); + this.socket.addEventListener("error", reject, { once: true }); + }); + } + + onMessage(message) { + if (message.id) { + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + message.error ? pending.reject(new Error(message.error.message)) : pending.resolve(message.result); + return; + } + if (message.method === "Runtime.exceptionThrown") this.errors.push(message.params.exceptionDetails.text); + if (message.method === "Log.entryAdded" && message.params.entry.level === "error") this.errors.push(message.params.entry.text); + const listeners = this.listeners.get(message.method) || []; + listeners.splice(0).forEach((resolve) => resolve(message.params)); + } + + send(method, params = {}) { + const id = ++this.sequence; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.socket.send(JSON.stringify({ id, method, params })); + }); + } + + wait(method, timeout = 10000) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Timed out waiting for ${method}`)), timeout); + const listeners = this.listeners.get(method) || []; + listeners.push((params) => { clearTimeout(timer); resolve(params); }); + this.listeners.set(method, listeners); + }); + } + + async evaluate(expression) { + const result = await this.send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }); + if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text); + return result.result.value; + } + + close() { this.socket.close(); } +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +const serverPort = await freePort(); +const debugPort = await freePort(); +const profile = mkdtempSync(join(tmpdir(), "flowchart-browser-")); +const server = spawn(process.execPath, [join(root, "scripts/serve.mjs")], { cwd: root, env: { ...process.env, PORT: String(serverPort) }, stdio: "ignore" }); +const browser = spawn(browserPath, [ + "--headless=new", "--disable-gpu", "--no-first-run", "--no-default-browser-check", "--no-sandbox", + `--remote-debugging-port=${debugPort}`, `--user-data-dir=${profile}`, "about:blank" +], { stdio: "ignore" }); + +let client; +try { + const targets = await waitForJson(`http://127.0.0.1:${debugPort}/json/list`); + const page = targets.find((target) => target.type === "page"); + assert(page?.webSocketDebuggerUrl, "Browser page target was not available"); + client = new DevToolsClient(page.webSocketDebuggerUrl); + await client.connect(); + await Promise.all([client.send("Page.enable"), client.send("Runtime.enable"), client.send("Log.enable")]); + const loaded = client.wait("Page.loadEventFired"); + await client.send("Page.navigate", { url: `http://127.0.0.1:${serverPort}` }); + await loaded; + + const initial = await client.evaluate(`({ + title: document.title, + textLength: document.body.innerText.trim().length, + shapes: document.querySelectorAll('.shape-item').length, + nodes: document.querySelectorAll('#node-layer .node').length, + edges: document.querySelectorAll('#edge-layer .edge').length, + overlay: Boolean(document.querySelector('[data-nextjs-dialog], .vite-error-overlay, #webpack-dev-server-client-overlay')) + })`); + assert(initial.title === "Flowchart Creator", "Document title is incorrect"); + assert(initial.textLength > 50, "Page content is unexpectedly blank"); + assert(initial.shapes === 12, "Shape palette did not render all symbols"); + assert(initial.nodes === 8 && initial.edges === 7, "Starter diagram did not render correctly"); + assert(!initial.overlay, "An error overlay is visible"); + + const afterAdd = await client.evaluate(`(() => { + document.querySelector('[data-shape="decision"]').click(); + return { nodes: document.querySelectorAll('#node-layer .node').length, panel: !document.querySelector('#properties-panel').hidden }; + })()`); + assert(afterAdd.nodes === 9 && afterAdd.panel, "Adding a shape did not update the editor"); + + const edited = await client.evaluate(`(() => { + document.querySelector('#node-layer .node:last-child').dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + const editor = document.querySelector('#inline-editor'); + const opened = getComputedStyle(editor).display !== 'none'; + editor.value = 'Approved?'; + editor.dispatchEvent(new FocusEvent('blur')); + return { opened, text: document.querySelector('#node-layer .node:last-child text').textContent }; + })()`); + assert(edited.opened && edited.text.includes("Approved?"), `Inline text editing failed: ${JSON.stringify(edited)}`); + + const history = await client.evaluate(`(() => { + document.querySelector('[data-action="duplicate"]').click(); + const duplicated = document.querySelectorAll('#node-layer .node').length; + document.querySelector('[data-action="undo"]').click(); + const undone = document.querySelectorAll('#node-layer .node').length; + document.querySelector('[data-action="redo"]').click(); + const redone = document.querySelectorAll('#node-layer .node').length; + document.querySelector('[data-action="clear"]').click(); + const cleared = document.querySelectorAll('#node-layer .node').length; + const hint = document.querySelector('.empty-hint')?.textContent; + document.querySelector('[data-action="undo"]').click(); + return { duplicated, undone, redone, cleared, restored: document.querySelectorAll('#node-layer .node').length, hint }; + })()`); + assert(history.duplicated === 10 && history.undone === 9 && history.redone === 10, "Duplicate or history controls failed"); + assert(history.cleared === 0 && history.restored === 10 && history.hint?.includes("Add a shape"), "Clear or restore behavior failed"); + assert(client.errors.length === 0, `Browser errors: ${client.errors.join("; ")}`); + + console.log("Browser smoke test passed: rendering, add, edit, duplicate, history, clear, and restore."); +} finally { + client?.close(); + browser.kill(); + server.kill(); + setTimeout(() => rmSync(profile, { recursive: true, force: true }), 250).unref(); +} From 073998c39de095cc3ffef08856447154b0e0fdf5 Mon Sep 17 00:00:00 2001 From: devkyato Date: Mon, 20 Jul 2026 08:49:02 +0800 Subject: [PATCH 06/10] test: exercise connectors persistence and exports --- tests/browser-smoke.mjs | 76 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/tests/browser-smoke.mjs b/tests/browser-smoke.mjs index bfd09e4..ff7aaf1 100644 --- a/tests/browser-smoke.mjs +++ b/tests/browser-smoke.mjs @@ -1,4 +1,4 @@ -import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -138,6 +138,25 @@ try { assert(initial.nodes === 8 && initial.edges === 7, "Starter diagram did not render correctly"); assert(!initial.overlay, "An error overlay is visible"); + const connectionResult = await client.evaluate(`(() => { + document.querySelector('[data-mode="connect"]').click(); + const svg = document.querySelector('#canvas'); + svg.setPointerCapture = () => {}; + const nodes = [...document.querySelectorAll('#node-layer .node')]; + const center = (element) => { const box = element.getBoundingClientRect(); return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; }; + const fromElement = document.querySelector('[data-port-node="' + nodes[0].dataset.nodeId + '"][data-port="bottom"]'); + const toElement = document.querySelector('[data-port-node="' + nodes[1].dataset.nodeId + '"][data-port="top"]'); + const from = center(fromElement); + const to = center(toElement); + fromElement.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientX: from.x, clientY: from.y, button: 0, pointerId: 1 })); + svg.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, clientX: to.x, clientY: to.y, button: 0, pointerId: 1 })); + svg.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, clientX: to.x, clientY: to.y, button: 0, pointerId: 1 })); + return { edges: document.querySelectorAll('#edge-layer .edge').length, status: document.querySelector('#status-text').textContent }; + })()`); + assert(connectionResult.edges === 8, `Connector creation failed: ${JSON.stringify(connectionResult)} ${client.errors.join("; ")}`); + await client.evaluate("document.querySelector('[data-action=\"undo\"]').click()"); + assert(await client.evaluate("document.querySelectorAll('#edge-layer .edge').length") === 7, "Connector undo failed"); + const afterAdd = await client.evaluate(`(() => { document.querySelector('[data-shape="decision"]').click(); return { nodes: document.querySelectorAll('#node-layer .node').length, panel: !document.querySelector('#properties-panel').hidden }; @@ -154,6 +173,43 @@ try { })()`); assert(edited.opened && edited.text.includes("Approved?"), `Inline text editing failed: ${JSON.stringify(edited)}`); + const geometryBefore = await client.evaluate(`(() => { + const node = document.querySelector('#node-layer .node:last-child').getBoundingClientRect(); + const zoom = document.querySelector('#zoom-display').textContent; + return { x: node.x, y: node.y, width: node.width, height: node.height, zoom }; + })()`); + await client.evaluate(`(() => { + const svg = document.querySelector('#canvas'); + const box = document.querySelector('#node-layer .node:last-child').getBoundingClientRect(); + svg.dispatchEvent(new WheelEvent('wheel', { bubbles: true, cancelable: true, ctrlKey: true, deltaY: -120, clientX: box.x + box.width / 2, clientY: box.y + box.height / 2 })); + const movedBox = document.querySelector('#node-layer .node:last-child').getBoundingClientRect(); + const start = { x: movedBox.x + movedBox.width / 2, y: movedBox.y + movedBox.height / 2 }; + const node = document.querySelector('#node-layer .node:last-child'); + node.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientX: start.x, clientY: start.y, button: 0, pointerId: 2 })); + svg.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, clientX: start.x + 35, clientY: start.y + 25, button: 0, pointerId: 2 })); + svg.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, clientX: start.x + 35, clientY: start.y + 25, button: 0, pointerId: 2 })); + })()`); + const geometryMoved = await client.evaluate(`(() => { + const node = document.querySelector('#node-layer .node:last-child').getBoundingClientRect(); + return { x: node.x, y: node.y, zoom: document.querySelector('#zoom-display').textContent }; + })()`); + assert(geometryMoved.zoom !== geometryBefore.zoom, "Ctrl+wheel zoom failed"); + assert(geometryMoved.x > geometryBefore.x + 15 && geometryMoved.y > geometryBefore.y + 10, "Dragging after zoom failed"); + + const resizeStart = await client.evaluate(`(() => { + const svg = document.querySelector('#canvas'); + const handle = document.querySelector('[data-handle="se"]'); + const box = handle.getBoundingClientRect(); + const start = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + const width = document.querySelector('#node-layer .node:last-child').getBoundingClientRect().width; + handle.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientX: start.x, clientY: start.y, button: 0, pointerId: 3 })); + svg.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, clientX: start.x + 40, clientY: start.y + 30, button: 0, pointerId: 3 })); + svg.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, clientX: start.x + 40, clientY: start.y + 30, button: 0, pointerId: 3 })); + return { ...start, width }; + })()`); + const resizedWidth = await client.evaluate("document.querySelector('#node-layer .node:last-child').getBoundingClientRect().width"); + assert(resizedWidth > resizeStart.width + 15, "Resize handles failed after zoom"); + const history = await client.evaluate(`(() => { document.querySelector('[data-action="duplicate"]').click(); const duplicated = document.querySelectorAll('#node-layer .node').length; @@ -169,9 +225,25 @@ try { })()`); assert(history.duplicated === 10 && history.undone === 9 && history.redone === 10, "Duplicate or history controls failed"); assert(history.cleared === 0 && history.restored === 10 && history.hint?.includes("Add a shape"), "Clear or restore behavior failed"); + + const persistence = await client.evaluate(`(() => { + document.querySelector('[data-action="save"]').click(); + document.querySelector('[data-action="clear"]').click(); + const cleared = document.querySelectorAll('#node-layer .node').length; + document.querySelector('[data-action="load"]').click(); + return { cleared, loaded: document.querySelectorAll('#node-layer .node').length }; + })()`); + assert(persistence.cleared === 0 && persistence.loaded === 10, "Local save and load failed"); + + await client.send("Browser.setDownloadBehavior", { behavior: "allow", downloadPath: profile }); + await client.evaluate(`(() => { document.querySelector('[data-action="export-svg"]').click(); document.querySelector('[data-action="export-png"]').click(); })()`); + await new Promise((resolve) => setTimeout(resolve, 800)); + const downloads = readdirSync(profile); + assert(downloads.some((name) => name.endsWith(".svg")), "SVG export did not download"); + assert(downloads.some((name) => name.endsWith(".png")), "PNG export did not download"); assert(client.errors.length === 0, `Browser errors: ${client.errors.join("; ")}`); - console.log("Browser smoke test passed: rendering, add, edit, duplicate, history, clear, and restore."); + console.log("Browser smoke test passed: rendering, connectors, zoom, drag, resize, text, history, persistence, and export."); } finally { client?.close(); browser.kill(); From fc176decaa502617fd102bdc6d3d70d09c864c30 Mon Sep 17 00:00:00 2001 From: devkyato Date: Mon, 20 Jul 2026 08:49:56 +0800 Subject: [PATCH 07/10] build: package static site deterministically --- .gitignore | 1 + package.json | 1 + scripts/build.mjs | 15 +++++++++++++++ 3 files changed, 17 insertions(+) create mode 100644 scripts/build.mjs diff --git a/.gitignore b/.gitignore index 1c1f30a..d7d2003 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .tmp/ +dist/ node_modules/ *.log .DS_Store diff --git a/package.json b/package.json index 23a527e..333a5ef 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "type": "module", "scripts": { "start": "node scripts/serve.mjs", + "build": "node scripts/build.mjs", "check": "node --check script.js", "test": "node --check script.js && node tests/browser-smoke.mjs" }, diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 0000000..2ebb65d --- /dev/null +++ b/scripts/build.mjs @@ -0,0 +1,15 @@ +import { copyFile, mkdir, rm } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const output = resolve(root, "dist"); +const files = ["index.html", "style.css", "script.js"]; + +if (dirname(output) !== root) throw new Error("Refusing to build outside the project root"); + +await rm(output, { recursive: true, force: true }); +await mkdir(output); +await Promise.all(files.map((file) => copyFile(join(root, file), join(output, file)))); + +console.log(`Built ${files.length} static files in dist/`); From e3f1c783f1b1be28194d398b9f87d4b70de514d7 Mon Sep 17 00:00:00 2001 From: devkyato Date: Mon, 20 Jul 2026 08:52:14 +0800 Subject: [PATCH 08/10] ci: automate checks pages and releases --- .editorconfig | 12 +++++++ .github/CODEOWNERS | 1 + .github/ISSUE_TEMPLATE/bug_report.yml | 35 +++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 6 ++++ .github/ISSUE_TEMPLATE/feature_request.yml | 24 +++++++++++++ .github/PULL_REQUEST_TEMPLATE.md | 15 ++++++++ .github/dependabot.yml | 9 +++++ .github/workflows/ci.yml | 24 +++++++++++++ .github/workflows/pages.yml | 40 ++++++++++++++++++++++ .github/workflows/release.yml | 29 ++++++++++++++++ 10 files changed, 195 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/pages.yml create mode 100644 .github/workflows/release.yml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1014ba7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..fa33e72 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @devkyato diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..6182274 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,35 @@ +name: Bug report +description: Report a reproducible problem in Flowchart Creator. +title: "bug: " +labels: [bug] +body: + - type: textarea + id: description + attributes: + label: What happened? + description: Describe the problem and what you expected instead. + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. Add a Process shape + 2. Connect it to a Decision + 3. ... + validations: + required: true + - type: input + id: browser + attributes: + label: Browser and version + placeholder: Chrome 130, Firefox 132, Safari 18 + validations: + required: true + - type: textarea + id: project + attributes: + label: Project JSON or screenshot + description: Attach a minimal exported project or screenshot when useful. Remove sensitive diagram content first. + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..70b4946 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,6 @@ +blank_issues_enabled: false +contact_links: + - name: Security issue + url: https://github.com/devkyato/Nodes/security/advisories/new + about: Report vulnerabilities privately. + diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..03ff568 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,24 @@ +name: Feature request +description: Suggest a focused improvement to the editor. +title: "feat: " +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What flowchart task is difficult today? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed improvement + description: Describe the smallest useful behavior or interaction. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..8a71ee0 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +## Summary + +Describe the change and the user-facing reason for it. + +## Validation + +- [ ] `npm test` +- [ ] `npm run build` +- [ ] Checked keyboard and pointer interaction when relevant +- [ ] Updated documentation or changelog when relevant + +## Screenshots + +Add before/after images for visible interface changes. + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ef063c8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,9 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + labels: + - maintenance + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..02a80c2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + branches: [main, "agent/**"] + pull_request: + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm test + - run: npm run build + diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..8d4a81b --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,40 @@ +name: Deploy Pages + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm test + - run: npm run build + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v4 + with: + path: dist + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d4cfa51 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,29 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm test + - run: npm run build + - name: Create release archive + run: tar -czf "flowchart-creator-${GITHUB_REF_NAME}.tar.gz" -C dist . + - name: Publish GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "$GITHUB_REF_NAME" "flowchart-creator-${GITHUB_REF_NAME}.tar.gz" --verify-tag --generate-notes --title "Flowchart Creator ${GITHUB_REF_NAME}" + From 6208b970058e5b65b94c93e607a9e56ebf9245e3 Mon Sep 17 00:00:00 2001 From: devkyato Date: Mon, 20 Jul 2026 08:53:32 +0800 Subject: [PATCH 09/10] docs: establish contributor and release guidance --- CHANGELOG.md | 22 +++++++++++ CONTRIBUTING.md | 30 +++++++++++++++ README.md | 81 ++++++++++++++++++++++++++++++++++++++++- SECURITY.md | 15 ++++++++ docs/architecture.md | 29 +++++++++++++++ docs/release-process.md | 9 +++++ 6 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 docs/architecture.md create mode 100644 docs/release-process.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1925971 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +All notable changes are documented here. Releases follow [Semantic Versioning](https://semver.org/). + +## [Unreleased] + +## [1.0.0] - 2026-07-20 + +### Added + +- Compact single-page SVG flowchart editor. +- Twelve standard flowchart symbols and standalone text. +- Shape resizing, styling, grouping, alignment, distribution, and layer ordering. +- Straight, orthogonal, and curved attached connectors with labels and endpoint reconnection. +- Multiline inline editing, keyboard shortcuts, pan, zoom, snap, and fit-to-screen. +- Snapshot-based undo and redo for diagram-changing actions. +- Local storage, JSON project download/import, and SVG/PNG export. +- Dependency-free packaging and browser workflow tests. +- Automated CI, GitHub Pages deployment, and tag-based GitHub releases. + +[Unreleased]: https://github.com/devkyato/Nodes/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/devkyato/Nodes/releases/tag/v1.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..547e484 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,30 @@ +# Contributing + +Thanks for improving Flowchart Creator. Keep changes focused, accessible, dependency-free at runtime, and compatible with opening `index.html` directly. + +## Development + +1. Create a topic branch from `main`. +2. Run `npm ci`. +3. Start the local server with `npm start`. +4. Make the smallest complete change. +5. Run `npm test` and `npm run build`. +6. Update `CHANGELOG.md` for user-visible changes. + +## Pull requests + +- Explain the user-facing problem and the chosen behavior. +- Include screenshots for visible interface changes. +- Add browser coverage when changing core interaction behavior. +- Keep generated output, browser profiles, and editor settings out of commits. +- Use clear commit messages such as `feat:`, `fix:`, `test:`, `docs:`, `build:`, or `ci:`. + +## Design principles + +- Prefer direct manipulation and compact controls. +- Preserve correct flowchart geometry. +- Keep one coordinate system for nodes, connectors, selection, and export. +- Make pointer behavior, keyboard behavior, and focus states agree. +- Avoid network calls, telemetry, and new runtime dependencies. + +By contributing, you agree that your work may be distributed under the MIT License. diff --git a/README.md b/README.md index c4ee2e9..6b669a5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,79 @@ -# Nodes -A minimal, browser-based node and flowchart editor with editable shapes, connectors, local saving, and SVG/PNG export. +# Flowchart Creator + +[![CI](https://github.com/devkyato/Nodes/actions/workflows/ci.yml/badge.svg)](https://github.com/devkyato/Nodes/actions/workflows/ci.yml) +[![Pages](https://github.com/devkyato/Nodes/actions/workflows/pages.yml/badge.svg)](https://github.com/devkyato/Nodes/actions/workflows/pages.yml) +[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) + +A compact, browser-based flowchart editor built with HTML, CSS, vanilla JavaScript, and SVG. It has no runtime dependencies, sends no diagram data to a server, and works by opening `index.html` directly. + +[Open Flowchart Creator](https://devkyato.github.io/Nodes/) + +## Highlights + +- Twelve standard flowchart symbols with accurate SVG geometry +- Direct multiline text editing and standalone text +- Straight, orthogonal, and curved connectors that stay attached +- Multi-selection, alignment, distribution, grouping, and layer controls +- Pointer, keyboard, zoom, pan, resize, copy, paste, undo, and redo support +- Local browser saving plus portable JSON project files +- Clean SVG and PNG export cropped to the diagram bounds +- Responsive compact interface with no framework or external library + +## Use locally + +Open `index.html` in a modern browser. For a local HTTP server: + +```bash +npm start +``` + +Then open `http://127.0.0.1:4173`. + +## Essential controls + +| Action | Control | +| --- | --- | +| Select multiple | Shift + click or drag a selection rectangle | +| Pan | Hold Space and drag | +| Zoom | Ctrl/Cmd + mouse wheel | +| Edit text | Double-click a shape, label, or standalone text | +| Add standalone text | Double-click empty canvas or use Text | +| Move precisely | Arrow keys; hold Shift for 10 px | +| Duplicate | Ctrl/Cmd + D | +| Copy / paste | Ctrl/Cmd + C / Ctrl/Cmd + V | +| Undo / redo | Ctrl/Cmd + Z / Ctrl/Cmd + Shift + Z | +| Finish text editing | Click outside or Ctrl/Cmd + Enter | +| Cancel text editing | Escape | + +## Project files + +```text +index.html Application structure +style.css Interface and responsive styling +script.js State, rendering, interaction, persistence, export +scripts/ Dependency-free local server and build packaging +tests/ Headless-browser workflow verification +.github/ CI, Pages, releases, and contribution templates +docs/ Architecture and release notes for maintainers +``` + +## Quality checks + +```bash +npm test +npm run build +``` + +The browser smoke suite exercises rendering, connectors, zoomed dragging, resizing, text editing, history, persistence, and SVG/PNG export. + +## Privacy + +Projects remain in the browser unless the user downloads or imports a file. The application has no analytics, accounts, API calls, or remote storage. + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). Security reports should follow [SECURITY.md](SECURITY.md). + +## License + +[MIT](LICENSE) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a5522cf --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,15 @@ +# Security policy + +## Supported versions + +Security fixes are applied to the latest release. + +## Reporting a vulnerability + +Please use [GitHub private vulnerability reporting](https://github.com/devkyato/Nodes/security/advisories/new). Do not open a public issue containing an undisclosed vulnerability or sensitive diagram data. + +Include the affected browser, a minimal reproduction, and the expected impact. You should receive an acknowledgement within seven days. + +## Data handling + +Flowchart Creator runs entirely in the browser. It does not send diagram content to a remote service. Saved projects use the browser's local storage; downloaded project and image files are handled by the browser. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..f844976 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,29 @@ +# Architecture + +Flowchart Creator intentionally keeps the runtime surface small: one HTML document, one stylesheet, and one JavaScript module loaded as a classic script. + +## State and history + +The application state owns the document title, nodes, edges, selection, viewport, mode, snap preference, and snapshot history. Diagram-changing actions commit once at the end of an interaction, so pointer movement does not flood undo history. + +## Coordinate system + +Nodes and connectors use a fixed 2400 × 1600 logical workspace. The SVG viewport applies one pan-and-zoom transform. Pointer coordinates are converted back into logical coordinates before movement, resize, selection, or connector calculations. This keeps interaction correct at every zoom level. + +## Rendering + +Rendering is deterministic from state: + +1. Connectors render beneath nodes. +2. Nodes render in `zIndex` order. +3. Selection outlines, handles, ports, and temporary gestures render in a separate overlay. + +Each flowchart symbol is generated by the shared geometry renderer. Export reuses the same node and connector renderers, then omits the editing overlay and grid. + +## Persistence + +Projects serialize into a versioned JSON object. Import normalizes missing style fields against current defaults, allowing compatible older files to load. Browser saving uses one namespaced local-storage key. + +## Testing + +The smoke suite starts the dependency-free server, opens an installed Chromium-family browser through the DevTools protocol, and verifies core workflows without a test framework. CI runs the same suite before packaging and deployment. diff --git a/docs/release-process.md b/docs/release-process.md new file mode 100644 index 0000000..6f975a6 --- /dev/null +++ b/docs/release-process.md @@ -0,0 +1,9 @@ +# Release process + +1. Confirm `CHANGELOG.md` contains the release date and version. +2. Update the version in `package.json` and refresh `package-lock.json`. +3. Run `npm test` and `npm run build`. +4. Merge the release changes into `main`. +5. Create and push an annotated tag such as `v1.0.0`. + +The release workflow verifies the tag, packages the three-file application, and creates a GitHub release with generated notes. The Pages workflow deploys every successful change to `main` independently. From b9674b2486eaeb8a482cd79ef695c9e1f3314b8a Mon Sep 17 00:00:00 2001 From: devkyato Date: Mon, 20 Jul 2026 08:54:37 +0800 Subject: [PATCH 10/10] fix: preserve connector labels in exports --- script.js | 2 +- tests/browser-smoke.mjs | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/script.js b/script.js index 9ac5205..8445644 100644 --- a/script.js +++ b/script.js @@ -323,7 +323,7 @@ if (edge.label) { const point = edgeLabelPoint(edge); const width = Math.max(34, edge.label.length * 7 + 10); - group.append(svgEl("rect", { class: "edge-label-bg", x: point.x - width / 2, y: point.y - 12, width, height: 22, rx: 3 })); + group.append(svgEl("rect", { class: "edge-label-bg", x: point.x - width / 2, y: point.y - 12, width, height: 22, rx: 3, fill: "#ffffff", opacity: .92 })); group.append(svgEl("text", { class: "edge-label", x: point.x, y: point.y + 4, "text-anchor": "middle", "font-size": 12, fill: "#334155", "data-text-edge": edge.id }, edge.label)); } parent.append(group); diff --git a/tests/browser-smoke.mjs b/tests/browser-smoke.mjs index ff7aaf1..3bebcf0 100644 --- a/tests/browser-smoke.mjs +++ b/tests/browser-smoke.mjs @@ -1,4 +1,4 @@ -import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -235,12 +235,26 @@ try { })()`); assert(persistence.cleared === 0 && persistence.loaded === 10, "Local save and load failed"); + const edgeLabel = await client.evaluate(`(() => { + const edge = document.querySelector('#edge-layer .edge'); + edge.querySelector('.edge-hit').dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerId: 4 })); + const input = document.querySelector('[data-edge-prop="label"]'); + input.value = 'Approved path'; + input.dispatchEvent(new Event('change', { bubbles: true })); + return document.querySelector('#edge-layer .edge .edge-label')?.textContent; + })()`); + assert(edgeLabel === "Approved path", "Connector label editing failed"); + await client.send("Browser.setDownloadBehavior", { behavior: "allow", downloadPath: profile }); await client.evaluate(`(() => { document.querySelector('[data-action="export-svg"]').click(); document.querySelector('[data-action="export-png"]').click(); })()`); await new Promise((resolve) => setTimeout(resolve, 800)); const downloads = readdirSync(profile); - assert(downloads.some((name) => name.endsWith(".svg")), "SVG export did not download"); + const svgDownload = downloads.find((name) => name.endsWith(".svg")); + assert(svgDownload, "SVG export did not download"); assert(downloads.some((name) => name.endsWith(".png")), "PNG export did not download"); + const exportedSvg = readFileSync(join(profile, svgDownload), "utf8"); + assert(exportedSvg.includes("Approved path") && exportedSvg.includes('fill="#ffffff"'), "SVG export did not preserve connector label styling"); + assert(!exportedSvg.includes("edge-hit"), "SVG export included editor-only hit targets"); assert(client.errors.length === 0, `Browser errors: ${client.errors.join("; ")}`); console.log("Browser smoke test passed: rendering, connectors, zoom, drag, resize, text, history, persistence, and export.");