From 7a4bdef5dc1c8dd0a5887380a90f0acb81194370 Mon Sep 17 00:00:00 2001 From: ys_teng <58208381+YeeShin504@users.noreply.github.com> Date: Sat, 21 Mar 2026 20:05:55 +0800 Subject: [PATCH 1/6] Fix annotation position and word wrap --- src/layout.ts | 19 ++++++++----------- src/renderer.ts | 45 ++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/src/layout.ts b/src/layout.ts index 5a1fc55..dc2d989 100644 --- a/src/layout.ts +++ b/src/layout.ts @@ -51,25 +51,22 @@ export function resolveLayout(entities: Entities): Diagram { // 1. check if need extra spacing for annotations let timeTickMargin = settings.showTimeTicks ? 60 : 0; - let width = settings.participantSpacingX * (participants.length - 1) - + 2 * settings.paddingX + timeTickMargin; + let annMargin = numAnnotations > 0 ? settings.annotationSpacingX : 0; + + let timeAxisX = settings.paddingX + timeTickMargin; + let startX = timeAxisX + (settings.showTimeTicks ? 20 : 0) + annMargin; - if (numAnnotations > 0) - width += 2 * settings.annotationSpacingX; + let width = startX + settings.participantSpacingX * (participants.length - 1) + annMargin + settings.paddingX; // 2. figure out top spacing for participant label and padding - let height = settings.paddingX + settings.participantLabelHeight + settings.messageSpacingY; // last one is a hack + let height = settings.paddingY + settings.participantLabelHeight + settings.messageSpacingY; // last one is a hack // 3. resolve participant x position const participantsX = new Map(); // map alias -> x coord - let timeAxisX = settings.paddingX + (numAnnotations > 0 ? settings.annotationSpacingX : 0) + (timeTickMargin > 0 ? 20 : 0); { - let x = timeAxisX + timeTickMargin; - if (timeTickMargin === 0) { - x = settings.paddingX + (numAnnotations > 0 ? settings.annotationSpacingX : 0); - } + let x = startX; for (const participant of participants) { participantsX.set(participant.alias, x); x += settings.participantSpacingX; @@ -120,7 +117,7 @@ export function resolveLayout(entities: Entities): Diagram { draws.push({ type: action.type, - x: participantsX.get(action.participant) - ((action.side == "left" ? 1 : -1) * (settings.annotationSpacingX / 2)), + x: participantsX.get(action.participant) - ((action.side == "left" ? 1 : -1) * settings.labelOffset), y: height + y * settings.messageSpacingY, align: action.side == "left" ? "right" : "left", // if on left, use right align text: action.text, diff --git a/src/renderer.ts b/src/renderer.ts index 53aae31..8f509a0 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -57,20 +57,55 @@ function drawParticipant(settings: Settings, participant: ParticipantPos): strin `; } +function wrapText(text: string, maxChars: number): string[] { + const words = text.split(" "); + const lines: string[] = []; + let currentLine = ""; + + for (let i = 0; i < words.length; i++) { + const word = words[i]; + if (currentLine.length === 0) { + currentLine = word; + } else if (currentLine.length + 1 + word.length <= maxChars) { + currentLine += " " + word; + } else { + lines.push(currentLine); + currentLine = word; + } + } + if (currentLine) lines.push(currentLine); + + return lines; +} + function drawAnnotation(settings: Settings, annotation: AnnotationPos): string { const { x, y, align, text } = annotation; - // TODO: handle align and wrapping + + const charWidth = settings.messageFontSize * 0.6; + const marginOffset = settings.labelOffset; + const rectWidth = settings.annotationSpacingX - marginOffset; + const maxChars = Math.max(1, Math.floor(rectWidth / charWidth)); + + const lines = wrapText(text, maxChars); + const lineHeight = settings.messageFontSize * 1.2; + const startY = y; + + const anchor = align === "left" ? "start" : align === "right" ? "end" : "middle"; + + const tspans = lines.map((line, i) => + `${line}` + ).join("\n"); + return ` - ${text} + ${tspans} ` } From 24a723fdb797ca292c8c387c4c388fe2bd2988ae Mon Sep 17 00:00:00 2001 From: ys_teng <58208381+YeeShin504@users.noreply.github.com> Date: Sat, 21 Mar 2026 20:06:29 +0800 Subject: [PATCH 2/6] Update README on stable vs dev editor --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b5215fd..b258db0 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Markup language for rendering network protocol diagrams. ## Interactive Demo -Try the live editor [here](https://alieron.github.io/protocol-ml/) +Try the [stable live editor](https://yeeshin504.github.io/protocol-ml/) or the [development live editor](https://yeeshin504.github.io/protocol-ml/dev/). ### Development From 2e2301da6d9fa07effdd717892973f8de30cab00 Mon Sep 17 00:00:00 2001 From: ys_teng <58208381+YeeShin504@users.noreply.github.com> Date: Sat, 21 Mar 2026 21:20:43 +0800 Subject: [PATCH 3/6] Remove case sensitivity when parsing settings --- docs/editor.js | 4 +- src/layout.ts | 29 +++++----- src/parser.ts | 144 +++++++++++++++++++++++++++++++++++++----------- src/renderer.ts | 40 +++++++------- 4 files changed, 151 insertions(+), 66 deletions(-) diff --git a/docs/editor.js b/docs/editor.js index 68e6fe7..0a1cac2 100644 --- a/docs/editor.js +++ b/docs/editor.js @@ -6,8 +6,8 @@ const copyCodeBtn = document.getElementById('copyCode'); const copyPNGBtn = document.getElementById('copyPNG'); const copySVGBtn = document.getElementById('copySVG'); -const INITIAL_CODE = `def messageSpacing 20px -def participantSpacing 160px +const INITIAL_CODE = `def timeTickInterval 40px +def participantSpacing 240px def showTimeTicks true def showGrid true diff --git a/src/layout.ts b/src/layout.ts index dc2d989..5d5aea9 100644 --- a/src/layout.ts +++ b/src/layout.ts @@ -51,16 +51,16 @@ export function resolveLayout(entities: Entities): Diagram { // 1. check if need extra spacing for annotations let timeTickMargin = settings.showTimeTicks ? 60 : 0; - let annMargin = numAnnotations > 0 ? settings.annotationSpacingX : 0; + let annMargin = numAnnotations > 0 ? settings.annotationWidth : 0; let timeAxisX = settings.paddingX + timeTickMargin; let startX = timeAxisX + (settings.showTimeTicks ? 20 : 0) + annMargin; - let width = startX + settings.participantSpacingX * (participants.length - 1) + annMargin + settings.paddingX; + let width = startX + settings.participantSpacing * (participants.length - 1) + annMargin + settings.paddingX; // 2. figure out top spacing for participant label and padding - let height = settings.paddingY + settings.participantLabelHeight + settings.messageSpacingY; // last one is a hack + let height = settings.paddingY + settings.participantLabelHeight + settings.timeTickInterval; // last one is a hack // 3. resolve participant x position @@ -69,7 +69,7 @@ export function resolveLayout(entities: Entities): Diagram { let x = startX; for (const participant of participants) { participantsX.set(participant.alias, x); - x += settings.participantSpacingX; + x += settings.participantSpacing; } } @@ -98,14 +98,16 @@ export function resolveLayout(entities: Entities): Diagram { draws.push({ type: action.type, x1: participantsX.get(action.from), - y1: height + startY * settings.messageSpacingY, + y1: height + startY * settings.timeTickInterval, x2: participantsX.get(action.to), - y2: height + endY * settings.messageSpacingY, + y2: height + endY * settings.timeTickInterval, arrowType: action.arrowType, label: action.label, }); - counter++; + const thicknessRatio = action.arrowType === "thick" ? settings.thickArrowThickness / settings.timeTickInterval : 0; + counterMax = Math.max(counterMax, startY + thicknessRatio, endY + thicknessRatio); + counter = endY + thicknessRatio; break; case "annotation": @@ -118,26 +120,27 @@ export function resolveLayout(entities: Entities): Diagram { draws.push({ type: action.type, x: participantsX.get(action.participant) - ((action.side == "left" ? 1 : -1) * settings.labelOffset), - y: height + y * settings.messageSpacingY, + y: height + y * settings.timeTickInterval, align: action.side == "left" ? "right" : "left", // if on left, use right align text: action.text, }); + counterMax = Math.max(counterMax, y); break; // default: // // should not reach here // console.error(`[protocol-ml] Errror: invalid action type ${action.type}`); } - counterMax = Math.max(counterMax, counter); } // 5. resolve height of participant lifetimes // draws.reverse(); // TBC: might look better if one rendered above - const oldHeight = height - settings.messageSpacingY; // another hack - height += (counterMax + 1) * settings.messageSpacingY; // add some extra length to the lifetime + const oldHeight = height - settings.timeTickInterval; // another hack + const totalTicks = Math.ceil(counterMax); + height += (totalTicks + 1) * settings.timeTickInterval; // add some extra length to the lifetime const lifelines: ParticipantPos[] = participants.map(p => { return { @@ -150,10 +153,10 @@ export function resolveLayout(entities: Entities): Diagram { }); if (settings.showTimeTicks || settings.showGrid) { - for (let i = 0; i <= counterMax; i++) { + for (let i = 0; i <= totalTicks; i++) { draws.push({ type: "tick", - y: oldHeight + settings.messageSpacingY + i * settings.messageSpacingY, + y: oldHeight + settings.timeTickInterval + i * settings.timeTickInterval, label: `${i}`, }); } diff --git a/src/parser.ts b/src/parser.ts index 7ea02f0..ade2d8a 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -35,35 +35,98 @@ export interface Entities { numAnnotations: number; } -const DEFAULT_SETTINGS = { - arrowSize: 10, - crossSize: 12, - thickArrowSize: 30, +export interface Settings { + arrowHeadSize: number; + dropCrossSize: number; + thickArrowThickness: number; + labelOffset: number; + corruptStartRatio: number; + dropStartRatio: number; + + squiggleSize: number; + squiggleCount: number; + + timeTickInterval: number; + participantSpacing: number; + participantLabelHeight: number; + annotationWidth: number; + + participantFontSize: number; + messageFontSize: number; + + paddingX: number; + paddingY: number; + + showGrid: boolean; + showTimeTicks: boolean; + timeUnit: string; +} + +const DEFAULT_SETTINGS: Settings = { + arrowHeadSize: 10, + dropCrossSize: 12, + thickArrowThickness: 40, labelOffset: 10, - corruptStart: 0.85, - dropStart: 0.85, + corruptStartRatio: 0.85, + dropStartRatio: 0.85, squiggleSize: 20, squiggleCount: 2, - messageSpacingY: 40, - participantSpacingX: 240, + timeTickInterval: 40, + participantSpacing: 240, participantLabelHeight: 30, - annotationSpacingX: 80, + annotationWidth: 80, participantFontSize: 20, messageFontSize: 15, paddingX: 30, paddingY: 20, - + showGrid: false, showTimeTicks: false, + timeUnit: "", }; -export type Settings = { - [key: string]: number | boolean | undefined; -} & typeof DEFAULT_SETTINGS; +function parseString(val: string): string { + const v = val.trim(); + // Strip quotes if present + if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) { + return v.slice(1, -1); + } + return v; +} + +function parseNumber(val: string): number | null { + const v = parseString(val); + if (v === "") return null; + + // Percentage: "85%" -> 0.85 + if (v.endsWith("%")) { + const numPart = v.slice(0, -1); + const n = Number(numPart); + if (numPart !== "" && !isNaN(n)) return n / 100; + } + + // Pixels: "20px" -> 20 + if (v.endsWith("px")) { + const numPart = v.slice(0, -2); + const n = Number(numPart); + if (numPart !== "" && !isNaN(n)) return n; + } + + // Strict number coercion + const n = Number(v); + return isNaN(n) ? null : n; +} + +function parseBoolean(val: string): boolean | null { + const v = parseString(val).toLowerCase(); + if (v === "true") return true; + if (v === "false") return false; + return null; +} export function parse(src: string): Entities { const settings: Settings = { ...DEFAULT_SETTINGS }; @@ -81,26 +144,45 @@ export function parse(src: string): Entities { if (!line || line.startsWith("//")) continue; // overwrite default settings - if (line.startsWith("def")) { - - const [, name, value] = line.split(/\s+/); - - if (/^true$/i.test(value)) { - settings[name] = true; - } else if (/^false$/i.test(value)) { - settings[name] = false; - } else { - settings[name] = parseFloat(value); + if (line.startsWith("def ")) { + // Use regex to capture the full value which might contain spaces and quotes + const match = line.match(/^def\s+([\w.]+)\s+(.+)$/); + if (!match) continue; + + const name = match[1]; + const rawValue = match[2]; + + const normalizedName = name.toLowerCase(); + // find exact match (case-insensitive) + const targetKey = Object.keys(DEFAULT_SETTINGS).find(k => k.toLowerCase() === normalizedName) as keyof Settings | undefined; + + if (targetKey) { + const defaultValue = DEFAULT_SETTINGS[targetKey]; + + // Use specialized parsers based on the target type + if (typeof defaultValue === "number") { + const val = parseNumber(rawValue); + if (val !== null) (settings as any)[targetKey] = val; + } else if (typeof defaultValue === "boolean") { + const val = parseBoolean(rawValue); + if (val !== null) (settings as any)[targetKey] = val; + } else if (typeof defaultValue === "string") { + (settings as any)[targetKey] = parseString(rawValue); + } } continue; } // participants - // /^participant\s+((?:["'][^"'@]+["'])|[^\s"'@]+)\s+([^\s"'@]+)/ - if (line.startsWith("participant")) { + // Example: participant Client c1 + // Example: participant "Web Server" ws + if (line.startsWith("participant ")) { + const match = line.match(/^participant\s+(?:(?:"([^"]+)")|(\S+))\s+(\w+)/); + if (!match) continue; - const [, name, alias] = line.split(/\s+/); + const name = match[1] || match[2]; + const alias = match[3]; participants.push({ name, @@ -113,7 +195,7 @@ export function parse(src: string): Entities { // annotations const annMatch = line.match( - /^(\w+)(?:\s*@([\d.]+))?\s*([<>])\s*"(.+)"/ + /^(\w+)(?:\s*@([\d.%px]+))?\s*([<>])\s*"(.+)"/ ); if (annMatch) { @@ -122,7 +204,7 @@ export function parse(src: string): Entities { actions.push({ type: "annotation", participant: annMatch[1], - height: annMatch[2] ? parseFloat(annMatch[2]) : undefined, + height: annMatch[2] ? (parseNumber(annMatch[2]) ?? undefined) : undefined, side: annMatch[3] === "<" ? "left" : "right", text: annMatch[4] }); @@ -132,7 +214,7 @@ export function parse(src: string): Entities { // arrows const arrowMatch = line.match( - /^(\w+)(?:\s*@([\d.]+))?\s*(->|=>|~>|-x)\s*(\w+)(?:\s*@([\d.]+))?(?:\s*:\s*"(.+)")?/ + /^(\w+)(?:\s*@([\d.%px]+))?\s*(->|=>|~>|-x)\s*(\w+)(?:\s*@([\d.%px]+))?(?:\s*:\s*"(.+)")?/ ); if (arrowMatch) { @@ -147,10 +229,10 @@ export function parse(src: string): Entities { actions.push({ type: "arrow", from: arrowMatch[1], - start: arrowMatch[2] ? parseFloat(arrowMatch[2]) : undefined, + start: arrowMatch[2] ? (parseNumber(arrowMatch[2]) ?? undefined) : undefined, arrowType: typeMap[arrowMatch[3]], to: arrowMatch[4], - end: arrowMatch[5] ? parseFloat(arrowMatch[5]) : undefined, + end: arrowMatch[5] ? (parseNumber(arrowMatch[5]) ?? undefined) : undefined, label: arrowMatch[6] }); diff --git a/src/renderer.ts b/src/renderer.ts index 8f509a0..dcfe139 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -18,7 +18,7 @@ function drawTimeAxis(settings: Settings, axis: TimeAxisPos, ticks: TickPos[]): fill="white" font-size="${settings.participantFontSize}" > - Time + Time ${settings.timeUnit} `; @@ -83,7 +83,7 @@ function drawAnnotation(settings: Settings, annotation: AnnotationPos): string { const charWidth = settings.messageFontSize * 0.6; const marginOffset = settings.labelOffset; - const rectWidth = settings.annotationSpacingX - marginOffset; + const rectWidth = settings.annotationWidth - marginOffset; const maxChars = Math.max(1, Math.floor(rectWidth / charWidth)); const lines = wrapText(text, maxChars); @@ -129,14 +129,14 @@ function drawArrow(settings: Settings, arrow: ArrowPos): string { let arrowsvg = ''; // arrow head - const baseX = x2 - ux * settings.arrowSize; - const baseY = y2 - uy * settings.arrowSize; + const baseX = x2 - ux * settings.arrowHeadSize; + const baseY = y2 - uy * settings.arrowHeadSize; - const leftX = baseX + px * settings.arrowSize * 0.5; - const leftY = baseY + py * settings.arrowSize * 0.5; + const leftX = baseX + px * settings.arrowHeadSize * 0.5; + const leftY = baseY + py * settings.arrowHeadSize * 0.5; - const rightX = baseX - px * settings.arrowSize * 0.5; - const rightY = baseY - py * settings.arrowSize * 0.5; + const rightX = baseX - px * settings.arrowHeadSize * 0.5; + const rightY = baseY - py * settings.arrowHeadSize * 0.5; switch (arrowType) { case "normal": @@ -150,23 +150,23 @@ function drawArrow(settings: Settings, arrow: ArrowPos): string { case "dropped": // cross - const dropX = x1 + ux * len * settings.dropStart; - const dropY = y1 + uy * len * settings.dropStart; + const dropX = x1 + ux * len * settings.dropStartRatio; + const dropY = y1 + uy * len * settings.dropStartRatio; arrowsvg = ` - - + + `; break; case "corrupt": // draw the squiggly - const waveX = x1 + ux * len * settings.corruptStart; - const waveY = y1 + uy * len * settings.corruptStart; + const waveX = x1 + ux * len * settings.corruptStartRatio; + const waveY = y1 + uy * len * settings.corruptStartRatio; - const waveLen = (len * (1 - settings.corruptStart)) - 2 * settings.arrowSize; + const waveLen = (len * (1 - settings.corruptStartRatio)) - 2 * settings.arrowHeadSize; const waveInterval = waveLen / (4 * settings.squiggleCount); @@ -207,13 +207,13 @@ function drawArrow(settings: Settings, arrow: ArrowPos): string { case "thick": arrowsvg = ` - + - - - + + + `; break; @@ -240,7 +240,7 @@ function drawArrow(settings: Settings, arrow: ArrowPos): string { const lx = mx - px * offset; const ly = (arrowType === "thick") - ? my + (direction * py * settings.thickArrowSize / 2) + ? my + (direction * py * settings.thickArrowThickness / 2) : my - py * offset; From 3436ec2206af59cdae7f4e38cd1c62ae7638e889 Mon Sep 17 00:00:00 2001 From: ys_teng <58208381+YeeShin504@users.noreply.github.com> Date: Sat, 21 Mar 2026 22:44:38 +0800 Subject: [PATCH 4/6] Add syntax reference to live editor --- docs/editor.css | 181 +++++++++++++++++++++- docs/editor.js | 201 +++++++++++++++++++++++++ docs/example.svg | 382 ++++++++++++++++++++++++++++++++++++++++------- docs/index.html | 33 +++- src/renderer.ts | 2 +- 5 files changed, 731 insertions(+), 68 deletions(-) diff --git a/docs/editor.css b/docs/editor.css index 0f01077..de360bb 100644 --- a/docs/editor.css +++ b/docs/editor.css @@ -18,6 +18,79 @@ header { border-bottom: 1px solid #3e3e42; } +.header-content { + display: flex; + justify-content: space-between; + align-items: center; +} + +.resizer { + width: 6px; + background: #1e1e1e; + cursor: col-resize; + position: relative; + z-index: 100; + border-left: 1px solid #3e3e42; + border-right: 1px solid #3e3e42; + transition: background 0.2s; +} + +.resizer:hover, .resizer.dragging { + background: #0e639c; +} + +.toggle-arrow { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: #2d2d2d; + border: 1px solid #3e3e42; + color: #858585; + width: 20px; + height: 40px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 10px; + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0,0,0,0.2); + transition: all 0.2s; +} + +.toggle-arrow:hover { + background: #3e3e42; + color: #ffffff; + border-color: #0e639c; +} + +.panel { + display: flex; + flex-direction: column; + min-width: 0; +} + +#panel-reference { + flex: 0 0 280px; + background: #252526; + transition: flex-basis 0.3s ease-in-out; +} + +#panel-reference.collapsed { + flex: 0 0 0px !important; + overflow: hidden; +} + +#panel-editor { + flex: 1 1 0; +} + +#panel-preview { + flex: 1 1 0; +} + h1 { font-size: 1.5rem; font-weight: 600; @@ -35,11 +108,101 @@ main { height: calc(100vh - 80px); } -.panel { - flex: 1; - display: flex; - flex-direction: column; - border-right: 1px solid #3e3e42; +.reference-content { + background: #252526; + padding: 1.25rem; + overflow-y: auto; +} + +.search-container { + padding: 0.75rem 1.25rem; + background: #252526; + border-bottom: 1px solid #3e3e42; +} + +#refSearch { + width: 100%; + background: #3c3c3c; + border: 1px solid #3e3e42; + color: #cccccc; + padding: 0.4rem 0.75rem; + border-radius: 3px; + font-size: 0.85rem; + outline: none; + transition: border-color 0.2s; +} + +#refSearch:focus { + border-color: #0e639c; +} + +.loading { + color: #858585; + font-size: 0.875rem; + font-style: italic; +} + +.ref-group h4 { + color: #569cd6; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + margin: 1.5rem 0 0.75rem 0; +} + +.ref-group:first-child h4 { + margin-top: 0; +} + +.ref-item { + background: #2d2d2d; + border: 1px solid #3e3e42; + border-radius: 6px; + padding: 0.75rem; + margin-bottom: 0.75rem; + transition: border-color 0.2s; +} + +.ref-item:hover { + border-color: #454545; +} + +.ref-title { + font-size: 0.85rem; + color: #cccccc; + font-weight: 500; + margin-bottom: 0.4rem; +} + +.ref-syntax { + display: block; + font-family: inherit; + font-size: 0.75rem; + background: #1e1e1e; + padding: 0.4rem 0.6rem; + color: #ce9178; + border-radius: 4px; + margin-bottom: 0.6rem; + border: 1px solid #3e3e42; +} + +.try-btn-small { + background: transparent; + border: 1px solid #3e3e42; + color: #858585; + padding: 0.25rem 0.6rem; + border-radius: 3px; + font-size: 0.7rem; + cursor: pointer; + width: 100%; + transition: all 0.2s; +} + +.try-btn-small:hover { + background: #3e3e42; + color: #ffffff; + border-color: #0e639c; } .panel:last-child { @@ -108,12 +271,16 @@ main { } .protocol-ml-wrapper { - display: inline-block; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; } svg { max-width: 100%; - height: auto; + max-height: 100%; } @media (max-width: 768px) { diff --git a/docs/editor.js b/docs/editor.js index 0a1cac2..60ec6a5 100644 --- a/docs/editor.js +++ b/docs/editor.js @@ -6,6 +6,203 @@ const copyCodeBtn = document.getElementById('copyCode'); const copyPNGBtn = document.getElementById('copyPNG'); const copySVGBtn = document.getElementById('copySVG'); +// Panel & Resizer elements +const panelRef = document.getElementById('panel-reference'); +const panelEditor = document.getElementById('panel-editor'); +const panelPreview = document.getElementById('panel-preview'); +const resizerRef = document.getElementById('resizer-ref'); +const resizerPreview = document.getElementById('resizer-preview'); +const toggleRefBtn = document.getElementById('toggleReference'); + +// Resizing logic +function initResizer(resizer, leftPanel, rightPanel, isFirst) { + let startX, startWidthLeft, startWidthRight; + + resizer.addEventListener('mousedown', (e) => { + if (e.target.id === 'toggleReference') return; + + startX = e.clientX; + startWidthLeft = leftPanel.offsetWidth; + startWidthRight = rightPanel.offsetWidth; + + // Disable transitions during resizing to prevent lag + leftPanel.style.transition = 'none'; + + const onMouseMove = (e) => { + const deltaX = e.clientX - startX; + if (isFirst) { + const newWidth = Math.max(0, startWidthLeft + deltaX); + leftPanel.style.flex = `0 0 ${newWidth}px`; + if (editor) editor.refresh(); + } else { + const newWidth = Math.max(100, startWidthLeft + deltaX); + leftPanel.style.flex = `0 0 ${newWidth}px`; + if (editor) editor.refresh(); + } + }; + + const onMouseUp = () => { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + resizer.classList.remove('dragging'); + document.body.style.cursor = 'default'; + + // Re-enable transition after resizing + leftPanel.style.transition = ''; + + if (editor) editor.refresh(); + }; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + resizer.classList.add('dragging'); + document.body.style.cursor = 'col-resize'; + }); +} + +// Toggle Reference logic +toggleRefBtn.addEventListener('click', (e) => { + e.stopPropagation(); + panelRef.classList.toggle('collapsed'); + const isCollapsed = panelRef.classList.contains('collapsed'); + toggleRefBtn.textContent = isCollapsed ? '▶' : '◀'; +}); + +const REFERENCE_DATA = [ + { + group: "Participants", + items: [ + { title: "Define Participant", syntax: 'participant Name alias', example: 'participant Client c\nparticipant Server s' }, + { title: "Participant Spacing", syntax: 'def participantSpacing 240px', example: 'def participantSpacing 300px' }, + { title: "Label Height", syntax: 'def participantLabelHeight 30px', example: 'def participantLabelHeight 50px' }, + { title: "Font Size", syntax: 'def participantFontSize 20px', example: 'def participantFontSize 24px' } + ] + }, + { + group: "Message Arrows", + items: [ + { title: "Normal Arrow", syntax: 'a -> b : "label"', example: 'a -> b : "Request"' }, + { title: "Thick Arrow", syntax: 'a => b : "label"', example: 'a => b : "Big Data"' }, + { title: "Corrupt Arrow", syntax: 'a ~> b : "label"', example: 'a ~> b : "Fragmented"' }, + { title: "Dropped Message", syntax: 'a -x b : "label"', example: 'a -x b : "Timeout"' }, + { title: "Time Offsets", syntax: 'a @1.5 -> b @1', example: 'a @1.5 -> b @1 : "Relative Time"' } + ] + }, + { + group: "Arrow Styles", + items: [ + { title: "Arrow Head Size", syntax: 'def arrowHeadSize 10px', example: 'def arrowHeadSize 15px' }, + { title: "Thick Thickness", syntax: 'def thickArrowThickness 40px', example: 'def thickArrowThickness 20px' }, + { title: "Label Offset", syntax: 'def labelOffset 10px', example: 'def labelOffset 20px' }, + { title: "Message Font Size", syntax: 'def messageFontSize 15px', example: 'def messageFontSize 18px' } + ] + }, + { + group: "Corrupt & Dropped", + items: [ + { title: "Corrupt Start %", syntax: 'def corruptStartRatio 85%', example: 'def corruptStartRatio 50%' }, + { title: "Drop Start %", syntax: 'def dropStartRatio 85%', example: 'def dropStartRatio 50%' }, + { title: "Drop Cross Size", syntax: 'def dropCrossSize 12px', example: 'def dropCrossSize 20px' }, + { title: "Corrupt Squiggle Size", syntax: 'def squiggleSize 20px', example: 'def squiggleSize 10px' }, + { title: "Corrupt Squiggle Count", syntax: 'def squiggleCount 2', example: 'def squiggleCount 5' } + ] + }, + { + group: "Annotations", + items: [ + { title: "Left Note", syntax: 'a < "text"', example: 'a < "Local check"' }, + { title: "Right Note", syntax: 'a > "text"', example: 'a > "Processing"' }, + { title: "Annotation Width", syntax: 'def annotationWidth 80px', example: 'def annotationWidth 120px' } + ] + }, + { + group: "Grid & Time", + items: [ + { title: "Show Grid", syntax: 'def showGrid true', example: 'def showGrid true' }, + { title: "Show Time Ticks", syntax: 'def showTimeTicks true', example: 'def showTimeTicks true' }, + { title: "Time Interval", syntax: 'def timeTickInterval 40px', example: 'def timeTickInterval 60px' }, + { title: "Time Unit", syntax: 'def timeUnit /ms', example: 'def timeUnit /ms' } + ] + }, + { + group: "Layout", + items: [ + { title: "Horizontal Padding", syntax: 'def paddingX 30px', example: 'def paddingX 50px' }, + { title: "Vertical Padding", syntax: 'def paddingY 20px', example: 'def paddingY 50px' } + ] + } +]; + +function renderReference(filter = '') { + const container = document.getElementById('reference-list'); + if (!container) return; + + container.innerHTML = ''; // Clear loading message + const query = filter.toLowerCase().trim(); + + REFERENCE_DATA.forEach(group => { + const filteredItems = group.items.filter(item => + item.title.toLowerCase().includes(query) || + item.syntax.toLowerCase().includes(query) || + group.group.toLowerCase().includes(query) + ); + + if (filteredItems.length === 0) return; + + const groupEl = document.createElement('div'); + groupEl.className = 'ref-group'; + groupEl.innerHTML = `

${group.group}

`; + + const itemsContainer = document.createElement('div'); + itemsContainer.className = 'group-items'; + + filteredItems.forEach(item => { + const itemEl = document.createElement('div'); + itemEl.className = 'ref-item'; + + const titleEl = document.createElement('div'); + titleEl.className = 'ref-title'; + titleEl.textContent = item.title; + + const codeEl = document.createElement('code'); + codeEl.className = 'ref-syntax'; + // Use the editor engine to highlight this static snippet! + CodeMirror.runMode(item.syntax, 'protocol-ml', codeEl); + + const btn = document.createElement('button'); + btn.className = 'try-btn-small'; + btn.textContent = 'Try it'; + btn.addEventListener('click', () => { + const currentValue = editor.getValue(); + const newValue = currentValue ? (currentValue + '\n\n' + item.example) : item.example; + editor.setValue(newValue); + editor.setCursor(editor.lineCount(), 0); + editor.focus(); + }); + + itemEl.appendChild(titleEl); + itemEl.appendChild(codeEl); + itemEl.appendChild(btn); + itemsContainer.appendChild(itemEl); + }); + + groupEl.appendChild(itemsContainer); + container.appendChild(groupEl); + }); + + if (container.innerHTML === '' && query !== '') { + container.innerHTML = '
No results found for "' + filter + '"
'; + } +} + +const refSearch = document.getElementById('refSearch'); +if (refSearch) { + refSearch.addEventListener('input', (e) => { + renderReference(e.target.value); + }); +} + + const INITIAL_CODE = `def timeTickInterval 40px def participantSpacing 240px def showTimeTicks true @@ -144,3 +341,7 @@ copySVGBtn.addEventListener('click', async () => { // Initial render render(); +renderReference(); + +initResizer(resizerRef, panelRef, panelEditor, true); +initResizer(resizerPreview, panelEditor, panelPreview, false); diff --git a/docs/example.svg b/docs/example.svg index 9f1e630..df0da80 100644 --- a/docs/example.svg +++ b/docs/example.svg @@ -1,105 +1,377 @@ - - + + + + + + + + + + + + + - + + Time + + + + + 0 + + + + + 1 + + + + + 2 + + + + + 3 + + + + + 4 + + + + + 5 + + + + + 6 + + + + + 7 + + + + + 8 + + + + + 9 + + + + + 10 + + + Alice - + - + Bob - + - - - + + + - + normal - - - + + + - + normal reply - - - - - + + + + + - + corrupted - - - - - + + + + + - + corrupted reply - - - + + + - + dropped - - - + + + - + dropped reply - - - - - - - + + + + + + + - + thick - - - - - - - + + + + + + + - + thick reply - - left label @2 + + left +label +@2 - - right label @5 + + right +label +@5 - + \ No newline at end of file diff --git a/docs/index.html b/docs/index.html index 77ff5b3..bf11c63 100644 --- a/docs/index.html +++ b/docs/index.html @@ -11,12 +11,32 @@
-

protocol-ml

-

Interactive Protocol Diagram Editor

+
+
+

protocol-ml

+

Interactive Protocol Diagram Editor

+
+
-
-
+
+
+
+ Reference +
+
+ +
+
+
Loading reference...
+
+
+ +
+ +
+ +
Code Editor @@ -26,7 +46,9 @@

protocol-ml

-
+
+ +
SVG Preview
@@ -41,6 +63,7 @@

protocol-ml

+ diff --git a/src/renderer.ts b/src/renderer.ts index dcfe139..f626ba6 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -268,7 +268,7 @@ export function renderSVG(entities: Entities): string { let svg: string[] = []; svg.push( - `` + `` ); svg.push( From 48745f46eadfd3afcd97b021df91f2de00388bb3 Mon Sep 17 00:00:00 2001 From: ys_teng <58208381+YeeShin504@users.noreply.github.com> Date: Sat, 21 Mar 2026 23:28:53 +0800 Subject: [PATCH 5/6] Add autocomplete to code editor --- docs/editor.css | 37 ++++++++++++++++++- docs/editor.js | 38 +++++++++++++++++++- docs/index.html | 3 ++ docs/syntax-highlight.js | 78 ++++++++++++++++++++++++++++++++++++++-- src/parser.ts | 6 ++-- 5 files changed, 155 insertions(+), 7 deletions(-) diff --git a/docs/editor.css b/docs/editor.css index de360bb..3a4ef25 100644 --- a/docs/editor.css +++ b/docs/editor.css @@ -337,7 +337,10 @@ svg { color: #b5cea8; } -/* identifiers */ +/* identifiers/aliases */ +.cm-pml-alias { + color: #4ec9b0; +} .cm-pml-arrow-normal { color: #4fc1ff; font-weight: bold; @@ -381,4 +384,36 @@ svg { .cm-pml-comment { color: #6a9955; font-style: italic; +} + +/* CodeMirror Hint Theme */ +.CodeMirror-hints { + position: absolute; + z-index: 10; + overflow: hidden; + list-style: none; + margin: 0; + padding: 2px; + background: #252526; + border: 1px solid #454545; + border-radius: 4px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); + font-family: 'JetBrains Mono', 'Consolas', 'Monaco', monospace; + font-size: 13px; + color: #d4d4d4; + max-height: 200px; + overflow-y: auto; +} + +.CodeMirror-hint { + margin: 0; + padding: 4px 8px; + border-radius: 2px; + cursor: pointer; + color: #d4d4d4; +} + +li.CodeMirror-hint-active { + background: #094771; + color: white; } \ No newline at end of file diff --git a/docs/editor.js b/docs/editor.js index 60ec6a5..3727d2b 100644 --- a/docs/editor.js +++ b/docs/editor.js @@ -226,6 +226,10 @@ b => a : "thick reply" a @2 < "left label @2" b @5 > "right label @5"`; +CodeMirror.commands.autocomplete = function(cm) { + cm.showHint({ hint: CodeMirror.hint["protocol-ml"] }); +}; + const editor = window.CodeMirror(document.getElementById('editor'), { value: INITIAL_CODE, mode: 'protocol-ml', @@ -233,7 +237,39 @@ const editor = window.CodeMirror(document.getElementById('editor'), { autofocus: true, tabSize: 2, indentUnit: 2, - viewportMargin: Infinity + indentWithTabs: false, + viewportMargin: Infinity, + extraKeys: { + "Ctrl-Space": "autocomplete", + "Ctrl-/": "toggleComment", + "Cmd-/": "toggleComment", + "Tab": (cm) => { + if (cm.state.completionActive) { + cm.state.completionActive.pick(); + } else { + const cursor = cm.getCursor(); + const line = cm.getLine(cursor.line); + const before = line.slice(0, cursor.ch); + // If it's just whitespace before the cursor, indent. + // Otherwise try to complete. + if (/^\s*$/.test(before)) { + return CodeMirror.Pass; + } + cm.execCommand("autocomplete"); + } + } + }, + hintOptions: { + direction: "above", + completeSingle: false, + alignWithWord: true + } +}); + +// Auto-trigger hints while typing (VS Code style) +editor.on("inputRead", (cm, change) => { + if (change.text[0] === " " || change.text[0] === "\n") return; + cm.showHint({ hint: CodeMirror.hint["protocol-ml"] }); }); let currentSVG = ''; diff --git a/docs/index.html b/docs/index.html index bf11c63..2e40cd1 100644 --- a/docs/index.html +++ b/docs/index.html @@ -6,6 +6,7 @@ protocol-ml - Interactive Editor + @@ -64,6 +65,8 @@

protocol-ml

+ + diff --git a/docs/syntax-highlight.js b/docs/syntax-highlight.js index 039d541..8d779d2 100644 --- a/docs/syntax-highlight.js +++ b/docs/syntax-highlight.js @@ -1,3 +1,15 @@ +const KEYWORDS = ["def", "participant", "true", "false"]; +const SETTINGS = [ + "arrowHeadSize", "dropCrossSize", "thickArrowThickness", "labelOffset", + "corruptStartRatio", "dropStartRatio", "squiggleSize", "squiggleCount", + "timeTickInterval", "participantSpacing", "participantLabelHeight", + "annotationWidth", "participantFontSize", "messageFontSize", + "paddingX", "paddingY", "showGrid", "showTimeTicks", "timeUnit" +]; + +const KEYWORDS_REGEX = new RegExp(`\\b(${KEYWORDS.join('|')})\\b`); +const SETTINGS_REGEX = new RegExp(`\\b(${SETTINGS.join('|')})\\b`); + CodeMirror.defineMode('protocol-ml', function () { return { startState: function () { return { inString: false }; }, @@ -42,13 +54,75 @@ CodeMirror.defineMode('protocol-ml', function () { if (stream.match(/[<>]/)) return 'pml-side'; // Keywords - if (stream.match(/\b(def|participant)\b/)) return 'pml-keyword'; + if (stream.match(KEYWORDS_REGEX)) return 'pml-keyword'; + + // Settings + if (stream.match(SETTINGS_REGEX)) return 'pml-setting'; // Numbers with optional unit if (stream.match(/\d+(\.\d+)?(px|em|rem|%)?/)) return 'pml-value'; + // Aliases / Identifiers + if (stream.match(/[a-zA-Z_]\w*/)) return 'pml-alias'; + stream.next(); return null; - } + }, + lineComment: "//" }; }); + +CodeMirror.hint["protocol-ml"] = function (cm) { + const cursor = cm.getCursor(); + const line = cm.getLine(cursor.line); + let start = cursor.ch; + let end = cursor.ch; + + // Move start backwards to find the beginning of the word containing letters/numbers + while (start > 0 && /[A-Za-z0-9_]/.test(line.charAt(start - 1))) { + start--; + } + + let word = line.slice(start, end); + + // Find all participant aliases in the document + const docText = cm.getValue(); + const aliasRegex = /^participant\s+(?:(?:"[^"]+")|\S+)\s+(\w+)/gm; + const aliases = []; + let match; + while ((match = aliasRegex.exec(docText)) !== null) { + if (match[1] && !aliases.includes(match[1])) { + aliases.push(match[1]); + } + } + + + const allSuggestions = [...aliases, ...KEYWORDS, ...SETTINGS]; + let list = allSuggestions.filter(s => s.toLowerCase().startsWith(word.toLowerCase())); + + // Sort: aliases first, then keywords, then settings + list.sort((a, b) => { + const aIsAlias = aliases.includes(a); + const bIsAlias = aliases.includes(b); + if (aIsAlias && !bIsAlias) return -1; + if (!aIsAlias && bIsAlias) return 1; + + const aIsKeyword = KEYWORDS.includes(a); + const bIsKeyword = KEYWORDS.includes(b); + if (aIsKeyword && !bIsKeyword) return -1; + if (!aIsKeyword && bIsKeyword) return 1; + + return a.localeCompare(b); + }); + + return { + list: list, + from: CodeMirror.Pos(cursor.line, start), + to: CodeMirror.Pos(cursor.line, end) + }; +}; + +CodeMirror.registerHelper("hintWords", "protocol-ml", [ + ...SETTINGS, + ...KEYWORDS, +]); diff --git a/src/parser.ts b/src/parser.ts index ade2d8a..cb34f01 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -138,10 +138,10 @@ export function parse(src: string): Entities { const lines = src.split("\n"); for (let line of lines) { + // Strip inline comments, respecting quotes + line = line.replace(/("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')|\/\/.*$/g, (m: string, g1: string | undefined) => g1 ? g1 : "").trim(); - line = line.trim(); - - if (!line || line.startsWith("//")) continue; + if (!line) continue; // overwrite default settings if (line.startsWith("def ")) { From 7ac685bb5580748de56001bee2a545f96584c8b8 Mon Sep 17 00:00:00 2001 From: ys_teng <58208381+YeeShin504@users.noreply.github.com> Date: Sun, 22 Mar 2026 00:09:56 +0800 Subject: [PATCH 6/6] Preload jetbrains mono font --- docs/editor.css | 3 ++- docs/editor.js | 1 + docs/example.svg | 2 +- docs/index.html | 2 ++ 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/editor.css b/docs/editor.css index 3a4ef25..d089dbd 100644 --- a/docs/editor.css +++ b/docs/editor.css @@ -177,8 +177,9 @@ main { .ref-syntax { display: block; - font-family: inherit; + font-family: 'JetBrains Mono', 'Consolas', 'Monaco', monospace; font-size: 0.75rem; + font-variant-ligatures: normal; background: #1e1e1e; padding: 0.4rem 0.6rem; color: #ce9178; diff --git a/docs/editor.js b/docs/editor.js index 3727d2b..55674a0 100644 --- a/docs/editor.js +++ b/docs/editor.js @@ -205,6 +205,7 @@ if (refSearch) { const INITIAL_CODE = `def timeTickInterval 40px def participantSpacing 240px +def timeUnit /ms def showTimeTicks true def showGrid true diff --git a/docs/example.svg b/docs/example.svg index df0da80..fa4c471 100644 --- a/docs/example.svg +++ b/docs/example.svg @@ -21,7 +21,7 @@ fill="white" font-size="20" > - Time + Time /ms diff --git a/docs/index.html b/docs/index.html index 2e40cd1..04c6ebf 100644 --- a/docs/index.html +++ b/docs/index.html @@ -5,6 +5,8 @@ protocol-ml - Interactive Editor +