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',