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.
This commit is contained in:
JK 2026-07-21 16:18:23 +02:00
parent f0ce1930b8
commit 980e34ef86
5 changed files with 272 additions and 98 deletions

View file

@ -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 => {

View file

@ -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);
};

View file

@ -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

View file

@ -19,24 +19,24 @@ export class TikzCodec {
const map: Record<string, string> = {
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<typeof this.parseOptions> = {};
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 || '';

View file

@ -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}');
});
});