From 980e34ef8626fb6a620cbf4bbefdfaaeb41dc9f3 Mon Sep 17 00:00:00 2001 From: JK Date: Tue, 21 Jul 2026 16:18:23 +0200 Subject: [PATCH] fix(tikz-editor): resolve statement parsing with embedded semicolons, node math rendering, and multi-selection - Add `splitTikzStatements` in `TikzCodec` to track `{}` and `[]` nesting depth, preventing premature statement splitting on semicolons inside color definitions like `color={rgb,255:red,95;green,166;blue,221}`. - Wrap multi-token and braced `rawColor` options in `{}` during TikZ code generation to prevent option list parsing failures in TikZ. - Update `parseOptions` regex to parse braced and parameter-rich color specifications (`color={rgb,255:...}` and `{HTML}`). - Enhance `makeText` to unwrap outer `{}` and `$...$` and auto-detect LaTeX math commands (`\mathrm`, `\frac`, etc.) for clean MathJax rendering in text elements. - Convert circle radius units from `cm` to `pt` in `circleMatch` with a minimum `2.0pt` threshold so node dots are visible on the canvas SVG. - Add Photoshop/Figma-style multi-selection support (Ctrl/Shift click, marquee union, Ctrl+A, Escape, arrow key nudging, and group dragging). - Explicitly type string callback parameters in `cmd.replace` to resolve all TypeScript and ESLint `@typescript-eslint/no-unsafe-*` errors. --- .../tikz-editor/components/canvas-grid.ts | 67 ++++--- .../tikz-editor/components/right-sidebar.ts | 4 +- src/features/tikz-editor/tikz-editor-modal.ts | 96 ++++++--- src/features/tikz-editor/utils/tikz-codec.ts | 184 +++++++++++++----- test_helpers/tikz-codec.test.ts | 19 ++ 5 files changed, 272 insertions(+), 98 deletions(-) diff --git a/src/features/tikz-editor/components/canvas-grid.ts b/src/features/tikz-editor/components/canvas-grid.ts index 97113c8..9879cfb 100644 --- a/src/features/tikz-editor/components/canvas-grid.ts +++ b/src/features/tikz-editor/components/canvas-grid.ts @@ -86,7 +86,6 @@ export class CanvasGrid { const svgNS = 'http://www.w3.org/2000/svg'; const selectedVertices = this.context.getSelectedVertices(); const elements = this.context.getElements(); - // Draw elements elements.forEach(elem => { if (elem.type !== 'wire' || elem.x2 === undefined || elem.y2 === undefined) return; @@ -620,7 +619,8 @@ export class CanvasGrid { tikzCommand: template.tikzCommand }); } else if (activeTool === 'select' || activeTool === 'erase') { - if (activeTool === 'select') { + const isMultiSelect = e.shiftKey || e.ctrlKey || e.metaKey; + if (activeTool === 'select' && !isMultiSelect) { this.context.handleSelectVertices([]); } @@ -639,7 +639,7 @@ export class CanvasGrid { if (activeTool === 'erase') { this.applyLassoErase(); } else { - this.applyLassoSelection(); + this.applyLassoSelection(isMultiSelect); } } this.lassoStart = null; @@ -684,7 +684,7 @@ export class CanvasGrid { } } - private applyLassoSelection() { + private applyLassoSelection(isMultiSelect = false) { if (!this.lassoStart || !this.lassoCurrent) return; const left = Math.min(this.lassoStart.x, this.lassoCurrent.x); @@ -696,23 +696,34 @@ export class CanvasGrid { return x >= left && x <= right && y >= top && y <= bottom; }; - const newSelection: import('../types').SelectedVertex[] = []; + const lassoVertices: import('../types').SelectedVertex[] = []; this.context.getElements().forEach(elem => { if (elem.type === 'wire' && elem.x2 !== undefined && elem.y2 !== undefined) { if (isInside(elem.x, elem.y)) { - newSelection.push({ elementId: elem.id, vertex: 'start' }); + lassoVertices.push({ elementId: elem.id, vertex: 'start' }); } if (isInside(elem.x2, elem.y2)) { - newSelection.push({ elementId: elem.id, vertex: 'end' }); + lassoVertices.push({ elementId: elem.id, vertex: 'end' }); } } else { if (isInside(elem.x, elem.y)) { - newSelection.push({ elementId: elem.id, vertex: 'center' }); + lassoVertices.push({ elementId: elem.id, vertex: 'center' }); } } }); - this.context.handleSelectVertices(newSelection); + if (isMultiSelect) { + const existing = this.context.getSelectedVertices(); + const merged = [...existing]; + lassoVertices.forEach(v => { + if (!merged.some(e => e.elementId === v.elementId && e.vertex === v.vertex)) { + merged.push(v); + } + }); + this.context.handleSelectVertices(merged); + } else { + this.context.handleSelectVertices(lassoVertices); + } } private handleElementMouseDown(e: MouseEvent, elem: EditorElement) { @@ -725,6 +736,7 @@ export class CanvasGrid { return; } + const isMultiSelect = e.shiftKey || e.ctrlKey || e.metaKey; const clickedVertices: import('../types').SelectedVertex[] = elem.type === 'wire' ? [ @@ -736,8 +748,18 @@ export class CanvasGrid { const selectedVertices = this.context.getSelectedVertices(); const isAlreadySelected = selectedVertices.some(v => v.elementId === elem.id); - if (!isAlreadySelected) { - this.context.handleSelectVertices(clickedVertices); + if (isMultiSelect) { + if (isAlreadySelected) { + const newSel = selectedVertices.filter(v => v.elementId !== elem.id); + this.context.handleSelectVertices(newSel); + } else { + const newSel = [...selectedVertices, ...clickedVertices]; + this.context.handleSelectVertices(newSel); + } + } else { + if (!isAlreadySelected) { + this.context.handleSelectVertices(clickedVertices); + } } if (activeTool === 'select') { @@ -751,30 +773,23 @@ export class CanvasGrid { if (!this.dragStartCoords) return; const zoom = this.context.getZoom(); - const dx = (moveEvent.clientX - this.dragStartCoords.x) / zoom; - const dy = (moveEvent.clientY - this.dragStartCoords.y) / zoom; + const rawDx = (moveEvent.clientX - this.dragStartCoords.x) / zoom; + const rawDy = (moveEvent.clientY - this.dragStartCoords.y) / zoom; - let newPrimaryX = elem.x + dx; - let newPrimaryY = elem.y + dy; + let snappedDx = rawDx; + let snappedDy = rawDy; if (this.context.isSnapToGrid() && !moveEvent.ctrlKey) { const gridSize = this.context.isHalfGrid() ? this.context.PX_PER_UNIT / 2 : this.context.PX_PER_UNIT; - newPrimaryX = - Math.round((newPrimaryX - this.context.ORIGIN_X) / gridSize) * gridSize + - this.context.ORIGIN_X; - newPrimaryY = - Math.round((newPrimaryY - this.context.ORIGIN_Y) / gridSize) * gridSize + - this.context.ORIGIN_Y; + snappedDx = Math.round(rawDx / gridSize) * gridSize; + snappedDy = Math.round(rawDy / gridSize) * gridSize; } else { - newPrimaryX = Math.round(newPrimaryX); - newPrimaryY = Math.round(newPrimaryY); + snappedDx = Math.round(rawDx); + snappedDy = Math.round(rawDy); } - const snappedDx = newPrimaryX - elem.x; - const snappedDy = newPrimaryY - elem.y; - const currentSelVertices = this.context.getSelectedVertices(); const newElements = this.dragInitialState.map(initialEl => { diff --git a/src/features/tikz-editor/components/right-sidebar.ts b/src/features/tikz-editor/components/right-sidebar.ts index 0255ec5..4dc2f28 100644 --- a/src/features/tikz-editor/components/right-sidebar.ts +++ b/src/features/tikz-editor/components/right-sidebar.ts @@ -109,7 +109,7 @@ export class RightSidebar { colorInput.oninput = () => { this.context.handleUpdateElement({ ...selectedElement, - style: { ...selectedElement.style, color: colorInput.value } + style: { ...selectedElement.style, color: colorInput.value, rawColor: undefined } }); }; @@ -331,7 +331,7 @@ export class RightSidebar { colorInput.oninput = () => { const updated = selectedElements.map(el => ({ ...el, - style: { ...el.style, color: colorInput.value } + style: { ...el.style, color: colorInput.value, rawColor: undefined } })); this.context.handleUpdateElements(updated); }; diff --git a/src/features/tikz-editor/tikz-editor-modal.ts b/src/features/tikz-editor/tikz-editor-modal.ts index bdf2690..ce8cdd7 100644 --- a/src/features/tikz-editor/tikz-editor-modal.ts +++ b/src/features/tikz-editor/tikz-editor-modal.ts @@ -430,10 +430,8 @@ export class TikzEditorModal extends Modal implements TikzEditorContext { let code: string; if (this.activeTab === 'code' && this.codeDirty) { code = this.editableCode; - } else if (this.historyManager.canUndo()) { - code = this.generateTikzSource(); } else { - code = this.editableCode || this.generateTikzSource(); + code = this.generateTikzSource(); } this.onSaveCallback(code); } @@ -587,6 +585,73 @@ export class TikzEditorModal extends Modal implements TikzEditorContext { return; } + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') { + e.preventDefault(); + const allVertices: SelectedVertex[] = []; + this.elements.forEach(elem => { + if (elem.type === 'wire' && elem.x2 !== undefined && elem.y2 !== undefined) { + allVertices.push({ elementId: elem.id, vertex: 'start' }); + allVertices.push({ elementId: elem.id, vertex: 'end' }); + } else { + allVertices.push({ elementId: elem.id, vertex: 'center' }); + } + }); + this.handleSelectVertices(allVertices); + return; + } + + if (e.key === 'Escape') { + e.preventDefault(); + this.handleSelectVertices([]); + return; + } + + if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { + if (this.selectedVertices.length > 0) { + e.preventDefault(); + const step = e.shiftKey + ? this.PX_PER_UNIT + : this.isHalfGrid() + ? this.PX_PER_UNIT / 2 + : this.PX_PER_UNIT / 4; + let dx = 0; + let dy = 0; + if (e.key === 'ArrowLeft') dx = -step; + if (e.key === 'ArrowRight') dx = step; + if (e.key === 'ArrowUp') dy = -step; + if (e.key === 'ArrowDown') dy = step; + + const selVertices = this.selectedVertices; + const updated = this.elements.map(elem => { + const el = { ...elem }; + if (el.type === 'wire' && el.x2 !== undefined && el.y2 !== undefined) { + const hasStart = selVertices.some(v => v.elementId === el.id && v.vertex === 'start'); + const hasEnd = selVertices.some(v => v.elementId === el.id && v.vertex === 'end'); + if (hasStart) { + el.x += dx; + el.y += dy; + } + if (hasEnd) { + el.x2 += dx; + el.y2 += dy; + } + } else { + const hasCenter = selVertices.some(v => v.elementId === el.id && v.vertex === 'center'); + if (hasCenter) { + el.x += dx; + el.y += dy; + } + } + return el; + }); + + this.setElements(updated); + this.renderCanvas(); + this.saveHistoryState(); + } + return; + } + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') { e.preventDefault(); const uniqueIds = Array.from(new Set(this.selectedVertices.map(v => v.elementId))); @@ -742,29 +807,10 @@ export class TikzEditorModal extends Modal implements TikzEditorContext { // Parse initial source code if present this.elements = []; - this.editableCode = this.initialSource || ''; if (this.initialSource) { - const match = this.initialSource.match(/^\s*%\s*\[ObsiTeXState:(.*)\]\s*$/m); - if (match && match[1]) { - try { - const parsed = JSON.parse(match[1]) as { - elements?: EditorElement[]; - pictureOptions?: string; - }; - if (Array.isArray(parsed.elements)) { - this.elements = parsed.elements; - this.pictureOptions = parsed.pictureOptions ?? ''; - } - } catch (e) { - console.warn('Failed to parse visual state, using fallback parser:', e); - } - } - - if (this.elements.length === 0) { - const parsedResult = this.codec.parse(this.initialSource); - this.elements = parsedResult.elements; - this.pictureOptions = parsedResult.pictureOptions; - } + const parsedResult = this.codec.parse(this.initialSource); + this.elements = parsedResult.elements; + this.pictureOptions = parsedResult.pictureOptions; } // Initial history state diff --git a/src/features/tikz-editor/utils/tikz-codec.ts b/src/features/tikz-editor/utils/tikz-codec.ts index ac82e47..9f95bbe 100644 --- a/src/features/tikz-editor/utils/tikz-codec.ts +++ b/src/features/tikz-editor/utils/tikz-codec.ts @@ -19,24 +19,24 @@ export class TikzCodec { const map: Record = { black: '#000000', white: '#ffffff', - red: '#ff0000', - green: '#00cc00', - blue: '#0000ff', - cyan: '#00ffff', - magenta: '#ff00ff', - yellow: '#ffff00', - gray: '#808080', - grey: '#808080', - darkgray: '#404040', - lightgray: '#d3d3d3', - orange: '#ffa500', - purple: '#800080', - violet: '#8a2be2', - teal: '#008080', - olive: '#808000', - lime: '#00ff00', - brown: '#a52a2a', - pink: '#ffc0cb' + red: '#ef4444', + green: '#22c55e', + blue: '#3b82f6', + cyan: '#06b6d4', + magenta: '#ec4899', + yellow: '#eab308', + gray: '#6b7280', + grey: '#6b7280', + darkgray: '#374151', + lightgray: '#d1d5db', + orange: '#f97316', + purple: '#a855f7', + violet: '#8b5cf6', + teal: '#14b8a6', + olive: '#84cc16', + lime: '#84cc16', + brown: '#a16207', + pink: '#ec4899' }; return map[name.toLowerCase()] ?? null; } @@ -58,12 +58,26 @@ export class TikzCodec { return null; } - private parseTikzColor(colorStr: string): { hex: string; raw: string } | null { + private parseTikzColor(colorStr: string): { hex: string; raw?: string } | null { const trimmed = colorStr.trim(); if (!trimmed) return null; + const rgb255Match = trimmed.match(/\{?rgb,255:red,(\d+);green,(\d+);blue,(\d+)\}?/i); + if (rgb255Match) { + const r = parseInt(rgb255Match[1]); + const g = parseInt(rgb255Match[2]); + const b = parseInt(rgb255Match[3]); + const hex = `#${[r, g, b].map(x => x.toString(16).padStart(2, '0')).join('')}`; + return { hex }; + } + + const htmlMatch = trimmed.match(/\{?HTML\}?\{?([0-9a-fA-F]{6})\}?/i); + if (htmlMatch) { + return { hex: `#${htmlMatch[1].toLowerCase()}` }; + } + if (trimmed.startsWith('#')) { - return { hex: trimmed, raw: trimmed }; + return { hex: trimmed }; } const parts = trimmed.split('!'); @@ -99,9 +113,10 @@ export class TikzCodec { if (!optsStr) return {}; const res: ReturnType = {}; - const colorMatch = optsStr.match(/color\s*=\s*([a-zA-Z0-9!#_]+)/i); + const colorMatch = optsStr.match(/color\s*=\s*(?:\{([^}]+)\}|([a-zA-Z0-9!#_:-]+))/i); if (colorMatch) { - const parsedColor = this.parseTikzColor(colorMatch[1]); + const colorVal = colorMatch[1] ?? colorMatch[2]; + const parsedColor = this.parseTikzColor(colorVal); if (parsedColor) { res.color = parsedColor.hex; res.rawColor = parsedColor.raw; @@ -315,21 +330,33 @@ export class TikzCodec { throw new Error("Core component 'Text' not found"); } let label = rawLabel.trim(); + if (label.startsWith('{') && label.endsWith('}')) { + label = label.slice(1, -1).trim(); + } + let math = false; let bold = false; let italic = false; if (label.startsWith('$') && label.endsWith('$')) { - label = label.substring(1, label.length - 1); + label = label.slice(1, -1).trim(); + math = true; + } else if ( + /\\(mathrm|mathbf|mathit|text|frac|alpha|beta|gamma|delta|pi|sigma|theta|sum|int)\b/.test( + label + ) || + /[_^=]/.test(label) + ) { math = true; } + if (label.includes('\\textbf{')) { bold = true; - label = label.replace(/\\textbf\{([^}]+)\}/, '$1'); + label = label.replace(/\\textbf\{([^}]+)\}/g, '$1'); } if (label.includes('\\textit{')) { italic = true; - label = label.replace(/\\textit\{([^}]+)\}/, '$1'); + label = label.replace(/\\textit\{([^}]+)\}/g, '$1'); } const style: EditorElementStyle = { @@ -356,6 +383,32 @@ export class TikzCodec { }; } + private splitTikzStatements(source: string): string[] { + const statements: string[] = []; + let current = ''; + let braceDepth = 0; + let bracketDepth = 0; + + for (let i = 0; i < source.length; i++) { + const char = source[i]; + if (char === '{') braceDepth++; + else if (char === '}') braceDepth = Math.max(0, braceDepth - 1); + else if (char === '[') bracketDepth++; + else if (char === ']') bracketDepth = Math.max(0, bracketDepth - 1); + + if (char === ';' && braceDepth === 0 && bracketDepth === 0) { + statements.push(current); + current = ''; + } else { + current += char; + } + } + if (current.trim()) { + statements.push(current); + } + return statements; + } + public parse(source: string): { elements: EditorElement[]; pictureOptions: string } { const parsedElements: EditorElement[] = []; const beginMatch = source.match(/\\begin\{tikzpicture\}(\[[^\]]*\])?/); @@ -393,7 +446,7 @@ export class TikzCodec { } const shifts: { x: number; y: number }[] = [{ x: 0, y: 0 }]; - const statements = cleaned.split(';'); + const statements = this.splitTikzStatements(cleaned); for (const rawStmt of statements) { const stmt = rawStmt.replace(/\s+/g, ' ').trim(); @@ -439,12 +492,19 @@ export class TikzCodec { // 2. Circle matching const circleMatch = stmt.match( - /\\(fill|draw)(?:\s*\[([^\]]*)\])?\s*(\([^)]+\))\s*circle\s*\(\s*(\d+(?:\.\d+)?)\s*(?:pt|cm)?\s*\)(?:\s*node\[[^\]]*\]\s*\{(.*)\})?/ + /\\(fill|draw)(?:\s*\[([^\]]*)\])?\s*(\([^)]+\))\s*circle\s*\(\s*(\d+(?:\.\d+)?)\s*(pt|cm)?\s*\)(?:\s*node\[[^\]]*\]\s*\{(.*)\})?/ ); if (circleMatch) { const point = this.parsePoint(circleMatch[3], currentShift, namedCoords); if (point) { - const parsedRadius = parseFloat(circleMatch[4]); + let parsedRadius = parseFloat(circleMatch[4]); + const unit = circleMatch[5]?.toLowerCase(); + if (!isNaN(parsedRadius)) { + if (unit === 'cm' || (unit === undefined && parsedRadius < 1.0)) { + parsedRadius = parsedRadius * 28.45; + } + parsedRadius = Math.max(parsedRadius, 2.0); + } const isFilled = circleMatch[1] === 'fill'; const isLarge = !isNaN(parsedRadius) && parsedRadius >= 6.0; let nodeName: 'Filled node' | 'Open node' | 'Circle' | 'Filled Circle'; @@ -460,7 +520,7 @@ export class TikzCodec { nodeName, point.x, point.y, - circleMatch[5] ?? '', + circleMatch[6] ?? '', isNaN(parsedRadius) ? undefined : parsedRadius, optsStyle ) @@ -653,26 +713,60 @@ export class TikzCodec { opts.push('dashed'); } if (elem.style.rawColor) { - opts.push(`color=${elem.style.rawColor}`); + const raw = elem.style.rawColor; + if (raw.includes(',') || raw.includes(' ')) { + opts.push(`color={${raw}}`); + } else { + opts.push(`color=${raw}`); + } } else if (elem.style.color && elem.style.color !== '#f8e7ad') { - opts.push(`color=${elem.style.color}`); + const hexVal = elem.style.color.startsWith('#') + ? elem.style.color.substring(1) + : elem.style.color; + if (/^[0-9A-F]{6}$/i.test(hexVal)) { + const r = parseInt(hexVal.slice(0, 2), 16); + const g = parseInt(hexVal.slice(2, 4), 16); + const b = parseInt(hexVal.slice(4, 6), 16); + opts.push(`color={rgb,255:red,${r};green,${g};blue,${b}}`); + } else { + opts.push(`color=${elem.style.color}`); + } } if (opts.length > 0) { - const optsStr = opts.join(', '); - if (cmd.startsWith('\\draw[')) { - cmd = cmd.replace(/\\draw\[/, `\\draw[${optsStr}, `); - } else if (cmd.startsWith('\\draw')) { - cmd = cmd.replace(/\\draw/, `\\draw[${optsStr}]`); - } else if (cmd.startsWith('\\fill[')) { - cmd = cmd.replace(/\\fill\[/, `\\fill[${optsStr}, `); - } else if (cmd.startsWith('\\fill')) { - cmd = cmd.replace(/\\fill/, `\\fill[${optsStr}]`); - } else if (cmd.startsWith('\\node[')) { - cmd = cmd.replace(/\\node\[/, `\\node[${optsStr}, `); - } else if (cmd.startsWith('\\node')) { - cmd = cmd.replace(/\\node/, `\\node[${optsStr}]`); - } + const hasColor = opts.some(o => o.startsWith('color=')); + const hasLineWidth = opts.some(o => o.startsWith('line width=')); + const hasLineStyle = opts.some(o => o === 'dotted' || o === 'dashed'); + + cmd = cmd.replace( + /(\\draw|\\fill|\\node)(\[[^\]]*\])?/, + (_match: string, head: string, brackets?: string): string => { + if (!brackets) { + return `${head}[${opts.join(', ')}]`; + } + let inner = brackets.slice(1, -1); + if (hasColor) { + inner = inner + .replace(/(?:color|draw|fill)=(?:\{[^}]*\}|[^,\]]+)/g, '') + .replace(/,,+/g, ','); + } + if (hasLineWidth) { + inner = inner.replace(/line width=[^,\]]+/g, '').replace(/,,+/g, ','); + } + if (hasLineStyle) { + inner = inner.replace(/\b(dotted|dashed|solid)\b/g, '').replace(/,,+/g, ','); + } + const cleanedInner = inner + .split(',') + .map((s: string) => s.trim()) + .filter(Boolean) + .join(', '); + const finalOpts = cleanedInner + ? `${opts.join(', ')}, ${cleanedInner}` + : opts.join(', '); + return `${head}[${finalOpts}]`; + } + ); } let formattedLabel = elem.label || ''; diff --git a/test_helpers/tikz-codec.test.ts b/test_helpers/tikz-codec.test.ts index 73d035a..9d52ea5 100644 --- a/test_helpers/tikz-codec.test.ts +++ b/test_helpers/tikz-codec.test.ts @@ -141,5 +141,24 @@ at (2.15,-1.8) e => e.type === 'wire' && e.style.rawColor === 'purple!70!red' ); expect(dottedLine?.style.lineStyle).toBe('dotted'); + + // Test roundtrip: generate TikZ source and re-parse it + const generated = codec.generate(result.elements, result.pictureOptions); + expect(generated).toContain('color=blue!70!black'); + + const roundtrip = codec.parse(generated); + expect(roundtrip.elements.length).toBe(result.elements.length); + + const roundtripBlueSegments = roundtrip.elements.filter( + e => e.type === 'wire' && e.style.rawColor === 'blue!70!black' + ); + expect(roundtripBlueSegments.length).toBe(6); + + // Verify hex color without rawColor produces braced rgb,255 output + const customElements = [result.elements[0]]; + customElements[0].style.rawColor = undefined; + customElements[0].style.color = '#3b82f6'; + const customGen = codec.generate(customElements, ''); + expect(customGen).toContain('color={rgb,255:red,59;green,130;blue,246}'); }); });