diff --git a/src/features/tikz-editor/components/canvas-grid.ts b/src/features/tikz-editor/components/canvas-grid.ts
index 839727e..97113c8 100644
--- a/src/features/tikz-editor/components/canvas-grid.ts
+++ b/src/features/tikz-editor/components/canvas-grid.ts
@@ -58,7 +58,7 @@ export class CanvasGrid {
axisOverlay.appendChild(lineY);
// X labels
- for (let x = 0; x <= 12; x++) {
+ for (let x = -20; x <= 20; x++) {
const text = activeDocument.createElementNS(svgNS, 'text');
text.setAttribute('x', (this.context.ORIGIN_X + x * this.context.PX_PER_UNIT).toString());
text.setAttribute('y', (this.context.ORIGIN_Y + 18).toString());
@@ -67,7 +67,7 @@ export class CanvasGrid {
}
// Y labels
- for (let y = -4; y <= 4; y++) {
+ for (let y = -20; y <= 20; y++) {
if (y !== 0) {
const text = activeDocument.createElementNS(svgNS, 'text');
text.setAttribute('x', (this.context.ORIGIN_X - 12).toString());
@@ -108,20 +108,47 @@ export class CanvasGrid {
lineClick.setCssStyles({ cursor: 'pointer' });
group.appendChild(lineClick);
- if (elem.name === 'Wire') {
+ const isBasicWire =
+ elem.name === 'Wire' || elem.name === 'Dashed Wire' || elem.name === 'Arrow';
+ if (isBasicWire) {
const line = activeDocument.createElementNS(svgNS, 'line');
line.setAttribute('x1', elem.x.toString());
line.setAttribute('y1', elem.y.toString());
line.setAttribute('x2', elem.x2.toString());
line.setAttribute('y2', elem.y2.toString());
- line.setAttribute(
- 'stroke',
- selectedVertices.some(v => v.elementId === elem.id)
- ? 'var(--text-accent)'
- : elem.style.color || 'var(--text-normal)'
- );
+ const strokeColor = selectedVertices.some(v => v.elementId === elem.id)
+ ? 'var(--text-accent)'
+ : elem.style.color && elem.style.color !== '#f8e7ad'
+ ? elem.style.color
+ : '#e2e8f0';
+ line.setAttribute('stroke', strokeColor);
line.setAttribute('stroke-width', (elem.style.thickness ?? 1.0).toString());
+
+ if (elem.name === 'Dashed Wire' || elem.style.lineStyle === 'dashed') {
+ line.setAttribute('stroke-dasharray', '6,4');
+ } else if (elem.style.lineStyle === 'dotted') {
+ line.setAttribute('stroke-dasharray', '2,4');
+ }
+
group.appendChild(line);
+
+ if (elem.name === 'Arrow' || elem.style.arrowStyle) {
+ const dx = elem.x2 - elem.x;
+ const dy = elem.y2 - elem.y;
+ const len = Math.hypot(dx, dy);
+ if (len > 0) {
+ const angle = Math.atan2(dy, dx);
+ const headLen = Math.min(10, len);
+ const p1x = elem.x2 - headLen * Math.cos(angle - Math.PI / 6);
+ const p1y = elem.y2 - headLen * Math.sin(angle - Math.PI / 6);
+ const p2x = elem.x2 - headLen * Math.cos(angle + Math.PI / 6);
+ const p2y = elem.y2 - headLen * Math.sin(angle + Math.PI / 6);
+ const head = activeDocument.createElementNS(svgNS, 'polygon');
+ head.setAttribute('points', `${elem.x2},${elem.y2} ${p1x},${p1y} ${p2x},${p2y}`);
+ head.setAttribute('fill', strokeColor);
+ group.appendChild(head);
+ }
+ }
} else {
const dx = elem.x2 - elem.x;
const dy = elem.y2 - elem.y;
diff --git a/src/features/tikz-editor/tikz-editor-modal.ts b/src/features/tikz-editor/tikz-editor-modal.ts
index 4bc948b..bdf2690 100644
--- a/src/features/tikz-editor/tikz-editor-modal.ts
+++ b/src/features/tikz-editor/tikz-editor-modal.ts
@@ -18,10 +18,10 @@ import { TikzCodec } from './utils/tikz-codec';
export class TikzEditorModal extends Modal implements TikzEditorContext {
// Constants
public readonly PX_PER_UNIT = 80;
- public readonly ORIGIN_X = 120;
- public readonly ORIGIN_Y = 360;
- public readonly CANVAS_WIDTH = 1120;
- public readonly CANVAS_HEIGHT = 720;
+ public readonly ORIGIN_X = 2000;
+ public readonly ORIGIN_Y = 2000;
+ public readonly CANVAS_WIDTH = 4000;
+ public readonly CANVAS_HEIGHT = 4000;
public readonly DEFAULT_STYLE = {
bold: false,
italic: false,
@@ -40,13 +40,15 @@ export class TikzEditorModal extends Modal implements TikzEditorContext {
private halfGrid = true;
private pictureOptions = '';
private zoom = 1.0;
+ private panX = 0;
+ private panY = 0;
private copiedElements: EditorElement[] = [];
// Panning state
private isSpacePressed = false;
private isPanning = false;
private panStartMouse = { x: 0, y: 0 };
- private panStartScroll = { left: 0, top: 0 };
+ private panStartPan = { x: 0, y: 0 };
// Sidebar / library state
private searchQuery = '';
@@ -126,6 +128,32 @@ export class TikzEditorModal extends Modal implements TikzEditorContext {
}
setZoom(zoom: number) {
this.zoom = zoom;
+ this.updateWorkspaceTransform();
+ }
+ getPan() {
+ return { x: this.panX, y: this.panY };
+ }
+ setPan(x: number, y: number) {
+ this.panX = x;
+ this.panY = y;
+ this.updateWorkspaceTransform();
+ }
+ updateWorkspaceTransform() {
+ if (this.canvasWorkspaceEl) {
+ this.canvasWorkspaceEl.style.transform = `translate(${this.panX}px, ${this.panY}px) scale(${this.zoom})`;
+ }
+ }
+ resetView() {
+ const w = this.canvasContainerEl?.clientWidth || 800;
+ const h = this.canvasContainerEl?.clientHeight || 600;
+ this.zoom = 1.0;
+ this.panX = Math.round(w / 2 - (this.ORIGIN_X + 2.5 * this.PX_PER_UNIT) * this.zoom);
+ this.panY = Math.round(h / 2 - (this.ORIGIN_Y - 0.5 * this.PX_PER_UNIT) * this.zoom);
+ const zoomLabel = this.contentEl?.querySelector('.zoom-label');
+ if (zoomLabel) {
+ zoomLabel.textContent = '100%';
+ }
+ this.updateWorkspaceTransform();
}
getSearchQuery() {
return this.searchQuery;
@@ -399,8 +427,14 @@ export class TikzEditorModal extends Modal implements TikzEditorContext {
handleInsertCode() {
if (this.onSaveCallback) {
- const code =
- this.activeTab === 'code' && this.codeDirty ? this.editableCode : this.generateTikzSource();
+ 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();
+ }
this.onSaveCallback(code);
}
this.close();
@@ -486,11 +520,13 @@ export class TikzEditorModal extends Modal implements TikzEditorContext {
switchTab(tab: 'edit' | 'code') {
this.activeTab = tab;
if (tab === 'code' && !this.codeDirty) {
- try {
- this.editableCode = this.generateTikzSource();
- } catch (err) {
- console.error('[TikzEditorModal] Error generating TikZ code:', err);
- this.editableCode = `% Error generating TikZ code: ${err instanceof Error ? err.message : String(err)}`;
+ if (!this.editableCode || this.historyManager.canUndo()) {
+ try {
+ this.editableCode = this.generateTikzSource();
+ } catch (err) {
+ console.error('[TikzEditorModal] Error generating TikZ code:', err);
+ this.editableCode = `% Error generating TikZ code: ${err instanceof Error ? err.message : String(err)}`;
+ }
}
}
this.renderRightSidebar();
@@ -706,6 +742,7 @@ 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]) {
@@ -770,7 +807,6 @@ export class TikzEditorModal extends Modal implements TikzEditorContext {
const controls = canvasAreaEl.createDiv({ cls: 'tikz-canvas-controls' });
const zoomOutBtn = controls.createEl('button', { title: 'Zoom out [ctrl + -]' });
-
const zoomOutParser = new DOMParser();
const zoomOutDoc = zoomOutParser.parseFromString(
``,
@@ -778,15 +814,23 @@ export class TikzEditorModal extends Modal implements TikzEditorContext {
);
zoomOutBtn.appendChild(activeDocument.importNode(zoomOutDoc.documentElement, true));
zoomOutBtn.onclick = () => {
- this.zoom = Math.max(this.zoom - 0.1, 0.5);
+ const oldZoom = this.zoom;
+ this.zoom = Math.max(this.zoom - 0.1, 0.3);
+ const w = this.canvasContainerEl.clientWidth || 800;
+ const h = this.canvasContainerEl.clientHeight || 600;
+ const centerX = w / 2;
+ const centerY = h / 2;
+ const canvasX = (centerX - this.panX) / oldZoom;
+ const canvasY = (centerY - this.panY) / oldZoom;
+ this.panX = centerX - canvasX * this.zoom;
+ this.panY = centerY - canvasY * this.zoom;
zoomLabel.textContent = `${Math.round(this.zoom * 100)}%`;
- this.canvasWorkspaceEl.style.transform = `scale(${this.zoom})`;
+ this.updateWorkspaceTransform();
};
const zoomLabel = controls.createSpan({ cls: 'zoom-label', text: '100%' });
const zoomInBtn = controls.createEl('button', { title: 'Zoom in [ctrl + +]' });
-
const zoomInParser = new DOMParser();
const zoomInDoc = zoomInParser.parseFromString(
``,
@@ -794,16 +838,33 @@ export class TikzEditorModal extends Modal implements TikzEditorContext {
);
zoomInBtn.appendChild(activeDocument.importNode(zoomInDoc.documentElement, true));
zoomInBtn.onclick = () => {
- this.zoom = Math.min(this.zoom + 0.1, 2.0);
+ const oldZoom = this.zoom;
+ this.zoom = Math.min(this.zoom + 0.1, 3.0);
+ const w = this.canvasContainerEl.clientWidth || 800;
+ const h = this.canvasContainerEl.clientHeight || 600;
+ const centerX = w / 2;
+ const centerY = h / 2;
+ const canvasX = (centerX - this.panX) / oldZoom;
+ const canvasY = (centerY - this.panY) / oldZoom;
+ this.panX = centerX - canvasX * this.zoom;
+ this.panY = centerY - canvasY * this.zoom;
zoomLabel.textContent = `${Math.round(this.zoom * 100)}%`;
- this.canvasWorkspaceEl.style.transform = `scale(${this.zoom})`;
+ this.updateWorkspaceTransform();
};
+ const resetBtn = controls.createEl('button', { title: 'Center view' });
+ const resetParser = new DOMParser();
+ const resetDoc = resetParser.parseFromString(
+ ``,
+ 'image/svg+xml'
+ );
+ resetBtn.appendChild(activeDocument.importNode(resetDoc.documentElement, true));
+ resetBtn.onclick = () => this.resetView();
+
controls.createDiv({ cls: 'divider' });
const undoBtn = controls.createEl('button', { title: 'Undo [Ctrl + Z]' });
undoBtn.disabled = true;
-
const undoParser = new DOMParser();
const undoDoc = undoParser.parseFromString(
``,
@@ -814,7 +875,6 @@ export class TikzEditorModal extends Modal implements TikzEditorContext {
const redoBtn = controls.createEl('button', { title: 'Redo [Ctrl + Y]' });
redoBtn.disabled = true;
-
const redoParser = new DOMParser();
const redoDoc = redoParser.parseFromString(
``,
@@ -826,7 +886,6 @@ export class TikzEditorModal extends Modal implements TikzEditorContext {
// Workspace
this.canvasWorkspaceEl = this.canvasContainerEl.createDiv({ cls: 'canvas-workspace' });
this.canvasWorkspaceEl.setCssStyles({ transformOrigin: '0 0' });
- this.canvasWorkspaceEl.style.transform = `scale(${this.zoom})`;
// Grid lines
this.canvasWorkspaceEl.createDiv({ cls: 'grid-background' });
@@ -853,28 +912,26 @@ export class TikzEditorModal extends Modal implements TikzEditorContext {
this.wiresOverlayEl
);
- // Panning handler via spacebar holding
+ // Panning handler via Spacebar + Left Click or Middle Click
this.canvasContainerEl.addEventListener(
'mousedown',
e => {
- if (this.isSpacePressed) {
+ if (e.button === 1 || (e.button === 0 && this.isSpacePressed)) {
e.preventDefault();
e.stopPropagation();
this.isPanning = true;
this.canvasContainerEl.removeClass('is-grab');
this.canvasContainerEl.addClass('is-grabbing');
this.panStartMouse = { x: e.clientX, y: e.clientY };
- this.panStartScroll = {
- left: this.canvasContainerEl.scrollLeft,
- top: this.canvasContainerEl.scrollTop
- };
+ this.panStartPan = { x: this.panX, y: this.panY };
const onMouseMove = (moveEvent: MouseEvent) => {
if (!this.isPanning) return;
const dx = moveEvent.clientX - this.panStartMouse.x;
const dy = moveEvent.clientY - this.panStartMouse.y;
- this.canvasContainerEl.scrollLeft = this.panStartScroll.left - dx;
- this.canvasContainerEl.scrollTop = this.panStartScroll.top - dy;
+ this.panX = this.panStartPan.x + dx;
+ this.panY = this.panStartPan.y + dy;
+ this.updateWorkspaceTransform();
};
const onMouseUp = () => {
@@ -894,13 +951,9 @@ export class TikzEditorModal extends Modal implements TikzEditorContext {
}
},
true
- ); // Use capture phase to intercept before canvas events
+ );
- // Scroll container behavior initial state
- this.canvasContainerEl.scrollLeft = 0;
- this.canvasContainerEl.scrollTop = 0;
-
- // Mouse wheel zoom listener (centered on cursor)
+ // Mouse wheel / Trackpad pan & zoom listener
this.canvasContainerEl.addEventListener(
'wheel',
e => {
@@ -915,32 +968,44 @@ export class TikzEditorModal extends Modal implements TikzEditorContext {
}
e.preventDefault();
- const oldZoom = this.zoom;
- const zoomFactor = e.ctrlKey ? 0.05 : 0.03;
- if (e.deltaY < 0) {
- this.zoom = Math.min(this.zoom + zoomFactor, 2.0);
+ if (e.ctrlKey || e.metaKey) {
+ const oldZoom = this.zoom;
+ const zoomFactor = 0.05;
+ const newZoom =
+ e.deltaY < 0
+ ? Math.min(this.zoom + zoomFactor, 3.0)
+ : Math.max(this.zoom - zoomFactor, 0.3);
+
+ if (newZoom !== oldZoom) {
+ const rect = this.canvasContainerEl.getBoundingClientRect();
+ const mouseX = e.clientX - rect.left;
+ const mouseY = e.clientY - rect.top;
+
+ const canvasX = (mouseX - this.panX) / oldZoom;
+ const canvasY = (mouseY - this.panY) / oldZoom;
+
+ this.zoom = newZoom;
+ this.panX = mouseX - canvasX * newZoom;
+ this.panY = mouseY - canvasY * newZoom;
+
+ const zoomLabel = this.contentEl.querySelector('.zoom-label');
+ if (zoomLabel) {
+ zoomLabel.textContent = `${Math.round(this.zoom * 100)}%`;
+ }
+ this.updateWorkspaceTransform();
+ }
} else {
- this.zoom = Math.max(this.zoom - zoomFactor, 0.5);
+ this.panX -= e.deltaX;
+ this.panY -= e.deltaY;
+ this.updateWorkspaceTransform();
}
-
- const zoomLabel = this.contentEl.querySelector('.zoom-label');
- if (zoomLabel) {
- zoomLabel.textContent = `${Math.round(this.zoom * 100)}%`;
- }
- this.canvasWorkspaceEl.style.transform = `scale(${this.zoom})`;
-
- // Adjust scroll to zoom towards mouse pointer
- const rect = this.canvasContainerEl.getBoundingClientRect();
- const mouseX = e.clientX - rect.left;
- const mouseY = e.clientY - rect.top;
-
- const canvasX = (mouseX + this.canvasContainerEl.scrollLeft) / oldZoom;
- const canvasY = (mouseY + this.canvasContainerEl.scrollTop) / oldZoom;
-
- this.canvasContainerEl.scrollLeft = canvasX * this.zoom - mouseX;
- this.canvasContainerEl.scrollTop = canvasY * this.zoom - mouseY;
},
{ passive: false }
);
+
+ // Initial view positioning
+ window.setTimeout(() => {
+ this.resetView();
+ }, 0);
}
}
diff --git a/src/features/tikz-editor/types.ts b/src/features/tikz-editor/types.ts
index 5523271..bae5dbb 100644
--- a/src/features/tikz-editor/types.ts
+++ b/src/features/tikz-editor/types.ts
@@ -3,8 +3,11 @@ export interface EditorElementStyle {
italic: boolean;
math: boolean;
color: string;
+ rawColor?: string;
fontSize: number; // in pt
thickness?: number; // line width in pt
+ lineStyle?: 'solid' | 'dashed' | 'dotted';
+ arrowStyle?: boolean;
}
export interface EditorElement {
@@ -56,6 +59,9 @@ export interface TikzEditorContext {
getPictureOptions(): string;
getZoom(): number;
setZoom(zoom: number): void;
+ getPan(): { x: number; y: number };
+ setPan(x: number, y: number): void;
+ resetView(): void;
getSearchQuery(): string;
setSearchQuery(query: string): void;
isShowPackageManager(): boolean;
diff --git a/src/features/tikz-editor/utils/tikz-codec.ts b/src/features/tikz-editor/utils/tikz-codec.ts
index 0384414..ac82e47 100644
--- a/src/features/tikz-editor/utils/tikz-codec.ts
+++ b/src/features/tikz-editor/utils/tikz-codec.ts
@@ -15,31 +15,255 @@ export class TikzCodec {
return Number.parseFloat(value.trim());
}
- private parsePoint(value: string, shift: { x: number; y: number }) {
- const match = value.match(/\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/);
- if (!match) return null;
- return {
- x: this.parseNumber(match[1]) + shift.x,
- y: this.parseNumber(match[2]) + shift.y
+ private colorNameToHex(name: string): string | null {
+ 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'
};
+ return map[name.toLowerCase()] ?? null;
}
- private makeWire(x1: number, y1: number, x2: number, y2: number): EditorElement {
- const wire = AssetsManager.getCoreComponents().find(t => t.name === 'Wire');
- if (!wire) {
- throw new Error("Core component 'Wire' not found");
+ private parseHex(hex: string): { r: number; g: number; b: number } | null {
+ if (hex.startsWith('#')) hex = hex.slice(1);
+ if (hex.length === 3) {
+ hex = hex
+ .split('')
+ .map(c => c + c)
+ .join('');
}
+ if (hex.length === 6) {
+ const num = parseInt(hex, 16);
+ if (!isNaN(num)) {
+ return { r: (num >> 16) & 255, g: (num >> 8) & 255, b: num & 255 };
+ }
+ }
+ return null;
+ }
+
+ private parseTikzColor(colorStr: string): { hex: string; raw: string } | null {
+ const trimmed = colorStr.trim();
+ if (!trimmed) return null;
+
+ if (trimmed.startsWith('#')) {
+ return { hex: trimmed, raw: trimmed };
+ }
+
+ const parts = trimmed.split('!');
+ if (parts.length === 1) {
+ const hex = this.colorNameToHex(parts[0]);
+ return hex ? { hex, raw: trimmed } : null;
+ }
+
+ const c1Hex = this.colorNameToHex(parts[0]) || '#000000';
+ const pct = parseFloat(parts[1]);
+ const weight = isNaN(pct) ? 1.0 : pct / 100;
+ const c2Hex = parts[2] ? this.colorNameToHex(parts[2]) || '#ffffff' : '#ffffff';
+
+ const rgb1 = this.parseHex(c1Hex) || { r: 0, g: 0, b: 0 };
+ const rgb2 = this.parseHex(c2Hex) || { r: 255, g: 255, b: 255 };
+
+ const r = Math.round(rgb1.r * weight + rgb2.r * (1 - weight));
+ const g = Math.round(rgb1.g * weight + rgb2.g * (1 - weight));
+ const b = Math.round(rgb1.b * weight + rgb2.b * (1 - weight));
+
+ const hex = `#${[r, g, b].map(x => x.toString(16).padStart(2, '0')).join('')}`;
+ return { hex, raw: trimmed };
+ }
+
+ private parseOptions(optsStr: string): {
+ color?: string;
+ rawColor?: string;
+ thickness?: number;
+ lineStyle?: 'solid' | 'dashed' | 'dotted';
+ arrowStyle?: boolean;
+ fontSize?: number;
+ } {
+ if (!optsStr) return {};
+ const res: ReturnType = {};
+
+ const colorMatch = optsStr.match(/color\s*=\s*([a-zA-Z0-9!#_]+)/i);
+ if (colorMatch) {
+ const parsedColor = this.parseTikzColor(colorMatch[1]);
+ if (parsedColor) {
+ res.color = parsedColor.hex;
+ res.rawColor = parsedColor.raw;
+ }
+ } else {
+ const tokens = optsStr.split(/[\s,]+/);
+ for (const token of tokens) {
+ const parsedColor = this.parseTikzColor(token);
+ if (parsedColor) {
+ res.color = parsedColor.hex;
+ res.rawColor = parsedColor.raw;
+ break;
+ }
+ }
+ }
+
+ const widthMatch = optsStr.match(/line\s*width\s*=\s*(\d+(?:\.\d+)?)\s*(pt|cm|mm)?/i);
+ if (widthMatch) {
+ let val = parseFloat(widthMatch[1]);
+ const unit = widthMatch[2]?.toLowerCase();
+ if (unit === 'cm') val *= 28.45;
+ else if (unit === 'mm') val *= 2.845;
+ res.thickness = val;
+ } else if (optsStr.includes('ultra thick')) res.thickness = 3.0;
+ else if (optsStr.includes('very thick')) res.thickness = 2.0;
+ else if (optsStr.includes('thick')) res.thickness = 1.5;
+ else if (optsStr.includes('semithick')) res.thickness = 1.2;
+ else if (optsStr.includes('very thin')) res.thickness = 0.4;
+ else if (optsStr.includes('ultra thin')) res.thickness = 0.2;
+ else if (optsStr.includes('thin')) res.thickness = 0.8;
+
+ if (optsStr.includes('dotted')) res.lineStyle = 'dotted';
+ else if (optsStr.includes('dashed')) res.lineStyle = 'dashed';
+
+ if (
+ optsStr.includes('->') ||
+ optsStr.includes('-stealth') ||
+ optsStr.includes('Triangle') ||
+ optsStr.includes('-latex') ||
+ optsStr.includes('->>')
+ ) {
+ res.arrowStyle = true;
+ }
+
+ const scaleMatch = optsStr.match(/scale\s*=\s*(\d+(?:\.\d+)?)/i);
+ if (scaleMatch) {
+ res.fontSize = Math.round(12 * parseFloat(scaleMatch[1]));
+ } else if (optsStr.includes('\\Huge')) res.fontSize = 24;
+ else if (optsStr.includes('\\Large')) res.fontSize = 18;
+ else if (optsStr.includes('\\large')) res.fontSize = 14;
+ else if (optsStr.includes('\\small')) res.fontSize = 10;
+
+ return res;
+ }
+
+ private parsePoint(
+ value: string,
+ shift: { x: number; y: number },
+ namedCoords?: Map
+ ): { x: number; y: number } | null {
+ const literalMatch = value.match(/\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/);
+ if (literalMatch) {
+ return {
+ x: this.parseNumber(literalMatch[1]) + shift.x,
+ y: this.parseNumber(literalMatch[2]) + shift.y
+ };
+ }
+ const namedMatch = value.match(/\(\s*([A-Za-z0-9_]+)\s*\)/);
+ if (namedMatch && namedCoords) {
+ const coord = namedCoords.get(namedMatch[1]);
+ if (coord) {
+ return {
+ x: coord.x + shift.x,
+ y: coord.y + shift.y
+ };
+ }
+ }
+ return null;
+ }
+
+ private expandForeach(source: string): string {
+ let prev = '';
+ let curr = source;
+ for (let iter = 0; iter < 10 && curr !== prev; iter++) {
+ prev = curr;
+ curr = curr.replace(
+ /\\foreach\s*\\([a-zA-Z0-9_]+)\s*in\s*\{([^}]+)\}\s*([\s\S]*?;)/g,
+ (_fullMatch: string, varName: string, valuesList: string, body: string): string => {
+ const items: string[] = [];
+ const rawItems = valuesList.split(',');
+ for (const item of rawItems) {
+ const trimmed = item.trim();
+ const rangeMatch = trimmed.match(/^(-?\d+)\s*\.\.\.\s*(-?\d+)$/);
+ if (rangeMatch) {
+ const start = parseInt(rangeMatch[1]);
+ const end = parseInt(rangeMatch[2]);
+ const step = start <= end ? 1 : -1;
+ for (let i = start; step > 0 ? i <= end : i >= end; i += step) {
+ items.push(i.toString());
+ }
+ } else if (trimmed) {
+ items.push(trimmed);
+ }
+ }
+ const regex = new RegExp(`\\\\${varName}\\b`, 'g');
+ return items.map(val => body.replace(regex, val)).join('\n');
+ }
+ );
+ }
+ return curr;
+ }
+
+ private stripComments(source: string): string {
+ return source
+ .split('\n')
+ .map(line => {
+ const idx = line.indexOf('%');
+ if (idx !== -1) return line.substring(0, idx);
+ return line;
+ })
+ .join('\n');
+ }
+
+ private makeWire(
+ x1: number,
+ y1: number,
+ x2: number,
+ y2: number,
+ optsStyle?: ReturnType
+ ): EditorElement {
+ let name = 'Wire';
+ if (optsStyle?.lineStyle === 'dashed' || optsStyle?.lineStyle === 'dotted') {
+ name = 'Dashed Wire';
+ } else if (optsStyle?.arrowStyle) {
+ name = 'Arrow';
+ }
+
+ const wire = AssetsManager.getCoreComponents().find(t => t.name === name);
+ if (!wire) {
+ throw new Error(`Core component '${name}' not found`);
+ }
+
+ const style: EditorElementStyle = {
+ ...this.defaultStyle,
+ color: optsStyle?.color ?? this.defaultStyle.color,
+ rawColor: optsStyle?.rawColor,
+ thickness: optsStyle?.thickness ?? this.defaultStyle.thickness,
+ lineStyle: optsStyle?.lineStyle,
+ arrowStyle: optsStyle?.arrowStyle
+ };
+
return {
id: this.createId(),
type: 'wire',
- name: 'Wire',
+ name,
x: this.toCanvasX(x1),
y: this.toCanvasY(y1),
x2: this.toCanvasX(x2),
y2: this.toCanvasY(y2),
label: '',
rotation: 0,
- style: { ...this.defaultStyle },
+ style,
svgMarkup: wire.svgMarkup,
tikzCommand: wire.tikzCommand
};
@@ -50,12 +274,21 @@ export class TikzCodec {
x: number,
y: number,
label = '',
- radius?: number
+ radius?: number,
+ optsStyle?: ReturnType
): EditorElement {
const template = AssetsManager.getCoreComponents().find(t => t.name === name);
if (!template) {
throw new Error(`Core component '${name}' not found`);
}
+
+ const style: EditorElementStyle = {
+ ...this.defaultStyle,
+ color: optsStyle?.color ?? this.defaultStyle.color,
+ rawColor: optsStyle?.rawColor,
+ thickness: optsStyle?.thickness ?? this.defaultStyle.thickness
+ };
+
return {
id: this.createId(),
type: 'component',
@@ -65,23 +298,49 @@ export class TikzCodec {
label,
rotation: 0,
radius: radius ?? (name.toLowerCase().includes('circle') ? 12.0 : 2.0),
- style: { ...this.defaultStyle },
+ style,
svgMarkup: template.svgMarkup,
tikzCommand: template.tikzCommand
};
}
- private makeText(x: number, y: number, rawLabel: string): EditorElement {
+ private makeText(
+ x: number,
+ y: number,
+ rawLabel: string,
+ optsStyle?: ReturnType
+ ): EditorElement {
const template = AssetsManager.getCoreComponents().find(t => t.name === 'Text');
if (!template) {
throw new Error("Core component 'Text' not found");
}
let label = rawLabel.trim();
let math = false;
+ let bold = false;
+ let italic = false;
+
if (label.startsWith('$') && label.endsWith('$')) {
label = label.substring(1, label.length - 1);
math = true;
}
+ if (label.includes('\\textbf{')) {
+ bold = true;
+ label = label.replace(/\\textbf\{([^}]+)\}/, '$1');
+ }
+ if (label.includes('\\textit{')) {
+ italic = true;
+ label = label.replace(/\\textit\{([^}]+)\}/, '$1');
+ }
+
+ const style: EditorElementStyle = {
+ ...this.defaultStyle,
+ math,
+ bold,
+ italic,
+ color: optsStyle?.color ?? this.defaultStyle.color,
+ rawColor: optsStyle?.rawColor,
+ fontSize: optsStyle?.fontSize ?? this.defaultStyle.fontSize
+ };
return {
id: this.createId(),
@@ -91,7 +350,7 @@ export class TikzCodec {
y: this.toCanvasY(y),
label,
rotation: 0,
- style: { ...this.defaultStyle, math },
+ style,
svgMarkup: template.svgMarkup,
tikzCommand: template.tikzCommand
};
@@ -102,24 +361,55 @@ export class TikzCodec {
const beginMatch = source.match(/\\begin\{tikzpicture\}(\[[^\]]*\])?/);
const pictureOptions = beginMatch?.[1] ?? '';
- const shifts: { x: number; y: number }[] = [{ x: 0, y: 0 }];
- const lines = source.split('\n');
+ const rawCleaned = this.expandForeach(this.stripComments(source));
+ const cleaned = rawCleaned
+ .replace(/\\begin\{tikzpicture\}(\[[^\]]*\])?/g, '')
+ .replace(/\\end\{tikzpicture\}/g, '')
+ .replace(/\\usepackage(?:\s*\[[^\]]*\])?\s*\{[^}]+\}/g, '')
+ .replace(/\\usetikzlibrary\s*\{[^}]+\}/g, '');
+ const namedCoords = new Map();
- for (const line of lines) {
- const trimmed = line.trim();
+ // Pre-pass for named coordinates: \coordinate (A) at (x,y);
+ const coordMatches = cleaned.matchAll(
+ /\\coordinate(?:\s*\[[^\]]*\])?\s*\(\s*([A-Za-z0-9_]+)\s*\)\s*at\s*(\([^)]+\))\s*;/g
+ );
+ for (const match of coordMatches) {
+ const pt = this.parsePoint(match[2], { x: 0, y: 0 });
+ if (pt) {
+ namedCoords.set(match[1], pt);
+ }
+ }
+
+ // Node pre-pass for named nodes: \node (A) at (x,y) or \node at (x,y) (A)
+ const nodeCoordMatches = cleaned.matchAll(
+ /\\node(?:\s*\[[^\]]*\])?\s*(?:\(\s*([A-Za-z0-9_]+)\s*\)\s*)?at\s*(\([^)]+\))(?:\s*\(\s*([A-Za-z0-9_]+)\s*\))?/g
+ );
+ for (const match of nodeCoordMatches) {
+ const name = match[1] || match[3];
+ if (name) {
+ const pt = this.parsePoint(match[2], { x: 0, y: 0 });
+ if (pt) namedCoords.set(name, pt);
+ }
+ }
+
+ const shifts: { x: number; y: number }[] = [{ x: 0, y: 0 }];
+ const statements = cleaned.split(';');
+
+ for (const rawStmt of statements) {
+ const stmt = rawStmt.replace(/\s+/g, ' ').trim();
if (
- !trimmed ||
- trimmed.startsWith('%') ||
- trimmed.startsWith('\\usepackage') ||
- trimmed.startsWith('\\usetikzlibrary') ||
- trimmed.startsWith('\\begin{tikzpicture}') ||
- trimmed.startsWith('\\end{tikzpicture}')
+ !stmt ||
+ stmt.startsWith('\\usepackage') ||
+ stmt.startsWith('\\usetikzlibrary') ||
+ stmt.startsWith('\\begin{tikzpicture}') ||
+ stmt.startsWith('\\end{tikzpicture}')
) {
continue;
}
const currentShift = shifts[shifts.length - 1];
- const scopeMatch = trimmed.match(
+
+ const scopeMatch = stmt.match(
/\\begin\{scope\}\s*\[shift=\{\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)\}\]/
);
if (scopeMatch) {
@@ -129,27 +419,32 @@ export class TikzCodec {
});
continue;
}
- if (trimmed.startsWith('\\end{scope}')) {
+ if (stmt.startsWith('\\end{scope}')) {
if (shifts.length > 1) shifts.pop();
continue;
}
- const nodeMatch = trimmed.match(
- /\\node(?:\s*\[[^\]]*\])?\s*at\s*(\(\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*\))\s*(?:\(\w+\)\s*)?\{(.*)\}\s*;/
+ // 1. Node matching
+ const nodeMatch = stmt.match(
+ /\\node(?:\s*\[([^\]]*)\])?(?:\s*\(([A-Za-z0-9_]+)\))?\s*at\s*(\([^)]+\))\s*(?:(?:\(([A-Za-z0-9_]+)\))?\s*)?\{((?:[^{}]|\{[^{}]*\})*)\}/
);
if (nodeMatch) {
- const point = this.parsePoint(nodeMatch[1], currentShift);
- if (point) parsedElements.push(this.makeText(point.x, point.y, nodeMatch[2]));
+ const point = this.parsePoint(nodeMatch[3], currentShift, namedCoords);
+ if (point) {
+ const optsStyle = this.parseOptions(nodeMatch[1] ?? '');
+ parsedElements.push(this.makeText(point.x, point.y, nodeMatch[5], optsStyle));
+ }
continue;
}
- const circleMatch = trimmed.match(
- /\\(fill|draw)\s*(\(\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*\))\s*circle\s*\(\s*(\d+(?:\.\d+)?)\s*(?:pt|cm)?\s*\)(?:\s*node\[[^\]]*\]\s*\{(.*)\})?\s*;/
+ // 2. Circle matching
+ const circleMatch = stmt.match(
+ /\\(fill|draw)(?:\s*\[([^\]]*)\])?\s*(\([^)]+\))\s*circle\s*\(\s*(\d+(?:\.\d+)?)\s*(?:pt|cm)?\s*\)(?:\s*node\[[^\]]*\]\s*\{(.*)\})?/
);
if (circleMatch) {
- const point = this.parsePoint(circleMatch[2], currentShift);
+ const point = this.parsePoint(circleMatch[3], currentShift, namedCoords);
if (point) {
- const parsedRadius = parseFloat(circleMatch[3]);
+ const parsedRadius = parseFloat(circleMatch[4]);
const isFilled = circleMatch[1] === 'fill';
const isLarge = !isNaN(parsedRadius) && parsedRadius >= 6.0;
let nodeName: 'Filled node' | 'Open node' | 'Circle' | 'Filled Circle';
@@ -159,25 +454,28 @@ export class TikzCodec {
nodeName = isLarge ? 'Circle' : 'Open node';
}
+ const optsStyle = this.parseOptions(circleMatch[2] ?? '');
parsedElements.push(
this.makeNode(
nodeName,
point.x,
point.y,
- circleMatch[4] ?? '',
- isNaN(parsedRadius) ? undefined : parsedRadius
+ circleMatch[5] ?? '',
+ isNaN(parsedRadius) ? undefined : parsedRadius,
+ optsStyle
)
);
}
continue;
}
- const circuitMatch = trimmed.match(
- /\\draw\s*(\(\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*\))\s*to\s*\[\s*([A-Za-z]+).*?(?:l=\{(.*?)\})?.*?\]\s*(\(\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*\))\s*;/
+ // 3. Circuit component matching
+ const circuitMatch = stmt.match(
+ /\\draw(?:\s*\[([^\]]*)\])?\s*(\([^)]+\))\s*to\s*\[\s*([A-Za-z]+).*?(?:l=\{(.*?)\})?.*?\]\s*(\([^)]+\))/
);
if (circuitMatch) {
- const start = this.parsePoint(circuitMatch[1], currentShift);
- const end = this.parsePoint(circuitMatch[4], currentShift);
+ const start = this.parsePoint(circuitMatch[2], currentShift, namedCoords);
+ const end = this.parsePoint(circuitMatch[5], currentShift, namedCoords);
if (start && end) {
const templates = [
...AssetsManager.getCoreComponents(),
@@ -188,7 +486,8 @@ export class TikzCodec {
throw new Error("Core component 'Wire' not found");
}
const template =
- templates.find(t => t.tikzCommand.includes(`to[${circuitMatch[2]}`)) ?? wireTemplate;
+ templates.find(t => t.tikzCommand.includes(`to[${circuitMatch[3]}`)) ?? wireTemplate;
+ const optsStyle = this.parseOptions(circuitMatch[1] ?? '');
parsedElements.push({
id: this.createId(),
type: 'wire',
@@ -197,9 +496,14 @@ export class TikzCodec {
y: this.toCanvasY(start.y),
x2: this.toCanvasX(end.x),
y2: this.toCanvasY(end.y),
- label: circuitMatch[3] ?? template.name,
+ label: circuitMatch[4] ?? template.name,
rotation: 0,
- style: { ...this.defaultStyle },
+ style: {
+ ...this.defaultStyle,
+ color: optsStyle.color ?? this.defaultStyle.color,
+ rawColor: optsStyle.rawColor,
+ thickness: optsStyle.thickness ?? this.defaultStyle.thickness
+ },
svgMarkup: template.svgMarkup,
tikzCommand: template.tikzCommand
});
@@ -207,16 +511,39 @@ export class TikzCodec {
continue;
}
- const rectMatch = trimmed.match(
- /\\(draw|fill)\s*(\(\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*\))\s*\+\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)\s*rectangle\s*\+\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)\s*;/
+ // 4. Plot coordinates matching
+ const plotMatch = stmt.match(
+ /\\draw(?:\s*\[([^\]]*)\])?\s*plot(?:\s*\[[^\]]*\])?\s*coordinates\s*\{([^}]+)\}/
+ );
+ if (plotMatch) {
+ const optsStyle = this.parseOptions(plotMatch[1] ?? '');
+ const rawCoordsStr = plotMatch[2];
+ const coordMatchesList = Array.from(rawCoordsStr.matchAll(/\([^)]+\)/g));
+ const pts: { x: number; y: number }[] = [];
+ for (const m of coordMatchesList) {
+ const p = this.parsePoint(m[0], currentShift, namedCoords);
+ if (p) pts.push(p);
+ }
+ for (let i = 0; i < pts.length - 1; i++) {
+ parsedElements.push(
+ this.makeWire(pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y, optsStyle)
+ );
+ }
+ continue;
+ }
+
+ // 5. Rectangle matching
+ const rectMatch = stmt.match(
+ /\\(draw|fill)(?:\s*\[([^\]]*)\])?\s*(\([^)]+\))\s*\+\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)\s*rectangle\s*\+\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/
);
if (rectMatch) {
- const point = this.parsePoint(rectMatch[2], currentShift);
+ const point = this.parsePoint(rectMatch[3], currentShift, namedCoords);
if (point) {
const template = AssetsManager.getCoreComponents().find(t => t.name === 'Rectangle');
if (!template) {
throw new Error("Core component 'Rectangle' not found");
}
+ const optsStyle = this.parseOptions(rectMatch[2] ?? '');
parsedElements.push({
id: this.createId(),
type: 'component',
@@ -225,7 +552,11 @@ export class TikzCodec {
y: this.toCanvasY(point.y),
label: '',
rotation: 0,
- style: { ...this.defaultStyle },
+ style: {
+ ...this.defaultStyle,
+ color: optsStyle.color ?? this.defaultStyle.color,
+ rawColor: optsStyle.rawColor
+ },
svgMarkup: template.svgMarkup,
tikzCommand: template.tikzCommand
});
@@ -233,71 +564,22 @@ export class TikzCodec {
continue;
}
- const triMatch = trimmed.match(
- /\\draw\s*(\(\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*\))\s*\+\(0\s*,\s*0\.4\)\s*--\s*\+\(-0\.4\s*,\s*-0\.3\)\s*--\s*\+\(0\.4\s*,\s*-0\.3\)\s*--\s*cycle\s*;/
- );
- if (triMatch) {
- const point = this.parsePoint(triMatch[1], currentShift);
- if (point) {
- const template = AssetsManager.getCoreComponents().find(t => t.name === 'Triangle');
- if (!template) {
- throw new Error("Core component 'Triangle' not found");
- }
- parsedElements.push({
- id: this.createId(),
- type: 'component',
- name: 'Triangle',
- x: this.toCanvasX(point.x),
- y: this.toCanvasY(point.y),
- label: '',
- rotation: 0,
- style: { ...this.defaultStyle },
- svgMarkup: template.svgMarkup,
- tikzCommand: template.tikzCommand
- });
- }
- continue;
- }
+ // 6. Generic \draw paths matching (multi-point lines / arrows)
+ if (stmt.startsWith('\\draw')) {
+ const drawOptsMatch = stmt.match(/^\\draw\s*\[([^\]]*)\]/);
+ const optsStyle = this.parseOptions(drawOptsMatch?.[1] ?? '');
- if (trimmed.startsWith('\\draw')) {
- const drawOptsMatch = trimmed.match(/^\\draw\s*\[([^\]]*)\]/);
- const opts = drawOptsMatch?.[1] ?? '';
- let wireName: 'Wire' | 'Dashed Wire' | 'Arrow' = 'Wire';
- if (opts.includes('dashed')) {
- wireName = 'Dashed Wire';
- } else if (opts.includes('-stealth') || opts.includes('->') || opts.includes('-latex')) {
- wireName = 'Arrow';
+ const pointMatchesList = Array.from(stmt.matchAll(/\([^)]+\)/g));
+ const pts: { x: number; y: number }[] = [];
+ for (const m of pointMatchesList) {
+ const p = this.parsePoint(m[0], currentShift, namedCoords);
+ if (p) pts.push(p);
}
- const pointMatches = Array.from(
- trimmed.matchAll(/\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/g)
- );
- for (let index = 0; index < pointMatches.length - 1; index++) {
- const first = pointMatches[index];
- const second = pointMatches[index + 1];
- const startX = this.parseNumber(first[1]) + currentShift.x;
- const startY = this.parseNumber(first[2]) + currentShift.y;
- const endX = this.parseNumber(second[1]) + currentShift.x;
- const endY = this.parseNumber(second[2]) + currentShift.y;
- const template = AssetsManager.getCoreComponents().find(t => t.name === wireName);
- if (!template) {
- throw new Error(`Core component '${wireName}' not found`);
- }
-
- parsedElements.push({
- id: this.createId(),
- type: 'wire',
- name: wireName,
- x: this.toCanvasX(startX),
- y: this.toCanvasY(startY),
- x2: this.toCanvasX(endX),
- y2: this.toCanvasY(endY),
- label: '',
- rotation: 0,
- style: { ...this.defaultStyle },
- svgMarkup: template.svgMarkup,
- tikzCommand: template.tikzCommand
- });
+ for (let index = 0; index < pts.length - 1; index++) {
+ const startPt = pts[index];
+ const endPt = pts[index + 1];
+ parsedElements.push(this.makeWire(startPt.x, startPt.y, endPt.x, endPt.y, optsStyle));
}
}
}
@@ -310,6 +592,14 @@ export class TikzCodec {
const neededLibraries = new Set();
elements.forEach(elem => {
+ if (
+ elem.name === 'Arrow' ||
+ elem.style.arrowStyle ||
+ elem.tikzCommand?.includes('stealth') ||
+ elem.tikzCommand?.includes('Triangle')
+ ) {
+ neededLibraries.add('arrows.meta');
+ }
const regPkg = AssetsManager.getRegistry().find(p =>
p.components.some(comp => comp.name === elem.name)
);
@@ -351,13 +641,37 @@ export class TikzCodec {
cmd = cmd.replace(/circle\s*\([^)]*\)/g, `circle (${elem.radius}pt)`);
}
+ // Handle style options insertion into TikZ command
+ const opts: string[] = [];
+
if (elem.style.thickness && elem.style.thickness !== 1.0) {
- if (cmd.startsWith('\\draw') && cmd.includes('line width=')) {
- cmd = cmd.replace(/line width=[^,\]]+/, `line width=${elem.style.thickness}pt`);
- } else if (cmd.startsWith('\\draw[')) {
- cmd = cmd.replace(/\\draw\[/, `\\draw[line width=${elem.style.thickness}pt, `);
+ opts.push(`line width=${elem.style.thickness}pt`);
+ }
+ if (elem.style.lineStyle === 'dotted') {
+ opts.push('dotted');
+ } else if (elem.style.lineStyle === 'dashed') {
+ opts.push('dashed');
+ }
+ if (elem.style.rawColor) {
+ opts.push(`color=${elem.style.rawColor}`);
+ } else if (elem.style.color && elem.style.color !== '#f8e7ad') {
+ 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[line width=${elem.style.thickness}pt]`);
+ 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}]`);
}
}
diff --git a/src/features/tikz/renderer.ts b/src/features/tikz/renderer.ts
index 49f33ae..3071bbe 100644
--- a/src/features/tikz/renderer.ts
+++ b/src/features/tikz/renderer.ts
@@ -48,15 +48,7 @@ export class TikzRenderer {
public async render(source: string): Promise {
const code = this.tidyTikzSource(source);
-
- // Check cache first
- const cachedHtml = this.renderCache.get(code);
- if (cachedHtml !== undefined) {
- const domParser = new DOMParser();
- const doc = domParser.parseFromString(cachedHtml, 'text/html');
- const svg = doc.querySelector('svg') as unknown as SVGElement;
- return svg;
- }
+ this.renderCache.delete(code);
// Lazy-load tikzjax.css if not loaded yet
if (!this.tikzjaxCss) {
@@ -202,7 +194,19 @@ export class TikzRenderer {
// Trim whitespace and remove empty lines
lines = lines.map(line => line.trim()).filter(line => line);
- return lines.join('\n');
+ let cleaned = lines.join('\n');
+ const needsArrowsMeta =
+ (cleaned.includes('Triangle') ||
+ cleaned.includes('stealth') ||
+ cleaned.includes('-{') ||
+ cleaned.includes('}->')) &&
+ !cleaned.includes('arrows.meta');
+
+ if (needsArrowsMeta) {
+ cleaned = `\\usetikzlibrary{arrows.meta}\n${cleaned}`;
+ }
+
+ return cleaned;
}
private registerCodeBlockProcessor() {
diff --git a/src/features/tikz/tikzjax/loader.ts b/src/features/tikz/tikzjax/loader.ts
index d6d19f3..c12763f 100644
--- a/src/features/tikz/tikzjax/loader.ts
+++ b/src/features/tikz/tikzjax/loader.ts
@@ -42,9 +42,6 @@ class CustomHTMLMachine extends HTMLMachine {
this.textBlockOriginV = pos.v;
}
- const left = pos.h * this.pointsPerDviUnit;
- const top = pos.v * this.pointsPerDviUnit;
-
this.svgDepth = this.svgDepth || 0;
this.svgDepth += (svgStr.match(/