From 0e4d866bda3636d767f8b2e77ce1f3b1f3cb1f7b Mon Sep 17 00:00:00 2001 From: Abraham Date: Fri, 7 Aug 2026 18:25:22 -0700 Subject: [PATCH] feat(visimer): split edge popover, add double-headed arrow, fatter hitbox (#3372) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(visimer): split edge popover, add double-headed arrow, fatter hitbox Splits the merged "Edge type" popover into focused pickers: Arrow (heads only, including the double-headed variant that had no dispatch path), Stroke (solid/dotted/thick/invisible), Edge color, Animate edge, and Edge curve (diagram-wide). Adds arrowStart to setEdgeStyle plus setEdgeAnimation and setFlowCurve ops. Each edge also gets a transparent 14px hit overlay, so clicking one no longer needs pixel-precise aim. * fix(visimer): drop start-only arrow picker options that mermaid can't render Arrow (reverse), Cross (start), and Dot (start) all set arrowStart with arrowEnd:'open'. Mermaid's edge parser requires a >/x/o on the end side whenever the start carries a head, so edgeOpString's start-only fallback adds a > — silently producing <-->, x-->, o--> instead of the previewed single-headed forms. Drop the three options; reverseEdge is the way to flip direction. Adds two tests covering arrowStart:'cross' and setEdgeAnimation merging onto an already-colored linkStyle line. * fix(visimer): insert flow curve directive after YAML frontmatter, not before Mermaid's frontMatterRegex is ^-anchored with no /m flag, so a directive above the `---\ntitle: X\n---\n` block prevents extraction and the header lines reach the flowchart parser as syntax errors. patchFlowInitCurve now walks LineInfo for the last consecutive 'frontmatter' line and inserts after it, with an offset-0 fallback for the no-frontmatter case. * test(visimer): pin inverse merge-patch symmetry (animation then color) * fix(visimer): promote invisible edge to solid on head-set, restore edge hover glow Three small polish items from claude/pullfrog PR review: - setEdgeStyle now promotes an invisible link to solid when a non-open arrow head is picked. Previously the dispatch silently no-op'd because edgeOpString returns `~~~` unconditionally for the invisible case, and the picker would appear to accept an option that never landed. - Restored the hover drop-shadow on edge paths — the transparent hit overlay was catching :hover but had nothing to paint. New rule targets `[data-mw-hit-overlay]:hover + [data-mw-entity]` (overlay is inserted before the visible path, so `+` matches). - Added the missing test for setEdgeLabel empty-string preserving arrowStart on a bidirectional edge. GitOrigin-RevId: 784f461cc46ca811c7cd9c6edf5e9b6bab32f1e7 --- .../split-edge-toolbar-and-fatter-hitbox.md | 8 + packages/core/src/flowchart/ops.ts | 184 ++++++++++++-- packages/core/src/index.ts | 8 +- packages/core/test/core.test.ts | 119 +++++++++ packages/dom/src/correlate.ts | 37 +++ packages/dom/src/icons.ts | 5 + packages/dom/src/popover.ts | 37 ++- packages/dom/src/view.ts | 230 ++++++++++++++++-- 8 files changed, 574 insertions(+), 54 deletions(-) create mode 100644 .changeset/split-edge-toolbar-and-fatter-hitbox.md diff --git a/.changeset/split-edge-toolbar-and-fatter-hitbox.md b/.changeset/split-edge-toolbar-and-fatter-hitbox.md new file mode 100644 index 0000000..61ede9c --- /dev/null +++ b/.changeset/split-edge-toolbar-and-fatter-hitbox.md @@ -0,0 +1,8 @@ +--- +'@visimer/core': minor +'@visimer/dom': minor +--- + +Split the flowchart edge popover into focused pickers — Arrow (heads only, including a double-headed option), Stroke, Edge color, Animate edge (None/Slow/Fast), and Edge curve (Default/Natural/Linear, diagram-wide) — instead of the prior single "Edge type" panel that mixed line and head style. Adds `arrowStart` to `setEdgeStyle`, plus new `setEdgeAnimation` and `setFlowCurve` ops. + +Edges are also easier to click on the canvas: each rendered edge now carries a transparent 14px-stroke hit overlay, so selecting a flowchart / state / class / ER edge no longer requires pixel-precise aim. diff --git a/packages/core/src/flowchart/ops.ts b/packages/core/src/flowchart/ops.ts index c313833..e68ec5f 100644 --- a/packages/core/src/flowchart/ops.ts +++ b/packages/core/src/flowchart/ops.ts @@ -2,19 +2,24 @@ import type { LineInfo, TextEdit } from '../types' import type { FlowGraph, FlowEdge, FlowNode } from './graph' import { SHAPE_DELIMS, type ChainStmt, type EdgeArrow, type EdgeLine, type NodeRef, type ShapeId } from './parse' +export type EdgeAnimation = 'none' | 'slow' | 'fast' +export type FlowCurve = 'basis' | 'natural' | 'linear' + export type FlowchartOp = | { type: 'addNode'; shape?: ShapeId; label?: string; id?: string } | { type: 'connect'; source: string; target: string; line?: EdgeLine; arrowEnd?: EdgeArrow; label?: string } | { type: 'renameNode'; id: string; label: string } | { type: 'setNodeShape'; id: string; shape: ShapeId } | { type: 'setEdgeLabel'; edgeId: string; label: string } - | { type: 'setEdgeStyle'; edgeId: string; line?: EdgeLine; arrowEnd?: EdgeArrow } + | { type: 'setEdgeStyle'; edgeId: string; line?: EdgeLine; arrowEnd?: EdgeArrow; arrowStart?: EdgeArrow } | { type: 'setDirection'; direction: string } | { type: 'deleteNode'; id: string } | { type: 'deleteEdge'; edgeId: string } | { type: 'renameSubgraph'; id: string; title: string } | { type: 'setNodeColor'; id: string; prop: 'fill' | 'stroke' | 'color'; value: string | null } | { type: 'setEdgeColor'; edgeId: string; value: string | null } + | { type: 'setEdgeAnimation'; edgeId: string; value: EdgeAnimation } + | { type: 'setFlowCurve'; value: FlowCurve } | { type: 'reverseEdge'; edgeId: string } | { type: 'duplicateNode'; id: string } @@ -40,17 +45,26 @@ function printLabel(label: string, forceQuote: boolean): string { return clean } -function edgeOpString(line: EdgeLine, arrowEnd: EdgeArrow): string { +function edgeOpString(line: EdgeLine, arrowEnd: EdgeArrow, arrowStart: EdgeArrow = 'open'): string { const end = arrowEnd === 'arrow' ? '>' : arrowEnd === 'cross' ? 'x' : arrowEnd === 'circle' ? 'o' : '' + // Mermaid uses `<` for the arrow head on the source side; `x`/`o` are the + // same characters as their end-side counterparts. `open` means no head. + const start = arrowStart === 'arrow' ? '<' : arrowStart === 'cross' ? 'x' : arrowStart === 'circle' ? 'o' : '' switch (line) { case 'thick': - return end ? `==${end}` : '===' + // `x==` doesn't tokenize as an edge — the "solid arrow at end" path + // requires a `>`/`x`/`o` on the end when the start carries a head, so + // callers should only set `arrowStart` alongside a non-`open` `arrowEnd` + if (start && !end) return `${start}==>` + return end ? `${start}==${end}` : '===' case 'dotted': - return end ? `-.-${end}` : '-.-' + if (start && !end) return `${start}-.->` + return end ? `${start}-.-${end}` : '-.-' case 'invisible': return '~~~' default: - return end ? `--${end}` : '---' + if (start && !end) return `${start}-->` + return end ? `${start}--${end}` : '---' } } @@ -229,6 +243,98 @@ function nodeOf(graph: FlowGraph, id: string): FlowNode | null { return graph.nodeById.get(id) ?? null } +/** + * Merge-patch the `linkStyle N` declarations for a given edge — parses the + * existing k:v pairs, applies `patch` (a null value deletes the key), and + * rewrites or inserts the line. Prevents callers from stomping unrelated + * declarations (e.g. setting animation shouldn't drop the stroke color). + */ +function patchEdgeLinkStyle( + ctx: FlowOpContext, + graph: FlowGraph, + edgeId: string, + patch: Record, +): OpResult | null { + const edge = findEdge(graph, edgeId) + if (!edge) return null + let linkLine = -1 + for (const [lineIndex, stmt] of graph.statements) { + if (stmt.kind !== 'linkStyle') continue + const m = /^linkStyle\s+(\d+)\b/.exec(ctx.lines[lineIndex].text.trim()) + if (m && Number(m[1]) === edge.order) { + linkLine = lineIndex + break + } + } + const props = new Map() + if (linkLine >= 0) { + const rest = ctx.lines[linkLine].text.trim().replace(/^linkStyle\s+\d+\s*/, '') + for (const pair of rest.split(',')) { + const i = pair.indexOf(':') + if (i > 0) props.set(pair.slice(0, i).trim(), pair.slice(i + 1).trim()) + } + } + for (const [key, value] of Object.entries(patch)) { + if (value === null) props.delete(key) + else props.set(key, value) + } + if (props.size === 0) { + if (linkLine === -1) return { edits: [] } + return { edits: [deleteLineEdit(ctx, linkLine)] } + } + const indent = linkLine >= 0 ? ctx.lines[linkLine].indent : bodyIndent(ctx) + const text = `${indent}linkStyle ${edge.order} ${[...props].map(([k, v]) => `${k}:${v}`).join(',')}` + if (linkLine >= 0) return { edits: [replaceLineEdit(ctx, linkLine, [text])] } + return { edits: [insertLinesAfter(ctx, graph.lastContentLine, [text])] } +} + +/** + * Absolute offset to insert a directive at — after any YAML frontmatter + * block, at document start otherwise. `LineInfo.end` is BEFORE the newline, + * so `+ 1` lands us at the start of the following line. + */ +function frontmatterInsertOffset(ctx: FlowOpContext): number { + let lastFrontmatter = -1 + for (const line of ctx.lines) { + if (line.kind === 'frontmatter') lastFrontmatter = line.index + else if (lastFrontmatter >= 0) break + } + if (lastFrontmatter < 0) return 0 + return ctx.lines[lastFrontmatter].end + 1 +} + +/** + * Patch the diagram-level `%%{init: {flowchart: {curve: X}}}%%` directive. + * Text-level: detects an existing init line via regex and replaces just the + * curve declaration, otherwise inserts a fresh directive at the very top. + * `basis` is mermaid's default; setting it removes any prior directive. + */ +function patchFlowInitCurve(ctx: FlowOpContext, value: FlowCurve): OpResult { + const initRe = /^\s*%%\{\s*init\s*:\s*(\{[\s\S]*?\})\s*\}%%\s*$/m + const m = initRe.exec(ctx.code) + const setDirective = `%%{init: {'flowchart': {'curve': '${value}'}}}%%` + if (!m) { + if (value === 'basis') return { edits: [] } + // The init directive must precede the flowchart header — but ONLY under + // any YAML frontmatter. Mermaid's `frontMatterRegex` is `^`-anchored with + // no /m flag, so a directive above `---\ntitle: X\n---\n` prevents front- + // matter extraction and the header lines reach the flowchart parser as + // syntax errors. `classifyLines` treats frontmatter as a first-class kind, + // so honoring its span is the right insertion point. + const insertAt = frontmatterInsertOffset(ctx) + return { edits: [{ start: insertAt, end: insertAt, text: `${setDirective}\n` }] } + } + // Rewrite the whole init line to keep this simple — the alternative is + // parsing/merging JSON-ish blobs, which mermaid's tolerant syntax makes + // fragile. Nested settings besides `flowchart.curve` are rare in visimer- + // generated code; when a user hand-wrote a rich directive, they can + // re-declare it after. + const start = m.index + m[0].search(/%%\{/) + const end = m.index + m[0].length + const replacement = value === 'basis' ? '' : setDirective + return { edits: [{ start, end, text: replacement }] } +} + // ---------- compiler ---------- export function compileFlowchartOp(ctx: FlowOpContext, op: FlowchartOp): OpResult | null { @@ -316,7 +422,7 @@ export function compileFlowchartOp(ctx: FlowOpContext, op: FlowchartOp): OpResul if (edge.seg.labelSpan) { if (label === '') { // remove the label entirely by rewriting the operator - const opStr = edgeOpString(edge.seg.line, edge.seg.arrowEnd) + const opStr = edgeOpString(edge.seg.line, edge.seg.arrowEnd, edge.seg.arrowStart) return { edits: [{ start: edge.seg.span.start, end: edge.seg.span.end, text: opStr }] } } return { edits: [{ start: edge.seg.labelSpan.start, end: edge.seg.labelSpan.end, text: label }] } @@ -328,9 +434,18 @@ export function compileFlowchartOp(ctx: FlowOpContext, op: FlowchartOp): OpResul case 'setEdgeStyle': { const edge = findEdge(graph, op.edgeId) if (!edge) return null - const line = op.line ?? edge.seg.line + let line = op.line ?? edge.seg.line const arrowEnd = op.arrowEnd ?? edge.seg.arrowEnd - const opStr = edgeOpString(line, arrowEnd) + const arrowStart = op.arrowStart ?? edge.seg.arrowStart + // Invisible links (`~~~`) render as a layout hint with no visible edge — + // mermaid ignores head markers on them, and `edgeOpString` therefore + // returns `'~~~'` unconditionally. If a caller sets a head on what's + // currently invisible, promote the line to `solid` so the head can + // actually render; otherwise the dispatch would silently no-op. + if (line === 'invisible' && (arrowEnd !== 'open' || arrowStart !== 'open')) { + line = 'solid' + } + const opStr = edgeOpString(line, arrowEnd, arrowStart) const label = edge.seg.label !== null ? `|${edge.seg.label.replace(/\|/g, '')}|` : '' return { edits: [{ start: edge.seg.span.start, end: edge.seg.span.end, text: `${opStr}${label}` }] } } @@ -463,26 +578,41 @@ export function compileFlowchartOp(ctx: FlowOpContext, op: FlowchartOp): OpResul } case 'setEdgeColor': { - const edge = findEdge(graph, op.edgeId) - if (!edge) return null - // linkStyle addresses edges by render index (== document order) - let linkLine = -1 - for (const [lineIndex, stmt] of graph.statements) { - if (stmt.kind !== 'linkStyle') continue - const m = /^linkStyle\s+(\d+)\b/.exec(ctx.lines[lineIndex].text.trim()) - if (m && Number(m[1]) === edge.order) { - linkLine = lineIndex - break - } - } - if (op.value === null) { - if (linkLine === -1) return { edits: [] } - return { edits: [deleteLineEdit(ctx, linkLine)] } + return patchEdgeLinkStyle(ctx, graph, op.edgeId, { + stroke: op.value, + 'stroke-width': op.value === null ? null : '2px', + }) + } + + case 'setEdgeAnimation': { + // `none` clears the animation-* + stroke-dasharray keys but leaves any + // other declarations (color, width) intact — hence the null-per-key + // rather than deleting the whole linkStyle line. + if (op.value === 'none') { + return patchEdgeLinkStyle(ctx, graph, op.edgeId, { + 'stroke-dasharray': null, + 'animation-name': null, + 'animation-duration': null, + 'animation-timing-function': null, + 'animation-iteration-count': null, + }) } - const indent = linkLine >= 0 ? ctx.lines[linkLine].indent : bodyIndent(ctx) - const text = `${indent}linkStyle ${edge.order} stroke:${op.value},stroke-width:2px` - if (linkLine >= 0) return { edits: [replaceLineEdit(ctx, linkLine, [text])] } - return { edits: [insertLinesAfter(ctx, graph.lastContentLine, [text])] } + const duration = op.value === 'slow' ? '2s' : '0.6s' + // Long-hand keys because mermaid's `linkStyle` parser splits declarations + // on `,` — the `animation` shorthand's own comma-separated form would be + // shredded across pseudo-declarations. `vsmr-flow` is injected as a + // @keyframes rule once per SVG by the renderer host. + return patchEdgeLinkStyle(ctx, graph, op.edgeId, { + 'stroke-dasharray': '8', + 'animation-name': 'vsmr-flow', + 'animation-duration': duration, + 'animation-timing-function': 'linear', + 'animation-iteration-count': 'infinite', + }) + } + + case 'setFlowCurve': { + return patchFlowInitCurve(ctx, op.value) } case 'renameSubgraph': { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 20b6438..9fbe5d6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -23,7 +23,13 @@ export { type FlowEdge, type FlowSubgraph, } from './flowchart/graph' -export { compileFlowchartOp, type FlowchartOp, type OpResult } from './flowchart/ops' +export { + compileFlowchartOp, + type FlowchartOp, + type OpResult, + type EdgeAnimation, + type FlowCurve, +} from './flowchart/ops' export { parseSequenceStatement, PARTICIPANT_TYPES, diff --git a/packages/core/test/core.test.ts b/packages/core/test/core.test.ts index 37f68d5..2f6ed67 100644 --- a/packages/core/test/core.test.ts +++ b/packages/core/test/core.test.ts @@ -238,6 +238,125 @@ describe('ops → minimal text edits', () => { ed.dispatch({ type: 'setEdgeColor', edgeId: again.entityId, value: null }) expect(ed.code).not.toContain('linkStyle') }) + + it('setEdgeStyle round-trips arrowStart for the double-headed arrow', () => { + const ed = new MermaidWysiwygEditor({ code: 'flowchart LR\n A --> B\n' }) + const edgeId = ed.result.flowchart!.edges[0].entityId + ed.dispatch({ type: 'setEdgeStyle', edgeId, arrowStart: 'arrow', arrowEnd: 'arrow' }) + expect(ed.code).toBe('flowchart LR\n A <--> B\n') + // and back + const edgeId2 = ed.result.flowchart!.edges[0].entityId + ed.dispatch({ type: 'setEdgeStyle', edgeId: edgeId2, arrowStart: 'open' }) + expect(ed.code).toBe('flowchart LR\n A --> B\n') + }) + + it('setEdgeStyle preserves label + line when only heads change', () => { + const ed = new MermaidWysiwygEditor({ code: 'flowchart LR\n A -.->|maybe| B\n' }) + const edgeId = ed.result.flowchart!.edges[0].entityId + ed.dispatch({ type: 'setEdgeStyle', edgeId, arrowStart: 'circle', arrowEnd: 'circle' }) + expect(ed.code).toBe('flowchart LR\n A o-.-o|maybe| B\n') + }) + + it('setEdgeStyle emits x on both ends for cross/cross', () => { + const ed = new MermaidWysiwygEditor({ code: 'flowchart LR\n A --> B\n' }) + const edgeId = ed.result.flowchart!.edges[0].entityId + ed.dispatch({ type: 'setEdgeStyle', edgeId, arrowStart: 'cross', arrowEnd: 'cross' }) + expect(ed.code).toBe('flowchart LR\n A x--x B\n') + }) + + it('setEdgeStyle promotes an invisible link to solid when a head is added', () => { + // Mermaid ignores head markers on `~~~` links, so without the promotion + // the dispatch would silently no-op and the picker would appear to + // accept an option that never landed. + const ed = new MermaidWysiwygEditor({ code: 'flowchart LR\n A ~~~ B\n' }) + const edgeId = ed.result.flowchart!.edges[0].entityId + ed.dispatch({ type: 'setEdgeStyle', edgeId, arrowEnd: 'arrow' }) + expect(ed.code).toBe('flowchart LR\n A --> B\n') + }) + + it('setEdgeLabel empty string preserves arrowStart on a bidirectional edge', () => { + // Pins the load-bearing arrowStart pass-through in the empty-label + // rewrite path — reverting to the 2-arg edgeOpString call would silently + // drop `<` and regress `<-->` to `-->`. + const ed = new MermaidWysiwygEditor({ code: 'flowchart LR\n A <-->|go| B\n' }) + const edgeId = ed.result.flowchart!.edges[0].entityId + ed.dispatch({ type: 'setEdgeLabel', edgeId, label: '' }) + expect(ed.code).toBe('flowchart LR\n A <--> B\n') + }) + + it('setEdgeAnimation merges onto an existing colored linkStyle without dropping the color', () => { + const ed = new MermaidWysiwygEditor({ code: 'flowchart LR\n A --> B\n' }) + const edgeId = ed.result.flowchart!.edges[0].entityId + ed.dispatch({ type: 'setEdgeColor', edgeId, value: '#06b6d4' }) + const edgeId2 = ed.result.flowchart!.edges[0].entityId + ed.dispatch({ type: 'setEdgeAnimation', edgeId: edgeId2, value: 'slow' }) + expect(ed.code).toContain('stroke:#06b6d4') + expect(ed.code).toContain('animation-duration:2s') + // both live on the same linkStyle 0 line, not two separate declarations + expect(ed.code.match(/linkStyle 0 /g)?.length).toBe(1) + }) + + it('setEdgeColor merges onto an existing animated linkStyle without dropping the animation', () => { + // Inverse direction of the merge test above — pins symmetry of + // patchEdgeLinkStyle so a future rewrite that made writes order- + // dependent would trip both cases. + const ed = new MermaidWysiwygEditor({ code: 'flowchart LR\n A --> B\n' }) + const edgeId = ed.result.flowchart!.edges[0].entityId + ed.dispatch({ type: 'setEdgeAnimation', edgeId, value: 'fast' }) + const edgeId2 = ed.result.flowchart!.edges[0].entityId + ed.dispatch({ type: 'setEdgeColor', edgeId: edgeId2, value: '#22c55e' }) + expect(ed.code).toContain('animation-duration:0.6s') + expect(ed.code).toContain('stroke:#22c55e') + expect(ed.code.match(/linkStyle 0 /g)?.length).toBe(1) + }) + + it('setEdgeAnimation adds/updates/clears the linkStyle animation keys', () => { + const ed = new MermaidWysiwygEditor({ code: 'flowchart LR\n A --> B\n' }) + const edgeId = ed.result.flowchart!.edges[0].entityId + ed.dispatch({ type: 'setEdgeAnimation', edgeId, value: 'slow' }) + expect(ed.code).toContain('linkStyle 0 ') + expect(ed.code).toContain('animation-duration:2s') + expect(ed.code).toContain('stroke-dasharray:8') + // switching to fast rewrites the duration, keeps the same line + const edgeId2 = ed.result.flowchart!.edges[0].entityId + ed.dispatch({ type: 'setEdgeAnimation', edgeId: edgeId2, value: 'fast' }) + expect(ed.code).toContain('animation-duration:0.6s') + expect(ed.code.match(/linkStyle 0 /g)?.length).toBe(1) + // none clears the animation keys but leaves the line if other decls remain + const edgeId3 = ed.result.flowchart!.edges[0].entityId + ed.dispatch({ type: 'setEdgeColor', edgeId: edgeId3, value: '#f00' }) + ed.dispatch({ type: 'setEdgeAnimation', edgeId: edgeId3, value: 'none' }) + expect(ed.code).toContain('linkStyle 0 stroke:#f00') + expect(ed.code).not.toContain('animation-duration') + }) + + it('setFlowCurve preserves YAML frontmatter — directive lands after the closing ---', () => { + const code = '---\ntitle: My Diagram\n---\nflowchart LR\n A --> B\n' + const ed = new MermaidWysiwygEditor({ code }) + ed.dispatch({ type: 'setFlowCurve', value: 'linear' }) + // frontmatter must still be at the very start so mermaid's ^-anchored + // frontMatterRegex can extract it; directive sits between frontmatter + // and the flowchart header + expect(ed.code.startsWith('---\ntitle: My Diagram\n---\n')).toBe(true) + expect(ed.code).toContain("%%{init: {'flowchart': {'curve': 'linear'}}}%%") + expect(ed.code).toMatch(/---\n%%\{init/) + // still parses as one flowchart edge + expect(ed.result.flowchart!.edges.length).toBe(1) + }) + + it('setFlowCurve inserts and replaces the init directive; basis removes it', () => { + const ed = new MermaidWysiwygEditor({ code: 'flowchart LR\n A --> B\n' }) + ed.dispatch({ type: 'setFlowCurve', value: 'linear' }) + expect(ed.code).toContain("%%{init: {'flowchart': {'curve': 'linear'}}}%%") + ed.dispatch({ type: 'setFlowCurve', value: 'natural' }) + expect(ed.code).toContain("'curve': 'natural'") + expect(ed.code).not.toContain("'curve': 'linear'") + // one directive, still parseable + expect(ed.result.flowchart!.edges.length).toBe(1) + // basis is the mermaid default: clear the directive + ed.dispatch({ type: 'setFlowCurve', value: 'basis' }) + expect(ed.code).not.toContain('%%{init') + }) }) describe('editor state', () => { diff --git a/packages/dom/src/correlate.ts b/packages/dom/src/correlate.ts index e18fc70..cebdaf9 100644 --- a/packages/dom/src/correlate.ts +++ b/packages/dom/src/correlate.ts @@ -21,6 +21,38 @@ function esc(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } +/** + * Widen a mermaid edge's click surface — clones the visible `` as a + * transparent 14px-stroke sibling that carries the same `data-mw-entity`, so + * hit-testing walks up to it from within a ~7px radius on either side of the + * visible line. `pointer-events: stroke` keeps the interior fill inert; the + * overlay is painted BEFORE the visible path so the arrow head, cross, or + * dot still visually wins overlap, and it inherits the same paint transform. + */ +function addEdgeHitOverlay(path: SVGElement, entityId: string): void { + const doc = path.ownerDocument + if (!doc || path.hasAttribute('data-mw-hit-overlay')) return + const parent = path.parentNode + if (!parent) return + // Skip if a prior render already added an overlay for this edge — mermaid + // reuses group elements across re-renders, and dagre's dedupe pass will + // otherwise stack overlays until the popover picker starts glitching. + const priorOverlay = (parent as ParentNode).querySelector(`[data-mw-hit-overlay="${CSS.escape(entityId)}"]`) + if (priorOverlay) return + const overlay = doc.createElementNS('http://www.w3.org/2000/svg', 'path') + const d = path.getAttribute('d') ?? '' + overlay.setAttribute('d', d) + overlay.setAttribute('fill', 'none') + overlay.setAttribute('stroke', 'transparent') + overlay.setAttribute('stroke-width', '14') + overlay.setAttribute('stroke-linecap', 'round') + overlay.setAttribute('data-mw-entity', entityId) + overlay.setAttribute('data-mw-hit-overlay', entityId) + overlay.style.pointerEvents = 'stroke' + overlay.style.cursor = 'pointer' + parent.insertBefore(overlay, path) +} + /** * Correlate mermaid's rendered flowchart SVG with the semantic graph. * Strategy: id conventions first (`flowchart--N`, `L___N`), @@ -65,6 +97,7 @@ export function correlateFlowchart(svg: SVGSVGElement, graph: FlowGraph): Correl edges.set(edge.entityId, el) claimedEdges.add(el) el.setAttribute('data-mw-entity', edge.entityId) + addEdgeHitOverlay(el, edge.entityId) } } const unmatchedEdges = graph.edges.filter((e) => !edges.has(e.entityId)) @@ -73,6 +106,7 @@ export function correlateFlowchart(svg: SVGSVGElement, graph: FlowGraph): Correl unmatchedEdges.forEach((e, i) => { edges.set(e.entityId, unclaimedEdgeEls[i]) unclaimedEdgeEls[i].setAttribute('data-mw-entity', e.entityId) + addEdgeHitOverlay(unclaimedEdgeEls[i], e.entityId) }) } else { for (const e of unmatchedEdges) failed.push(e.entityId) @@ -161,6 +195,7 @@ export function correlateState(svg: SVGSVGElement, graph: StateGraph): Correlati graph.transitions.forEach((t, i) => { edges.set(t.entityId, edgeEls[i]) edgeEls[i].setAttribute('data-mw-entity', t.entityId) + addEdgeHitOverlay(edgeEls[i], t.entityId) }) } else { for (const t of graph.transitions) failed.push(t.entityId) @@ -237,6 +272,7 @@ export function correlateClass(svg: SVGSVGElement, graph: ClassGraph): Correlati graph.relations.forEach((r, i) => { edges.set(r.entityId, edgeEls[i]) edgeEls[i].setAttribute('data-mw-entity', r.entityId) + addEdgeHitOverlay(edgeEls[i], r.entityId) }) } else { for (const r of graph.relations) failed.push(r.entityId) @@ -305,6 +341,7 @@ export function correlateEr(svg: SVGSVGElement, graph: ErGraph): Correlation { graph.relations.forEach((r, i) => { edges.set(r.entityId, edgeEls[i]) edgeEls[i].setAttribute('data-mw-entity', r.entityId) + addEdgeHitOverlay(edgeEls[i], r.entityId) }) } else { for (const r of graph.relations) failed.push(r.entityId) diff --git a/packages/dom/src/icons.ts b/packages/dom/src/icons.ts index 189d197..bb4598f 100644 --- a/packages/dom/src/icons.ts +++ b/packages/dom/src/icons.ts @@ -51,4 +51,9 @@ export const ICONS = { ), ellipsis: icon(''), chevronDown: icon(''), + equal: icon(''), + spline: icon( + '', + ), + circlePlay: icon(''), } diff --git a/packages/dom/src/popover.ts b/packages/dom/src/popover.ts index 2dc2b7d..95f28f5 100644 --- a/packages/dom/src/popover.ts +++ b/packages/dom/src/popover.ts @@ -18,9 +18,13 @@ export interface PopoverAction { export interface PopoverPanelItem { /** text glyph for the cell */ glyph?: string - /** color swatch cell (takes precedence over glyph) */ + /** inline svg markup for the cell (takes precedence over glyph) */ + svg?: string + /** color swatch cell (takes precedence over glyph and svg) */ swatch?: string title: string + /** shown under the cell as a text label (e.g. "Slow" / "Fast" / "Default") */ + label?: string selected?: boolean onClick: () => void } @@ -132,7 +136,8 @@ export class Popover { for (const item of section.items) { const b = document.createElement('button') b.type = 'button' - b.className = 'mw-popover-cell' + (item.selected ? ' selected' : '') + (item.swatch ? ' swatch' : '') + const kindClass = item.swatch ? ' swatch' : item.svg ? ' svg' : '' + b.className = 'mw-popover-cell' + (item.selected ? ' selected' : '') + kindClass + (item.label ? ' labeled' : '') b.title = item.title if (item.swatch) { const dot = document.createElement('span') @@ -140,8 +145,22 @@ export class Popover { if (item.swatch === 'none') dot.classList.add('none') else dot.style.background = item.swatch b.appendChild(dot) + } else if (item.svg) { + const g = document.createElement('span') + g.className = 'mw-cell-svg' + g.innerHTML = item.svg + b.appendChild(g) } else { - b.textContent = item.glyph ?? '' + const g = document.createElement('span') + g.className = 'mw-cell-glyph' + g.textContent = item.glyph ?? '' + b.appendChild(g) + } + if (item.label) { + const lbl = document.createElement('span') + lbl.className = 'mw-cell-label' + lbl.textContent = item.label + b.appendChild(lbl) } b.addEventListener('click', (e) => { e.stopPropagation() @@ -194,12 +213,12 @@ export const POPOVER_CSS = ` position: absolute; bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%); background: var(--mw-chrome-bg, #0a0a0a); border: 1px solid var(--mw-chrome-border, #333); border-radius: 8px; box-shadow: 0 0 0 1px rgba(0,0,0,0.4), 0 8px 30px rgba(0,0,0,0.45); - padding: 6px; min-width: 140px; + padding: 4px; min-width: 0; } .mw-popover-panel.below { bottom: auto; top: calc(100% + 6px); } .mw-popover-panel-title { - font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.07em; - color: var(--mw-chrome-dim, #666); padding: 3px 5px 7px; white-space: nowrap; + font-size: 9.5px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.07em; + color: var(--mw-chrome-dim, #666); padding: 2px 4px 4px; white-space: nowrap; } .mw-popover-section-title { font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.06em; @@ -217,6 +236,12 @@ export const POPOVER_CSS = ` background: color-mix(in srgb, var(--mw-accent, #0070f3) 12%, transparent); } .mw-popover-cell.swatch { padding: 5px; display: flex; align-items: center; justify-content: center; } +.mw-popover-cell.svg { padding: 4px 6px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; } +.mw-popover-cell.svg .mw-cell-svg { display: inline-flex; } +.mw-popover-cell.svg svg { width: 26px; height: 12px; display: block; } +.mw-popover-cell.labeled { display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 4px 6px; } +.mw-popover-cell.labeled svg { width: 18px; height: 18px; } +.mw-popover-cell .mw-cell-label { font-size: 9.5px; color: inherit; opacity: 0.8; } .mw-swatch-dot { width: 15px; height: 15px; border-radius: 99px; display: block; border: 1px solid rgba(255,255,255,0.18); } .mw-swatch-dot.none { background: linear-gradient(to top left, transparent 45%, #ff4444 46%, #ff4444 54%, transparent 55%); diff --git a/packages/dom/src/view.ts b/packages/dom/src/view.ts index ea4158e..2cbd885 100644 --- a/packages/dom/src/view.ts +++ b/packages/dom/src/view.ts @@ -3,11 +3,13 @@ import { MESSAGE_OPS, PARTICIPANT_TYPES, type Diagnostic, - type ShapeId, - type EdgeLine, + type EdgeAnimation, type EdgeArrow, + type EdgeLine, + type FlowCurve, type MessageOp, type ParticipantType, + type ShapeId, } from '@visimer/core' import { correlateClass, @@ -33,6 +35,101 @@ import { ICONS } from './icons' /** default swatches offered in the color panels (works on light and dark themes) */ export const COLOR_PALETTE = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#6366f1', '#d946ef', '#64748b'] +// ---------- edge popover previews + read-back helpers ---------- + +/** SVG preview of the arrow-head combination for the Arrow picker cells. */ +function arrowHeadSvg(line: EdgeLine, start: EdgeArrow, end: EdgeArrow): string { + const dash = line === 'dotted' ? 'stroke-dasharray="2 2"' : line === 'invisible' ? 'stroke-dasharray="1 3" opacity="0.5"' : '' + const w = line === 'thick' ? '2.5' : '1.5' + const x1 = start === 'open' ? 4 : 8 + const x2 = end === 'open' ? 28 : 24 + const headStart = arrowHeadMark(start, 'start') + const headEnd = arrowHeadMark(end, 'end') + return ` + + ${headStart}${headEnd} + ` +} + +/** Inline mark for one end of an arrow preview. `side` selects orientation. */ +function arrowHeadMark(kind: EdgeArrow, side: 'start' | 'end'): string { + if (kind === 'open') return '' + if (kind === 'circle') { + const cx = side === 'start' ? 4 : 28 + return `` + } + if (kind === 'cross') { + const cx = side === 'start' ? 4 : 28 + return `` + } + // filled triangle for `arrow` + if (side === 'start') return `` + return `` +} + +/** SVG preview of a stroke style — no arrow heads, wide enough to see dash. */ +function strokeSvg(line: EdgeLine): string { + const dash = line === 'dotted' ? 'stroke-dasharray="3 3"' : line === 'invisible' ? 'stroke-dasharray="1 3" opacity="0.35"' : '' + const w = line === 'thick' ? '3' : '1.5' + return ` + + ` +} + +/** SVG preview of one of the mermaid curve shapes. */ +function curveSvg(curve: FlowCurve): string { + const path = curve === 'linear' + ? 'M4,14 L14,4 L28,14' + : curve === 'natural' + ? 'M4,14 Q16,-2 28,14' + : 'M4,14 C4,4 28,4 28,14' + return ` + + ` +} + +/** SVG preview for the animation-speed picker; `none` gets a slashed circle. */ +function animSvg(v: EdgeAnimation): string { + if (v === 'none') { + return ` + + ` + } + // filled arc suggesting motion; `fast` fills more of the circle + const arc = v === 'fast' ? 'M12,3 A9,9 0 0,1 12,21 A9,9 0 0,1 6,5' : 'M12,3 A9,9 0 0,1 20,17' + return ` + + ` +} + +/** Read the current diagram-wide curve from the init directive (or 'basis' default). */ +function readFlowCurve(code: string): FlowCurve { + const m = /%%\{\s*init[\s\S]*?curve\s*:\s*['"]?(basis|natural|linear)['"]?/i.exec(code) + return (m?.[1] as FlowCurve | undefined) ?? 'basis' +} + +/** Read the animation speed for an edge's linkStyle line (or 'none'). */ +function readEdgeAnimation(code: string, edgeOrder: number): EdgeAnimation { + const re = new RegExp(`linkStyle\\s+${edgeOrder}\\b[^\\n]*animation-duration\\s*:\\s*([0-9.]+)s`, 'i') + const m = re.exec(code) + if (!m) return 'none' + return Number(m[1]) <= 1 ? 'fast' : 'slow' +} + +/** + * Inject the `@keyframes vsmr-flow` rule that the `linkStyle` animation emitter + * references. Called once per render so a re-render replacing the SVG picks + * the rule back up; idempotent within a single SVG. + */ +function injectAnimationKeyframes(svg: SVGElement): void { + if (svg.querySelector('style[data-vsmr-flow]')) return + const doc = svg.ownerDocument + const style = doc.createElementNS('http://www.w3.org/2000/svg', 'style') + style.setAttribute('data-vsmr-flow', '') + style.textContent = '@keyframes vsmr-flow { to { stroke-dashoffset: -20; } }' + svg.insertBefore(style, svg.firstChild) +} + export interface MermaidLike { initialize(config: Record): void render(id: string, code: string): Promise<{ svg: string; bindFunctions?: (el: Element) => void }> @@ -95,6 +192,10 @@ const BASE_CSS = ` .mw-canvas.mw-readonly [data-mw-entity] { cursor: default; } .mw-canvas.mw-tool-connect [data-mw-entity^="node:"] { cursor: crosshair; } .mw-canvas [data-mw-entity]:hover { filter: drop-shadow(0 0 3px var(--mw-accent, #6366f1)); } +/* The transparent hit overlay eats the hover on visible edge paths — restore + * the drop-shadow on the sibling visible path when the overlay is hovered. + * Overlay is placed BEFORE the visible path (insertBefore), so `+` matches. */ +.mw-canvas [data-mw-hit-overlay]:hover + [data-mw-entity] { filter: drop-shadow(0 0 3px var(--mw-accent, #6366f1)); } .mw-canvas .mw-selected { filter: drop-shadow(0 0 2px var(--mw-accent, #6366f1)) drop-shadow(0 0 5px var(--mw-accent, #6366f1)) !important; } .mw-canvas .mw-ghost-edge { stroke: var(--mw-accent, #6366f1); stroke-width: 2; stroke-dasharray: 6 4; fill: none; pointer-events: none; } .mw-canvas .mw-connect-source { filter: drop-shadow(0 0 5px var(--mw-accent, #6366f1)) !important; } @@ -675,6 +776,7 @@ export class MermaidCanvasView { if (this.svg) { if (this.panZoomEnabled) this.prepareSvgForPanZoom() else this.svg.style.maxWidth = '100%' + injectAnimationKeyframes(this.svg) this.bindSvg() } this.editor.setDiagnostics([]) @@ -1698,29 +1800,79 @@ export class MermaidCanvasView { if (flowchart && id.startsWith('edge:')) { const edge = flowchart.edges.find((e) => e.entityId === id) if (!edge) return [] - const kinds: Array<{ line: EdgeLine; arrowEnd: EdgeArrow; g: string; t: string }> = [ - { line: 'solid', arrowEnd: 'arrow', g: '—▶', t: 'solid arrow' }, - { line: 'dotted', arrowEnd: 'arrow', g: '⋯▶', t: 'dotted arrow' }, - { line: 'thick', arrowEnd: 'arrow', g: '═▶', t: 'thick arrow' }, - { line: 'solid', arrowEnd: 'open', g: '——', t: 'solid open' }, - { line: 'dotted', arrowEnd: 'open', g: '⋯⋯', t: 'dotted open' }, - { line: 'thick', arrowEnd: 'open', g: '══', t: 'thick open' }, - { line: 'solid', arrowEnd: 'cross', g: '—✕', t: 'solid cross' }, - { line: 'solid', arrowEnd: 'circle', g: '—●', t: 'solid circle' }, + // Mermaid can't render single-headed reverse arrows — its edge parser + // requires a `>`/`x`/`o` on the end side whenever the start carries a + // head, so `<--` silently becomes `<-->`. Only offer combos mermaid + // renders faithfully; `reverseEdge` is the way to flip direction. + const arrows: Array<{ start: EdgeArrow; end: EdgeArrow; t: string }> = [ + { start: 'open', end: 'open', t: 'No arrow' }, + { start: 'open', end: 'arrow', t: 'Arrow' }, + { start: 'arrow', end: 'arrow', t: 'Double point' }, + { start: 'open', end: 'cross', t: 'Cross' }, + { start: 'cross', end: 'cross', t: 'Double cross' }, + { start: 'open', end: 'circle', t: 'Dot' }, + { start: 'circle', end: 'circle', t: 'Double dot' }, + ] + const strokes: Array<{ line: EdgeLine; t: string }> = [ + { line: 'solid', t: 'Solid' }, + { line: 'dotted', t: 'Dotted' }, + { line: 'thick', t: 'Thick' }, + { line: 'invisible', t: 'Invisible' }, ] + const curves: Array<{ v: FlowCurve; t: string; label: string; svg: string }> = [ + { v: 'basis', t: 'Default (curved)', label: 'Default', svg: curveSvg('basis') }, + { v: 'natural', t: 'Natural spline', label: 'Natural', svg: curveSvg('natural') }, + { v: 'linear', t: 'Straight segments', label: 'Linear', svg: curveSvg('linear') }, + ] + const anims: Array<{ v: EdgeAnimation; t: string; label: string; svg: string }> = [ + { v: 'none', t: 'No animation', label: 'None', svg: animSvg('none') }, + { v: 'slow', t: 'Slow flow', label: 'Slow', svg: animSvg('slow') }, + { v: 'fast', t: 'Fast flow', label: 'Fast', svg: animSvg('fast') }, + ] + const currentCurve = readFlowCurve(this.editor.code) + const currentAnim = readEdgeAnimation(this.editor.code, edge.order) return [ { icon: ICONS.arrowRight, - title: 'Edge type', + title: 'Arrow', panel: { - title: 'Edge type', - items: kinds.map((k) => ({ - glyph: k.g, - title: k.t, - selected: edge.seg.line === k.line && edge.seg.arrowEnd === k.arrowEnd, - onClick: () => - this.editor.dispatch({ type: 'setEdgeStyle', edgeId: id, line: k.line, arrowEnd: k.arrowEnd }), - })), + title: 'Arrow', + sections: [ + { + columns: 4, + items: arrows.map((a) => ({ + svg: arrowHeadSvg(edge.seg.line, a.start, a.end), + title: a.t, + selected: edge.seg.arrowStart === a.start && edge.seg.arrowEnd === a.end, + onClick: () => + this.editor.dispatch({ + type: 'setEdgeStyle', + edgeId: id, + arrowStart: a.start, + arrowEnd: a.end, + }), + })), + }, + ], + }, + }, + { + icon: ICONS.equal, + title: 'Stroke', + panel: { + title: 'Stroke', + sections: [ + { + columns: 4, + items: strokes.map((s) => ({ + svg: strokeSvg(s.line), + title: s.t, + label: s.t, + selected: edge.seg.line === s.line, + onClick: () => this.editor.dispatch({ type: 'setEdgeStyle', edgeId: id, line: s.line }), + })), + }, + ], }, }, { @@ -1731,6 +1883,44 @@ export class MermaidCanvasView { sections: [this.colorSection('Stroke', (value) => this.editor.dispatch({ type: 'setEdgeColor', edgeId: id, value }))], }, }, + { + icon: ICONS.circlePlay, + title: 'Animate edge', + panel: { + title: 'Animate edge', + sections: [ + { + columns: 3, + items: anims.map((a) => ({ + svg: a.svg, + title: a.t, + label: a.label, + selected: currentAnim === a.v, + onClick: () => this.editor.dispatch({ type: 'setEdgeAnimation', edgeId: id, value: a.v }), + })), + }, + ], + }, + }, + { + icon: ICONS.spline, + title: 'Edge curve (diagram-wide)', + panel: { + title: 'Edge curve', + sections: [ + { + columns: 3, + items: curves.map((c) => ({ + svg: c.svg, + title: c.t, + label: c.label, + selected: currentCurve === c.v, + onClick: () => this.editor.dispatch({ type: 'setFlowCurve', value: c.v }), + })), + }, + ], + }, + }, { icon: ICONS.arrowLeftRight, title: 'Reverse direction',