feat: enhance TikZ editor with tooltips for keyboard shortcuts, add thickness control, and improve snapping functionality

- Updated left sidebar tool buttons to include keyboard shortcuts in titles.
- Added a thickness slider in the right sidebar for adjusting element thickness.
- Refactored snapping functionality to allow for different modes (grid, half, none) and updated related UI elements.
- Improved error handling in TikZ code generation and added support for new shapes (circles, rectangles, triangles).
- Enhanced CSS for better layout and user experience, including adjustments for canvas controls and element interactions.
This commit is contained in:
JK 2026-06-05 21:52:46 +02:00
parent 2bf059a65f
commit f17bc66c4e
21 changed files with 5218 additions and 3673 deletions

View file

@ -4,7 +4,7 @@ import { builtinModules } from 'node:module';
import fs from 'fs';
import path from 'path';
import inlineWorkerPlugin from 'esbuild-plugin-inline-worker';
import esbuildSvelte from 'esbuild-svelte';
const builtins = [
...new Set([...builtinModules, ...builtinModules.map(moduleName => `node:${moduleName}`)])
@ -27,7 +27,6 @@ async function build() {
bundle: true,
plugins: [
inlineWorkerPlugin({ format: 'iife' }),
esbuildSvelte({ compilerOptions: { css: 'injected' } }),
{
name: 'copy-to-dev-vault-plugin',
setup(build) {

View file

@ -45,7 +45,6 @@
"deepmerge": "^4.3.1",
"electron": "^42.3.0",
"esbuild": "^0.28.0",
"esbuild-svelte": "^0.9.5",
"eslint": "^10.4.1",
"eslint-plugin-obsidianmd": "^0.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
@ -58,7 +57,6 @@
"pdf-lib": "^1.17.1",
"prettier": "^3.8.3",
"sass": "^1.100.0",
"svelte": "^5.56.1",
"ts-jest": "^29.4.11",
"tslib": "2.8.1",
"typescript": "6.0.3",
@ -69,7 +67,6 @@
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.0",
"@lezer/common": "^1.5.2",
"@lucide/svelte": "^1.17.0",
"dvi2html": "0.0.2",
"esbuild-plugin-inline-worker": "^0.1.1",
"flatqueue": "^3.0.0",

File diff suppressed because it is too large Load diff

View file

@ -344,20 +344,30 @@ function createTagManagerPlugin(
);
}
function createEquationField(plugin: LatexReferencer): StateField<EquationState> {
return StateField.define<EquationState>({
create(state) {
return parseEquationInfo(state, plugin);
},
update(value, tr) {
if (!tr.docChanged) return value;
return parseEquationInfo(tr.state, plugin);
}
});
let equationField: StateField<EquationState> | null = null;
let activePlugin: LatexReferencer | null = null;
function getEquationField(): StateField<EquationState> {
if (!equationField) {
equationField = StateField.define<EquationState>({
create(state) {
if (!activePlugin) return [];
return parseEquationInfo(state, activePlugin);
},
update(value, tr) {
if (!tr.docChanged) return value;
if (!activePlugin) return [];
return parseEquationInfo(tr.state, activePlugin);
}
});
}
return equationField;
}
/** The main export that bundles all required editor extensions. */
export function createEquationNumberPlugin(plugin: LatexReferencer): Extension {
const equationField = createEquationField(plugin);
return [mathBlockPositionsField, equationField, createTagManagerPlugin(plugin, equationField)];
activePlugin = plugin;
const eqField = getEquationField();
return [mathBlockPositionsField, eqField, createTagManagerPlugin(plugin, eqField)];
}

View file

@ -1,41 +0,0 @@
<script lang="ts">
import { type ParamType } from "./render";
import { SquareCheckBig, Loader } from "@lucide/svelte";
interface Props {
startCount: number;
}
let { startCount }: Props = $props();
let renderStates = $state<{ filename: string; status: number }[]>([]);
export function initRenderStates(data: ParamType[]) {
data.forEach((param) => {
renderStates.push({ status: 0, filename: param.file.name });
});
}
export function updateRenderStates(i: number) {
renderStates[i].status = 1;
}
</script>
<div class="progress">
<div>Rendering...</div>
{#each renderStates as item}
<div>
{#if item.status}
<SquareCheckBig size="14" />
{:else}
<Loader size="14" />
{/if}
{item.filename}
</div>
{/each}
</div>
<style>
.progress {
font-size: 14px;
}
</style>

View file

@ -0,0 +1,97 @@
import { type ParamType } from './render';
interface Props {
startCount: number;
}
export class Progress {
private container: HTMLDivElement;
private renderStates: { filename: string; status: number; element?: HTMLDivElement }[] = [];
constructor(options: { target: HTMLElement; props: Props }) {
console.log('[Progress] Initializing with props:', options.props);
this.container = options.target.createDiv({ cls: 'progress' });
this.container.style.fontSize = '14px';
this.container.createDiv({ text: 'Rendering...' });
}
initRenderStates(data: ParamType[]) {
console.log('[Progress] initRenderStates with data:', data);
this.renderStates = [];
// Clear previous items if any
const existingItems = this.container.querySelectorAll('.progress-item');
existingItems.forEach(el => el.remove());
data.forEach(param => {
const itemDiv = this.container.createDiv({ cls: 'progress-item' });
itemDiv.style.display = 'flex';
itemDiv.style.alignItems = 'center';
itemDiv.style.gap = '6px';
itemDiv.style.marginTop = '4px';
const iconSpan = itemDiv.createSpan({ cls: 'progress-icon' });
// Lucide-like loader SVG (with some basic style to rotate if desired)
iconSpan.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-loader spin-icon" style="animation: spin 1s linear infinite;">
<path d="M12 2v4"/>
<path d="m16.2 6.2 2.9-2.9"/>
<path d="M18 12h4"/>
<path d="m16.2 17.8 2.9 2.9"/>
<path d="M12 18v4"/>
<path d="m4.9 19.1 2.9-2.9"/>
<path d="M2 12h4"/>
<path d="m4.9 4.9 2.9 2.9"/>
</svg>
`;
itemDiv.createSpan({ text: param.file.name });
this.renderStates.push({
status: 0,
filename: param.file.name,
element: itemDiv
});
});
// Ensure CSS keyframes for rotation are added to the document if not present
if (!document.getElementById('progress-spin-style')) {
const style = document.createElement('style');
style.id = 'progress-spin-style';
style.innerHTML = `
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);
}
}
updateRenderStates(i: number) {
console.log('[Progress] updateRenderStates at index:', i);
if (this.renderStates[i]) {
this.renderStates[i].status = 1;
const itemDiv = this.renderStates[i].element;
if (itemDiv) {
const iconSpan = itemDiv.querySelector('.progress-icon');
if (iconSpan) {
// Lucide-like square-check-big SVG
iconSpan.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-check-big" style="color: var(--text-success, #4caf50);">
<path d="m9 11 3 3L22 4"/>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
</svg>
`;
}
}
}
}
destroy() {
console.log('[Progress] destroy');
if (this.container) {
this.container.remove();
}
}
}

View file

@ -0,0 +1,204 @@
import { ComponentTemplate, TikzPackage } from './types';
export class AssetsManager {
private static corePackages: ComponentTemplate[] = [
{
name: 'Text',
type: 'text',
category: 'Basic',
svgMarkup: `<svg viewBox="0 0 40 40" width="30" height="30" style="color: var(--text-normal);"><text x="50%" y="60%" dominant-baseline="middle" text-anchor="middle" font-size="22" font-family="serif" font-weight="bold" fill="currentColor">Aa</text></svg>`,
tikzCommand: '\\node[font={fontSize}] at ({x}, {y}) {{label}};'
},
{
name: 'Wire',
type: 'wire',
category: 'Basic',
svgMarkup: `<svg viewBox="0 0 40 40" width="30" height="30" style="color: var(--text-normal);"><line x1="5" y1="20" x2="35" y2="20" stroke="currentColor" stroke-width="2.5" /><circle cx="5" cy="20" r="3" fill="currentColor"/><circle cx="35" cy="20" r="3" fill="currentColor"/></svg>`,
tikzCommand: '\\draw[line width=0.8pt] ({x}, {y}) -- ({x2}, {y2});'
},
{
name: 'End node',
type: 'component',
category: 'Basic',
svgMarkup: `<svg viewBox="0 0 40 40" width="30" height="30" style="color: var(--text-normal);"><line x1="5" y1="20" x2="25" y2="20" stroke="currentColor" stroke-width="2" /><circle cx="25" cy="20" r="5" fill="currentColor" /></svg>`,
tikzCommand: '\\fill ({x}, {y}) circle (2pt) node[anchor=west] {{label}};'
},
{
name: 'Filled node',
type: 'component',
category: 'Basic',
svgMarkup: `<svg viewBox="0 0 40 40" width="30" height="30" style="color: var(--text-normal);"><circle cx="20" cy="20" r="5" fill="currentColor" /></svg>`,
tikzCommand: '\\fill ({x}, {y}) circle (2pt);'
},
{
name: 'Open node',
type: 'component',
category: 'Basic',
svgMarkup: `<svg viewBox="0 0 40 40" width="30" height="30" style="color: var(--text-normal);"><circle cx="20" cy="20" r="5" fill="none" stroke="currentColor" stroke-width="2" /></svg>`,
tikzCommand: '\\draw ({x}, {y}) circle (2pt);'
},
{
name: 'Junction node',
type: 'component',
category: 'Basic',
svgMarkup: `<svg viewBox="0 0 40 40" width="30" height="30" style="color: var(--text-normal);"><line x1="5" y1="20" x2="35" y2="20" stroke="currentColor" stroke-width="2" /><line x1="20" y1="20" x2="20" y2="35" stroke="currentColor" stroke-width="2" /><circle cx="20" cy="20" r="5" fill="currentColor" /></svg>`,
tikzCommand: '\\fill ({x}, {y}) circle (2.5pt);'
},
{
name: 'Dashed Wire',
type: 'wire',
category: 'Basic',
svgMarkup: `<svg viewBox="0 0 40 40" width="30" height="30" style="color: var(--text-normal);"><line x1="5" y1="20" x2="35" y2="20" stroke="currentColor" stroke-width="2.5" stroke-dasharray="4,3" /><circle cx="5" cy="20" r="3" fill="currentColor"/><circle cx="35" cy="20" r="3" fill="currentColor"/></svg>`,
tikzCommand: '\\draw[line width=0.8pt, dashed] ({x}, {y}) -- ({x2}, {y2});'
},
{
name: 'Arrow',
type: 'wire',
category: 'Basic',
svgMarkup: `<svg viewBox="0 0 40 40" width="30" height="30" style="color: var(--text-normal);"><line x1="5" y1="20" x2="30" y2="20" stroke="currentColor" stroke-width="2.5" /><polygon points="30,15 38,20 30,25" fill="currentColor"/><circle cx="5" cy="20" r="3" fill="currentColor"/></svg>`,
tikzCommand: '\\draw[line width=0.8pt, -stealth] ({x}, {y}) -- ({x2}, {y2});'
},
{
name: 'Circle',
type: 'component',
category: 'Basic',
svgMarkup: `<svg viewBox="0 0 40 40" width="30" height="30" style="color: var(--text-normal);"><circle cx="20" cy="20" r="12" fill="none" stroke="currentColor" stroke-width="2" /></svg>`,
tikzCommand: '\\draw ({x}, {y}) circle (12pt);'
},
{
name: 'Filled Circle',
type: 'component',
category: 'Basic',
svgMarkup: `<svg viewBox="0 0 40 40" width="30" height="30" style="color: var(--text-normal);"><circle cx="20" cy="20" r="12" fill="currentColor" /></svg>`,
tikzCommand: '\\fill ({x}, {y}) circle (12pt);'
},
{
name: 'Rectangle',
type: 'component',
category: 'Basic',
svgMarkup: `<svg viewBox="0 0 40 40" width="30" height="30" style="color: var(--text-normal);"><rect x="8" y="8" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" /></svg>`,
tikzCommand: '\\draw ({x}, {y}) +(-0.4,-0.4) rectangle +(0.4,0.4);'
},
{
name: 'Triangle',
type: 'component',
category: 'Basic',
svgMarkup: `<svg viewBox="0 0 40 40" width="30" height="30" style="color: var(--text-normal);"><polygon points="20,8 8,30 32,30" fill="none" stroke="currentColor" stroke-width="2" /></svg>`,
tikzCommand: '\\draw ({x}, {y}) +(0,0.4) -- +(-0.4,-0.3) -- +(0.4,-0.3) -- cycle;'
}
];
private static registry: TikzPackage[] = [
{
name: 'circuitikz',
displayName: 'CircuiTikZ',
installed: false,
components: [
{
name: 'Resistor (IEC)',
type: 'wire',
category: 'Circuits',
svgMarkup: `<svg viewBox="0 0 50 20" width="40" height="20" style="color: var(--text-normal);"><line x1="0" y1="10" x2="10" y2="10" stroke="currentColor" stroke-width="2"/><rect x="10" y="4" width="30" height="12" fill="none" stroke="currentColor" stroke-width="2"/><line x1="40" y1="10" x2="50" y2="10" stroke="currentColor" stroke-width="2"/></svg>`,
tikzCommand: '\\draw ({x}, {y}) to[R, l={label}] ({x2}, {y2});'
},
{
name: 'American Resistor',
type: 'wire',
category: 'Circuits',
svgMarkup: `<svg viewBox="0 0 50 20" width="40" height="20" style="color: var(--text-normal);"><path d="M0,10 L10,10 L13,3 L19,17 L25,3 L31,17 L37,3 L40,10 L50,10" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>`,
tikzCommand: '\\draw ({x}, {y}) to[R, style=american, l={label}] ({x2}, {y2});'
},
{
name: 'Capacitor (IEC)',
type: 'wire',
category: 'Circuits',
svgMarkup: `<svg viewBox="0 0 50 20" width="40" height="20" style="color: var(--text-normal);"><line x1="0" y1="10" x2="22" y2="10" stroke="currentColor" stroke-width="2"/><line x1="22" y1="2" x2="22" y2="18" stroke="currentColor" stroke-width="2"/><line x1="28" y1="2" x2="28" y2="18" stroke="currentColor" stroke-width="2"/><line x1="28" y1="10" x2="50" y2="10" stroke="currentColor" stroke-width="2"/></svg>`,
tikzCommand: '\\draw ({x}, {y}) to[C, l={label}] ({x2}, {y2});'
},
{
name: 'Variable Capacitor',
type: 'wire',
category: 'Circuits',
svgMarkup: `<svg viewBox="0 0 50 20" width="40" height="20" style="color: var(--text-normal);"><line x1="0" y1="10" x2="22" y2="10" stroke="currentColor" stroke-width="2"/><line x1="22" y1="2" x2="22" y2="18" stroke="currentColor" stroke-width="2"/><line x1="28" y1="2" x2="28" y2="18" stroke="currentColor" stroke-width="2"/><line x1="28" y1="10" x2="50" y2="10" stroke="currentColor" stroke-width="2"/><line x1="16" y1="16" x2="34" y2="4" stroke="currentColor" stroke-width="1.5"/></svg>`,
tikzCommand: '\\draw ({x}, {y}) to[vC, l={label}] ({x2}, {y2});'
},
{
name: 'Polarized Capacitor',
type: 'wire',
category: 'Circuits',
svgMarkup: `<svg viewBox="0 0 50 20" width="40" height="20" style="color: var(--text-normal);"><line x1="0" y1="10" x2="22" y2="10" stroke="currentColor" stroke-width="2"/><line x1="22" y1="2" x2="22" y2="18" stroke="currentColor" stroke-width="2"/><path d="M 28 2 Q 31 10 28 18" fill="none" stroke="currentColor" stroke-width="2"/><line x1="29" y1="10" x2="50" y2="10" stroke="currentColor" stroke-width="2"/></svg>`,
tikzCommand: '\\draw ({x}, {y}) to[pC, l={label}] ({x2}, {y2});'
},
{
name: 'Inductor',
type: 'wire',
category: 'Circuits',
svgMarkup: `<svg viewBox="0 0 50 20" width="40" height="20" style="color: var(--text-normal);"><path d="M0,10 L10,10 Q14,3 18,10 Q22,3 26,10 Q30,3 34,10 Q38,3 40,10 L50,10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>`,
tikzCommand: '\\draw ({x}, {y}) to[L, l={label}] ({x2}, {y2});'
}
]
},
{
name: 'tikz-logic',
displayName: 'Logic Gates',
installed: false,
components: [
{
name: 'AND Gate',
type: 'component',
category: 'Logic',
svgMarkup: `<svg viewBox="0 0 50 30" width="40" height="25" style="color: var(--text-normal);"><path d="M5,5 L20,5 A10,10 0 0 1 30,15 A10,10 0 0 1 20,25 L5,25 Z" fill="none" stroke="currentColor" stroke-width="2"/><line x1="0" y1="10" x2="5" y2="10" stroke="currentColor" stroke-width="2"/><line x1="0" y1="20" x2="5" y2="20" stroke="currentColor" stroke-width="2"/><line x1="30" y1="15" x2="45" y2="15" stroke="currentColor" stroke-width="2"/></svg>`,
tikzCommand:
'\\node[and gate US, draw, logic gate inputs=nn] at ({x}, {y}) (and1) {{label}};'
},
{
name: 'OR Gate',
type: 'component',
category: 'Logic',
svgMarkup: `<svg viewBox="0 0 50 30" width="40" height="25" style="color: var(--text-normal);"><path d="M5,5 Q15,15 5,25 Q18,25 30,15 Q18,5 5,5 Z" fill="none" stroke="currentColor" stroke-width="2"/><line x1="0" y1="10" x2="7" y2="10" stroke="currentColor" stroke-width="2"/><line x1="0" y1="20" x2="7" y2="20" stroke="currentColor" stroke-width="2"/><line x1="30" y1="15" x2="45" y2="15" stroke="currentColor" stroke-width="2"/></svg>`,
tikzCommand:
'\\node[or gate US, draw, logic gate inputs=nn] at ({x}, {y}) (or1) {{label}};'
},
{
name: 'NOT Gate',
type: 'component',
category: 'Logic',
svgMarkup: `<svg viewBox="0 0 50 30" width="40" height="25" style="color: var(--text-normal);"><polygon points="10,5 30,15 10,25" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="34" cy="15" r="3" fill="none" stroke="currentColor" stroke-width="2"/><line x1="0" y1="15" x2="10" y2="15" stroke="currentColor" stroke-width="2"/><line x1="37" y1="15" x2="47" y2="15" stroke="currentColor" stroke-width="2"/></svg>`,
tikzCommand: '\\node[not gate US, draw] at ({x}, {y}) (not1) {{label}};'
}
]
}
];
public static getCoreComponents(): ComponentTemplate[] {
return [...this.corePackages];
}
public static getRegistry(): TikzPackage[] {
return this.registry;
}
public static async installPackage(packageName: string): Promise<boolean> {
const pkg = this.registry.find(p => p.name === packageName);
if (!pkg) return false;
// Simulate package metadata and asset download latency
await new Promise(resolve => setTimeout(resolve, 1500));
// Optional: Real fetch for additional dynamic data if required, e.g.
// try {
// const res = await requestUrl(`https://raw.githubusercontent.com/YouFoundJK/ObsiTeXcore/main/assets/packages/${packageName}.json`);
// if (res.status === 200) { ... }
// } catch (e) { console.log("Fetch failed, using fallback"); }
pkg.installed = true;
return true;
}
public static async uninstallPackage(packageName: string): Promise<boolean> {
const pkg = this.registry.find(p => p.name === packageName);
if (!pkg) return false;
pkg.installed = false;
return true;
}
}

View file

@ -0,0 +1,661 @@
import { type TikzEditorContext, type EditorElement } from '../types';
import { renderMath, finishRenderMath } from 'obsidian';
export class CanvasGrid {
private draggingId: string | null = null;
private dragStartCoords: { x: number; y: number } | null = null;
private dragOffset: { x: number; y: number } | null = null;
private wireStart: { x: number; y: number } | null = null;
private wireCurrent: { x: number; y: number } | null = null;
constructor(
private context: TikzEditorContext,
private containerEl: HTMLElement,
private workspaceEl: HTMLDivElement,
private wiresOverlayEl: SVGElement
) {
// Canvas mousedown listener
this.workspaceEl.addEventListener('mousedown', e => this.handleCanvasMouseDown(e));
}
public render() {
const gridBg = this.workspaceEl.querySelector('.grid-background');
if (gridBg) {
gridBg.className = 'grid-background' + (this.context.isHalfGrid() ? ' half-grid' : '');
}
// Axes
this.renderAxes();
// Wires
this.renderWires();
// Placed Elements
this.renderPlacedElements();
}
private renderAxes() {
const axisOverlay = this.workspaceEl.querySelector('.axis-overlay');
if (!axisOverlay) return;
axisOverlay.innerHTML = '';
const svgNS = 'http://www.w3.org/2000/svg';
const lineX = activeDocument.createElementNS(svgNS, 'line');
lineX.setAttribute('x1', '0');
lineX.setAttribute('y1', this.context.ORIGIN_Y.toString());
lineX.setAttribute('x2', this.context.CANVAS_WIDTH.toString());
lineX.setAttribute('y2', this.context.ORIGIN_Y.toString());
axisOverlay.appendChild(lineX);
const lineY = activeDocument.createElementNS(svgNS, 'line');
lineY.setAttribute('x1', this.context.ORIGIN_X.toString());
lineY.setAttribute('y1', '0');
lineY.setAttribute('x2', this.context.ORIGIN_X.toString());
lineY.setAttribute('y2', this.context.CANVAS_HEIGHT.toString());
axisOverlay.appendChild(lineY);
// X labels
for (let x = 0; x <= 12; 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());
text.textContent = x.toString();
axisOverlay.appendChild(text);
}
// Y labels
for (let y = -4; y <= 4; y++) {
if (y !== 0) {
const text = activeDocument.createElementNS(svgNS, 'text');
text.setAttribute('x', (this.context.ORIGIN_X - 12).toString());
text.setAttribute(
'y',
(this.context.ORIGIN_Y - y * this.context.PX_PER_UNIT + 4).toString()
);
text.textContent = y.toString();
axisOverlay.appendChild(text);
}
}
}
public renderWires() {
this.wiresOverlayEl.innerHTML = '';
const svgNS = 'http://www.w3.org/2000/svg';
const selectedElementId = this.context.getSelectedElementId();
const elements = this.context.getElements();
// Draw elements
elements.forEach(elem => {
if (elem.type !== 'wire' || elem.x2 === undefined || elem.y2 === undefined) return;
const group = activeDocument.createElementNS(svgNS, 'g');
group.setAttribute(
'class',
'wire-group' + (selectedElementId === elem.id ? ' selected' : '')
);
group.addEventListener('mousedown', e => this.handleElementMouseDown(e, elem));
const lineClick = activeDocument.createElementNS(svgNS, 'line');
lineClick.setAttribute('x1', elem.x.toString());
lineClick.setAttribute('y1', elem.y.toString());
lineClick.setAttribute('x2', elem.x2.toString());
lineClick.setAttribute('y2', elem.y2.toString());
lineClick.setAttribute('stroke', 'transparent');
lineClick.setAttribute('stroke-width', '12');
lineClick.style.cursor = 'pointer';
group.appendChild(lineClick);
if (elem.name === 'Wire') {
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',
selectedElementId === elem.id
? 'var(--text-accent)'
: elem.style.color || 'var(--text-normal)'
);
line.setAttribute('stroke-width', (elem.style.thickness ?? 1.0).toString());
group.appendChild(line);
} else {
const dx = elem.x2 - elem.x;
const dy = elem.y2 - elem.y;
const len = Math.hypot(dx, dy);
const angle = Math.atan2(dy, dx) * (180 / Math.PI);
const midX = elem.x + dx / 2;
const midY = elem.y + dy / 2;
const margin = Math.min(20, len / 2);
const startX = elem.x + (dx / len) * (len / 2 - margin);
const startY = elem.y + (dy / len) * (len / 2 - margin);
const endX = elem.x2 - (dx / len) * (len / 2 - margin);
const endY = elem.y2 - (dy / len) * (len / 2 - margin);
const line1 = activeDocument.createElementNS(svgNS, 'line');
line1.setAttribute('x1', elem.x.toString());
line1.setAttribute('y1', elem.y.toString());
line1.setAttribute('x2', startX.toString());
line1.setAttribute('y2', startY.toString());
line1.setAttribute(
'stroke',
selectedElementId === elem.id
? 'var(--text-accent)'
: elem.style.color || 'var(--text-normal)'
);
line1.setAttribute('stroke-width', (elem.style.thickness ?? 1.0).toString());
group.appendChild(line1);
const line2 = activeDocument.createElementNS(svgNS, 'line');
line2.setAttribute('x1', endX.toString());
line2.setAttribute('y1', endY.toString());
line2.setAttribute('x2', elem.x2.toString());
line2.setAttribute('y2', elem.y2.toString());
line2.setAttribute(
'stroke',
selectedElementId === elem.id
? 'var(--text-accent)'
: elem.style.color || 'var(--text-normal)'
);
line2.setAttribute('stroke-width', (elem.style.thickness ?? 1.0).toString());
group.appendChild(line2);
const innerG = activeDocument.createElementNS(svgNS, 'g');
innerG.setAttribute(
'transform',
`translate(${midX}, ${midY}) rotate(${angle}) translate(-25, -10)`
);
const rect = activeDocument.createElementNS(svgNS, 'rect');
rect.setAttribute('x', '-2');
rect.setAttribute('y', '-2');
rect.setAttribute('width', '54');
rect.setAttribute('height', '24');
rect.setAttribute('fill', 'var(--background-primary)');
rect.setAttribute('stroke', 'none');
innerG.appendChild(rect);
const svgWrap = activeDocument.createElementNS(svgNS, 'g');
svgWrap.setAttribute('class', 'comp-svg-fill');
svgWrap.style.color =
selectedElementId === elem.id
? 'var(--text-accent)'
: elem.style.color || 'var(--text-normal)';
svgWrap.innerHTML = elem.svgMarkup;
innerG.appendChild(svgWrap);
group.appendChild(innerG);
if (elem.label) {
if (elem.style.math) {
const foreign = activeDocument.createElementNS(svgNS, 'foreignObject');
foreign.setAttribute('x', (midX - 100).toString());
foreign.setAttribute('y', (midY - 28).toString());
foreign.setAttribute('width', '200');
foreign.setAttribute('height', '24');
foreign.style.overflow = 'visible';
const div = activeDocument.createElement('div');
div.style.display = 'flex';
div.style.justifyContent = 'center';
div.style.alignItems = 'center';
div.style.width = '100%';
div.style.height = '100%';
div.style.fontSize = `${elem.style.fontSize}px`;
div.style.color = elem.style.color || 'var(--text-normal)';
try {
const mathEl = renderMath(elem.label, false);
div.appendChild(mathEl);
const MathJax = (window as typeof window & { MathJax?: { chtmlStylesheet?: unknown } }).MathJax;
if (MathJax && typeof MathJax.chtmlStylesheet === 'function') {
void finishRenderMath();
}
} catch {
div.textContent = elem.label;
}
foreign.appendChild(div);
group.appendChild(foreign);
} else {
const text = activeDocument.createElementNS(svgNS, 'text');
text.setAttribute('x', midX.toString());
text.setAttribute('y', (midY - 18).toString());
text.setAttribute('font-size', `${elem.style.fontSize}px`);
text.setAttribute('font-weight', elem.style.bold ? 'bold' : 'normal');
text.setAttribute('font-style', elem.style.italic ? 'italic' : 'normal');
text.setAttribute('fill', elem.style.color || 'var(--text-normal)');
text.setAttribute('text-anchor', 'middle');
text.setAttribute('dominant-baseline', 'middle');
text.textContent = elem.label;
group.appendChild(text);
}
}
}
// Draw handles if this wire is selected
if (selectedElementId === elem.id) {
const handleStart = activeDocument.createElementNS(svgNS, 'circle');
handleStart.setAttribute('cx', elem.x.toString());
handleStart.setAttribute('cy', elem.y.toString());
handleStart.setAttribute('r', '6');
handleStart.setAttribute('fill', 'var(--interactive-accent)');
handleStart.setAttribute('stroke', 'var(--text-on-accent)');
handleStart.setAttribute('stroke-width', '1.5');
handleStart.style.cursor = 'move';
handleStart.addEventListener('mousedown', e => {
this.handleWireHandleMouseDown(e, elem, 'start');
});
group.appendChild(handleStart);
const handleEnd = activeDocument.createElementNS(svgNS, 'circle');
handleEnd.setAttribute('cx', elem.x2.toString());
handleEnd.setAttribute('cy', elem.y2.toString());
handleEnd.setAttribute('r', '6');
handleEnd.setAttribute('fill', 'var(--interactive-accent)');
handleEnd.setAttribute('stroke', 'var(--text-on-accent)');
handleEnd.setAttribute('stroke-width', '1.5');
handleEnd.style.cursor = 'move';
handleEnd.addEventListener('mousedown', e => {
this.handleWireHandleMouseDown(e, elem, 'end');
});
group.appendChild(handleEnd);
}
this.wiresOverlayEl.appendChild(group);
});
// Draw active wire preview
if (this.wireStart && this.wireCurrent) {
const activeTemplate = this.context.getActiveTemplate();
if (activeTemplate && activeTemplate.type === 'wire') {
const dx = this.wireCurrent.x - this.wireStart.x;
const dy = this.wireCurrent.y - this.wireStart.y;
const len = Math.hypot(dx, dy);
const angle = Math.atan2(dy, dx) * (180 / Math.PI);
const midX = this.wireStart.x + dx / 2;
const midY = this.wireStart.y + dy / 2;
const line = activeDocument.createElementNS(svgNS, 'line');
line.setAttribute('x1', this.wireStart.x.toString());
line.setAttribute('y1', this.wireStart.y.toString());
line.setAttribute('x2', this.wireCurrent.x.toString());
line.setAttribute('y2', this.wireCurrent.y.toString());
line.setAttribute('stroke', 'var(--text-accent)');
line.setAttribute('stroke-width', '1.5');
line.setAttribute('stroke-dasharray', '4,4');
this.wiresOverlayEl.appendChild(line);
const innerG = activeDocument.createElementNS(svgNS, 'g');
innerG.setAttribute(
'transform',
`translate(${midX}, ${midY}) rotate(${angle}) translate(-25, -10)`
);
innerG.style.opacity = '0.7';
const rect = activeDocument.createElementNS(svgNS, 'rect');
rect.setAttribute('x', '-2');
rect.setAttribute('y', '-2');
rect.setAttribute('width', '54');
rect.setAttribute('height', '24');
rect.setAttribute('fill', 'var(--background-primary)');
rect.setAttribute('stroke', 'none');
innerG.appendChild(rect);
const svgWrap = activeDocument.createElementNS(svgNS, 'g');
svgWrap.style.color = 'var(--text-accent)';
svgWrap.innerHTML = activeTemplate.svgMarkup;
innerG.appendChild(svgWrap);
this.wiresOverlayEl.appendChild(innerG);
} else {
const line = activeDocument.createElementNS(svgNS, 'line');
line.setAttribute('x1', this.wireStart.x.toString());
line.setAttribute('y1', this.wireStart.y.toString());
line.setAttribute('x2', this.wireCurrent.x.toString());
line.setAttribute('y2', this.wireCurrent.y.toString());
line.setAttribute('stroke', 'var(--text-accent)');
line.setAttribute('stroke-width', '2');
line.setAttribute('stroke-dasharray', '4,4');
this.wiresOverlayEl.appendChild(line);
}
}
}
private renderPlacedElements() {
this.workspaceEl.querySelectorAll('.placed-element').forEach(el => el.remove());
const selectedElementId = this.context.getSelectedElementId();
const elements = this.context.getElements();
elements.forEach(elem => {
if (elem.type !== 'component' && elem.type !== 'text') return;
const el = activeDocument.createElement('div');
el.className =
'placed-element' +
(selectedElementId === elem.id ? ' selected' : '') +
(elem.type === 'text' ? ' is-text' : '');
el.style.left = `${elem.x}px`;
el.style.top = `${elem.y}px`;
el.style.transform = `rotate(${elem.rotation}deg)`;
el.addEventListener('mousedown', e => this.handleElementMouseDown(e, elem));
if (elem.type === 'text') {
const content = activeDocument.createElement('div');
content.className = 'text-element-content';
content.style.fontSize = `${elem.style.fontSize}px`;
content.style.fontWeight = elem.style.bold ? 'bold' : 'normal';
content.style.fontStyle = elem.style.italic ? 'italic' : 'normal';
content.style.color = elem.style.color || 'var(--text-normal)';
if (elem.style.math && elem.label) {
try {
const mathEl = renderMath(elem.label, false);
content.appendChild(mathEl);
const MathJax = (window as typeof window & { MathJax?: { chtmlStylesheet?: unknown } }).MathJax;
if (MathJax && typeof MathJax.chtmlStylesheet === 'function') {
void finishRenderMath();
}
} catch {
content.textContent = elem.label;
}
} else {
content.textContent = elem.label || 'Text';
}
el.appendChild(content);
} else {
const visual = activeDocument.createElement('div');
visual.className = 'node-visual';
visual.style.color = elem.style.color || 'var(--text-normal)';
let svg = elem.svgMarkup;
if (elem.radius !== undefined) {
const svgRadius = elem.radius * 2.5;
svg = svg.replace(/r="\d+(\.\d+)?"/g, `r="${svgRadius}"`);
}
visual.innerHTML = svg;
el.appendChild(visual);
if (elem.label) {
const label = activeDocument.createElement('div');
label.className = 'node-label';
label.style.fontSize = `${elem.style.fontSize}px`;
label.style.color = elem.style.color || 'var(--text-normal)';
if (elem.style.math) {
try {
const mathEl = renderMath(elem.label, false);
label.appendChild(mathEl);
const MathJax = (window as typeof window & { MathJax?: { chtmlStylesheet?: unknown } }).MathJax;
if (MathJax && typeof MathJax.chtmlStylesheet === 'function') {
void finishRenderMath();
}
} catch {
label.textContent = elem.label;
}
} else {
label.textContent = elem.label;
}
el.appendChild(label);
}
}
this.workspaceEl.appendChild(el);
});
}
private getCanvasCoords(e: MouseEvent): { x: number; y: number } {
const rect = this.workspaceEl.getBoundingClientRect();
const zoom = this.context.getZoom();
const rawX = (e.clientX - rect.left) / zoom;
const rawY = (e.clientY - rect.top) / zoom;
if (this.context.isSnapToGrid() && !e.ctrlKey) {
const gridSize = this.context.isHalfGrid()
? this.context.PX_PER_UNIT / 2
: this.context.PX_PER_UNIT;
return {
x: Math.round((rawX - this.context.ORIGIN_X) / gridSize) * gridSize + this.context.ORIGIN_X,
y: Math.round((rawY - this.context.ORIGIN_Y) / gridSize) * gridSize + this.context.ORIGIN_Y
};
}
return { x: Math.round(rawX), y: Math.round(rawY) };
}
private handleCanvasMouseDown(e: MouseEvent) {
const activeTool = this.context.getActiveTool();
const activeTemplate = this.context.getActiveTemplate();
console.log(
'[CanvasGrid] handleCanvasMouseDown button:',
e.button,
'activeTool:',
activeTool,
'activeTemplate:',
activeTemplate?.name
);
if (e.button !== 0) return;
const coords = this.getCanvasCoords(e);
console.log('[CanvasGrid] handleCanvasMouseDown coords:', coords);
if (activeTemplate && activeTemplate.type === 'component') {
this.context.handleAddElement({
type: 'component',
name: activeTemplate.name,
x: coords.x,
y: coords.y,
label: '',
rotation: 0,
style: { bold: false, italic: false, math: false, color: '#f8e7ad', fontSize: 12, thickness: 1.0 },
svgMarkup: activeTemplate.svgMarkup,
tikzCommand: activeTemplate.tikzCommand
});
e.preventDefault();
} else if (activeTool === 'wire' || (activeTemplate && activeTemplate.type === 'wire')) {
this.wireStart = coords;
this.wireCurrent = coords;
e.preventDefault();
const onMouseMove = (moveEvent: MouseEvent) => {
if (this.wireStart) {
const mCoords = this.getCanvasCoords(moveEvent);
console.log('[CanvasGrid] handleCanvasMouseMove wire drawing coords:', mCoords);
this.wireCurrent = mCoords;
this.renderWires();
}
};
const onMouseUp = (upEvent: MouseEvent) => {
console.log('[CanvasGrid] handleCanvasMouseUp wireStart:', this.wireStart);
if (this.wireStart && this.wireCurrent) {
const dist = Math.hypot(
this.wireCurrent.x - this.wireStart.x,
this.wireCurrent.y - this.wireStart.y
);
if (dist > 10) {
const angleVal = Math.round(Math.atan2(this.wireCurrent.y - this.wireStart.y, this.wireCurrent.x - this.wireStart.x) * (180 / Math.PI));
const normalizedAngle = (angleVal + 360) % 360;
if (activeTemplate && activeTemplate.type === 'wire') {
this.context.handleAddElement({
type: 'wire',
name: activeTemplate.name,
x: this.wireStart.x,
y: this.wireStart.y,
x2: this.wireCurrent.x,
y2: this.wireCurrent.y,
label: activeTemplate.name,
rotation: normalizedAngle,
style: { bold: false, italic: false, math: false, color: '#f8e7ad', fontSize: 12, thickness: 1.0 },
svgMarkup: activeTemplate.svgMarkup,
tikzCommand: activeTemplate.tikzCommand
});
} else {
this.context.handleAddElement({
type: 'wire',
name: 'Wire',
x: this.wireStart.x,
y: this.wireStart.y,
x2: this.wireCurrent.x,
y2: this.wireCurrent.y,
label: '',
rotation: normalizedAngle,
style: { bold: false, italic: false, math: false, color: '#f8e7ad', fontSize: 12, thickness: 1.0 },
svgMarkup: `<svg viewBox="0 0 40 40"><line x1="0" y1="20" x2="40" y2="20" stroke="currentColor" stroke-width="2"/></svg>`,
tikzCommand: '\\draw[line width=0.8pt] ({x}, {y}) -- ({x2}, {y2});'
});
}
}
}
this.wireStart = null;
this.wireCurrent = null;
this.renderWires();
activeDocument.removeEventListener('mousemove', onMouseMove);
activeDocument.removeEventListener('mouseup', onMouseUp);
};
activeDocument.addEventListener('mousemove', onMouseMove);
activeDocument.addEventListener('mouseup', onMouseUp);
} else if (activeTool === 'text') {
const template = activeTemplate ?? {
name: 'Text',
type: 'text',
category: 'Basic',
svgMarkup: `<svg viewBox="0 0 40 40" width="30" height="30" style="color: var(--text-normal);"><text x="50%" y="60%" dominant-baseline="middle" text-anchor="middle" font-size="22" font-family="serif" font-weight="bold" fill="currentColor">Aa</text></svg>`,
tikzCommand: '\\node[font={fontSize}] at ({x}, {y}) {{label}};'
};
this.context.handleAddElement({
type: 'text',
name: 'Text',
x: coords.x,
y: coords.y,
label: template.name === 'Text' ? 'Label Text' : template.name,
rotation: 0,
style: { bold: false, italic: false, math: true, color: '#f8e7ad', fontSize: 12, thickness: 1.0 },
svgMarkup: template.svgMarkup,
tikzCommand: template.tikzCommand
});
} else if (activeTool === 'select') {
this.context.handleSelectElement(null);
}
}
private handleElementMouseDown(e: MouseEvent, elem: EditorElement) {
const activeTool = this.context.getActiveTool();
console.log(
'[CanvasGrid] handleElementMouseDown element:',
elem.id,
elem.name,
'activeTool:',
activeTool
);
e.stopPropagation();
if (e.button !== 0) return;
if (activeTool === 'erase') {
this.context.handleDeleteElement(elem.id);
return;
}
this.context.handleSelectElement(elem.id);
if (activeTool === 'select') {
this.draggingId = elem.id;
this.dragStartCoords = { x: e.clientX, y: e.clientY };
this.dragOffset = { x: elem.x, y: elem.y };
const onMouseMove = (moveEvent: MouseEvent) => {
if (!this.draggingId || !this.dragStartCoords || !this.dragOffset) return;
console.log('[CanvasGrid] handleElementMouseMove draggingId:', this.draggingId);
const zoom = this.context.getZoom();
const dx = (moveEvent.clientX - this.dragStartCoords.x) / zoom;
const dy = (moveEvent.clientY - this.dragStartCoords.y) / zoom;
let newX = this.dragOffset.x + dx;
let newY = this.dragOffset.y + dy;
if (this.context.isSnapToGrid() && !moveEvent.ctrlKey) {
const gridSize = this.context.isHalfGrid()
? this.context.PX_PER_UNIT / 2
: this.context.PX_PER_UNIT;
newX = Math.round((newX - this.context.ORIGIN_X) / gridSize) * gridSize + this.context.ORIGIN_X;
newY = Math.round((newY - this.context.ORIGIN_Y) / gridSize) * gridSize + this.context.ORIGIN_Y;
} else {
newX = Math.round(newX);
newY = Math.round(newY);
}
const el = this.context.getElements().find(item => item.id === this.draggingId);
if (!el) return;
if (el.type === 'wire' && el.x2 !== undefined && el.y2 !== undefined) {
const wireDx = el.x2 - el.x;
const wireDy = el.y2 - el.y;
this.context.handleUpdateElementPosition(
this.draggingId,
newX,
newY,
newX + wireDx,
newY + wireDy,
false
);
} else {
this.context.handleUpdateElementPosition(this.draggingId, newX, newY, undefined, undefined, false);
}
};
const onMouseUp = () => {
console.log('[CanvasGrid] handleElementMouseUp finished dragging');
this.context.saveHistoryState();
this.draggingId = null;
this.dragStartCoords = null;
this.dragOffset = null;
activeDocument.removeEventListener('mousemove', onMouseMove);
activeDocument.removeEventListener('mouseup', onMouseUp);
};
activeDocument.addEventListener('mousemove', onMouseMove);
activeDocument.addEventListener('mouseup', onMouseUp);
}
}
private handleWireHandleMouseDown(e: MouseEvent, elem: EditorElement, handleType: 'start' | 'end') {
e.stopPropagation();
if (e.button !== 0) return;
const onMouseMove = (moveEvent: MouseEvent) => {
const coords = this.getCanvasCoords(moveEvent);
let newX = elem.x;
let newY = elem.y;
let newX2 = elem.x2!;
let newY2 = elem.y2!;
if (handleType === 'start') {
newX = coords.x;
newY = coords.y;
} else {
newX2 = coords.x;
newY2 = coords.y;
}
const angleVal = Math.round(Math.atan2(newY2 - newY, newX2 - newX) * (180 / Math.PI));
const normalizedAngle = (angleVal + 360) % 360;
this.context.handleUpdateElement({
...elem,
x: newX,
y: newY,
x2: newX2,
y2: newY2,
rotation: normalizedAngle
}, false);
};
const onMouseUp = () => {
this.context.saveHistoryState();
activeDocument.removeEventListener('mousemove', onMouseMove);
activeDocument.removeEventListener('mouseup', onMouseUp);
};
activeDocument.addEventListener('mousemove', onMouseMove);
activeDocument.addEventListener('mouseup', onMouseUp);
}
}

View file

@ -0,0 +1,277 @@
import { type TikzEditorContext, type ComponentTemplate } from '../types';
import { AssetsManager } from '../assets-manager';
export class LeftSidebar {
constructor(
private context: TikzEditorContext,
private containerEl: HTMLElement
) {}
public render() {
this.containerEl.empty();
// 1. Toolbar
const toolbar = this.containerEl.createDiv({ cls: 'toolbar' });
// Helper to add tool button
const addToolBtn = (
tool: 'select' | 'wire' | 'text' | 'erase',
label: string,
iconHtml: string
) => {
const btn = toolbar.createEl('button', { cls: 'tool-btn', title: label });
btn.innerHTML = iconHtml;
if (this.context.getActiveTool() === tool) btn.addClass('active');
btn.onclick = () => {
console.log(`[LeftSidebar] Select tool ${tool}`);
this.context.handleSelectTool(tool);
};
};
addToolBtn(
'select',
'Select / Move [V]',
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m3 3 7.07 16.97 2.51-7.39 7.39-2.51L3 3z"/><path d="m13 13 6 6"/></svg>`
);
// Wire button
const wireBtn = toolbar.createEl('button', { cls: 'tool-btn', title: 'Wire [W]' });
wireBtn.innerHTML = `<div class="wire-icon"></div>`;
if (this.context.getActiveTool() === 'wire') wireBtn.addClass('active');
wireBtn.onclick = () => {
console.log('[LeftSidebar] Select tool wire');
this.context.handleSelectTool('wire');
};
addToolBtn(
'text',
'Text [T]',
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg>`
);
addToolBtn(
'erase',
'Eraser [E]',
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.4 5.4c1 1 1 2.5 0 3.4L13 21Z"/><path d="M22 21H7"/><path d="m5 11 9 9"/></svg>`
);
toolbar.createDiv({ cls: 'divider' });
// Snap to grid (Full)
const snapBtn = toolbar.createEl('button', { cls: 'tool-btn', title: 'Snap to grid (Full) [G]' });
snapBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/><path d="M9 3v18"/><path d="M15 3v18"/></svg>`;
if (this.context.isSnapToGrid() && !this.context.isHalfGrid()) snapBtn.addClass('active');
snapBtn.onclick = () => {
console.log('[LeftSidebar] Set snapping mode to grid');
this.context.setSnappingMode('grid');
};
// Half grid
const halfGridBtn = toolbar.createEl('button', {
cls: 'tool-btn text-toggle',
title: 'Snap to half grid [H]'
});
halfGridBtn.textContent = '.5';
if (this.context.isSnapToGrid() && this.context.isHalfGrid()) halfGridBtn.addClass('active');
halfGridBtn.onclick = () => {
console.log('[LeftSidebar] Set snapping mode to half');
this.context.setSnappingMode('half');
};
// Unsnapped / Free movement
const unsnapBtn = toolbar.createEl('button', {
cls: 'tool-btn',
title: 'Unsnapped (Free movement) [U]'
});
unsnapBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="2" y1="2" x2="22" y2="22"/><path d="M7 21v-8M7 9V3M17 21v-2M17 13V3M3 7h4M13 7h8M3 17h18"/></svg>`;
if (!this.context.isSnapToGrid()) unsnapBtn.addClass('active');
unsnapBtn.onclick = () => {
console.log('[LeftSidebar] Set snapping mode to none');
this.context.setSnappingMode('none');
};
// 2. Search
const searchBox = this.containerEl.createDiv({ cls: 'search-box' });
searchBox.createEl('span', { cls: 'search-icon' }).innerHTML =
`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>`;
const searchInput = searchBox.createEl('input', {
type: 'text',
placeholder: 'Search for Component...'
});
searchInput.value = this.context.getSearchQuery();
searchInput.oninput = () => {
this.context.setSearchQuery(searchInput.value);
this.renderLibraryList();
};
// 3. Component Library List Container
this.containerEl.createDiv({ cls: 'library' });
this.renderLibraryList();
// 4. Packages Footer
const pkgSection = this.containerEl.createDiv({ cls: 'packages-section' });
const pkgHeader = pkgSection.createDiv({ cls: 'packages-header' });
const pkgTitle = pkgHeader.createDiv({ cls: 'title' });
pkgTitle.createSpan().innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>`;
pkgTitle.createSpan({ text: ' Packages' });
const addPkgBtn = pkgHeader.createEl('button', {
cls: 'add-pkg-btn',
title: 'Manage packages'
});
addPkgBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`;
addPkgBtn.onclick = () => {
console.log('[LeftSidebar] Click toggle package manager');
this.context.togglePackageManager();
};
// Package popover if active
if (this.context.isShowPackageManager()) {
const popover = pkgSection.createDiv({ cls: 'pkg-manager' });
const popHeader = popover.createDiv({ cls: 'pkg-manager-header' });
popHeader.createSpan({ text: 'Manage Packages' });
const closePopover = popHeader.createEl('button', { cls: 'close-btn' });
closePopover.innerHTML = '&times;';
closePopover.onclick = () => {
console.log('[LeftSidebar] Close package manager');
this.context.togglePackageManager();
};
const pkgList = popover.createDiv({ cls: 'pkg-list' });
this.context.getPackages().forEach(pkg => {
const item = pkgList.createDiv({ cls: 'pkg-item' });
const info = item.createDiv({ cls: 'pkg-info' });
info.createDiv({ cls: 'pkg-name', text: pkg.displayName });
info.createDiv({ cls: 'pkg-status', text: pkg.installed ? 'Installed' : 'Available' });
const actions = item.createDiv({ cls: 'pkg-actions' });
if (pkg.installed) {
const btn = actions.createEl('button', { cls: 'pkg-btn uninstall', text: 'Remove' });
btn.onclick = () => this.context.handleUninstallPackage(pkg.name);
} else if (this.context.getInstallingPackage() === pkg.name) {
const btn = actions.createEl('button', { cls: 'pkg-btn loading' });
btn.disabled = true;
btn.innerHTML = `<span class="spin"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/></svg></span>`;
} else {
const btn = actions.createEl('button', { cls: 'pkg-btn install', text: 'Install' });
btn.onclick = () => this.context.handleInstallPackage(pkg.name);
}
});
}
}
private renderLibraryList() {
const libraryContainer = this.containerEl.querySelector('.library') as HTMLElement;
if (!libraryContainer) return;
libraryContainer.empty();
// Category components builder
const core = AssetsManager.getCoreComponents();
const extra = this.context.getPackages().flatMap(p => p.components);
const all = [...core, ...extra];
const pinnedComponents = this.context.getPinnedComponents();
const pinned = all.filter(
c => pinnedComponents.includes(c.name) && !core.some(coreComp => coreComp.name === c.name)
);
const basic = [...core, ...pinned];
// Filter by query
const searchQuery = this.context.getSearchQuery();
let filtered = all;
if (searchQuery.trim()) {
filtered = all.filter(
c =>
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.category.toLowerCase().includes(searchQuery.toLowerCase())
);
}
const categoriesMap = new Map<string, ComponentTemplate[]>();
if (!searchQuery.trim()) {
categoriesMap.set('Basic', basic);
} else {
filtered.forEach(c => {
if (!categoriesMap.has(c.category)) {
categoriesMap.set(c.category, []);
}
categoriesMap.get(c.category)!.push(c);
});
}
if (categoriesMap.size === 0) {
libraryContainer.createDiv({
cls: 'empty-library',
text: 'No components found. Try installing packages!'
});
return;
}
categoriesMap.forEach((comps, category) => {
const section = libraryContainer.createDiv({ cls: 'category-section' });
section.createDiv({ cls: 'category-header', text: category });
const grid = section.createDiv({ cls: 'grid' });
comps.forEach(comp => {
const isActive = this.context.getActiveTemplate()?.name === comp.name;
const card = grid.createDiv({
cls: 'comp-card' + (isActive ? ' active-template' : ''),
attr: { role: 'button', tabindex: '0', title: `Click to select ${comp.name}` }
});
// Pin button for non-core packages if search active
if (searchQuery.trim() && !core.some(coreComp => coreComp.name === comp.name)) {
const isPinned = pinnedComponents.includes(comp.name);
const pinBtn = card.createEl('button', {
cls: 'pin-btn' + (isPinned ? ' pinned' : ''),
title: isPinned ? 'Pinned in Basic' : 'Pin to Basic'
});
pinBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-.44-1.24l-2.78-3.47A2 2 0 0 1 15 9.3V5a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v4.3a2 2 0 0 1-.78 1.23l-2.78 3.5a2 2 0 0 0-.44 1.24z"/></svg>`;
pinBtn.onclick = e => {
e.stopPropagation();
console.log('[LeftSidebar] Click toggle pin for:', comp.name);
if (isPinned) {
this.context.setPinnedComponents(pinnedComponents.filter(name => name !== comp.name));
} else {
this.context.setPinnedComponents([...pinnedComponents, comp.name]);
}
this.context.renderLeftSidebar();
};
}
const svgContainer = card.createDiv({ cls: 'svg-container' });
svgContainer.innerHTML = comp.svgMarkup;
card.createEl('span', { cls: 'comp-label', text: comp.name });
card.onclick = () => {
console.log('[LeftSidebar] Click select template:', comp.name);
this.context.handleSelectTemplate(comp);
};
card.onkeydown = e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.context.handleSelectTemplate(comp);
}
};
});
});
}
public updateToolbarClasses() {
const toolbar = this.containerEl.querySelector('.toolbar');
if (!toolbar) return;
toolbar.querySelectorAll('.tool-btn').forEach(btn => btn.removeClass('active'));
const activeTool = this.context.getActiveTool();
const selectBtn = toolbar.querySelector('button[title^="Select / Move"]');
const wireBtn = toolbar.querySelector('button[title^="Wire"]');
const textBtn = toolbar.querySelector('button[title^="Text"]');
const eraseBtn = toolbar.querySelector('button[title^="Eraser"]');
if (activeTool === 'select' && selectBtn) selectBtn.addClass('active');
if (activeTool === 'wire' && wireBtn) wireBtn.addClass('active');
if (activeTool === 'text' && textBtn) textBtn.addClass('active');
if (activeTool === 'erase' && eraseBtn) eraseBtn.addClass('active');
}
}

View file

@ -0,0 +1,309 @@
import { type TikzEditorContext, type EditorElement } from '../types';
export class RightSidebar {
constructor(
private context: TikzEditorContext,
private containerEl: HTMLElement
) {}
public render() {
this.containerEl.empty();
// Tabs Header
const tabsHeader = this.containerEl.createDiv({ cls: 'tabs-header' });
const activeTab = this.context.getActiveTab();
const editTabBtn = tabsHeader.createEl('button', {
cls: 'tab-btn' + (activeTab === 'edit' ? ' active' : ''),
text: 'Edit Component'
});
editTabBtn.createSpan({ cls: 'tab-icon' }).innerHTML =
`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="10" x2="20" y2="3"/><line x1="2" y1="14" x2="6" y2="14"/><line x1="10" y1="8" x2="14" y2="8"/><line x1="18" y1="16" x2="22" y2="16"/></svg> `;
editTabBtn.onclick = () => {
console.log('[RightSidebar] Switch to tab: edit');
this.context.switchTab('edit');
};
const codeTabBtn = tabsHeader.createEl('button', {
cls: 'tab-btn' + (activeTab === 'code' ? ' active' : ''),
text: 'Code'
});
codeTabBtn.createSpan({ cls: 'tab-icon' }).innerHTML =
`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg> `;
codeTabBtn.onclick = () => {
console.log('[RightSidebar] Switch to tab: code');
this.context.switchTab('code');
};
const tabContent = this.containerEl.createDiv({ cls: 'tab-content' });
if (activeTab === 'edit') {
const selectedElementId = this.context.getSelectedElementId();
const selectedElement =
this.context.getElements().find(el => el.id === selectedElementId) || null;
if (selectedElement) {
const editPanel = tabContent.createDiv({ cls: 'edit-panel' });
editPanel.createDiv({ cls: 'section-title' }).innerHTML =
`Edit component: <span class="comp-name">${selectedElement.name}</span>`;
// 1. Label text input
const labelGroup = editPanel.createDiv({ cls: 'control-group' });
labelGroup.createEl('label', { attr: { for: 'label-input' }, text: 'Label Text' });
const inputRow = labelGroup.createDiv({ cls: 'input-row' });
const labelInput = inputRow.createEl('input', {
type: 'text',
value: selectedElement.label,
attr: { id: 'label-input', placeholder: 'Enter label...' }
});
labelInput.oninput = () => {
console.log('[RightSidebar] handleLabelChange to:', labelInput.value);
this.context.handleUpdateElement({
...selectedElement,
label: labelInput.value
});
};
// 2. Text size and Color picker
const fontColorGroup = editPanel.createDiv({ cls: 'control-group' });
fontColorGroup.createEl('label', {
attr: { for: 'font-size-select' },
text: 'Text size & color'
});
const fontColorRow = fontColorGroup.createDiv({ cls: 'row gap' });
const fontSizeSelect = fontColorRow.createEl('select', {
attr: { id: 'font-size-select' }
});
[10, 12, 14, 18, 24].forEach(pt => {
const opt = fontSizeSelect.createEl('option', { value: pt.toString(), text: `${pt} pt` });
if (selectedElement.style.fontSize === pt) opt.selected = true;
});
fontSizeSelect.onchange = () => {
const size = parseInt(fontSizeSelect.value) || 12;
console.log('[RightSidebar] handleFontSizeChange to:', size);
this.context.handleUpdateElement({
...selectedElement,
style: { ...selectedElement.style, fontSize: size }
});
};
const colorPickerWrap = fontColorRow.createDiv({ cls: 'color-picker-wrap' });
const colorInput = colorPickerWrap.createEl('input', {
type: 'color',
value: /^#[0-9a-f]{6}$/i.test(selectedElement.style.color)
? selectedElement.style.color
: '#f8e7ad'
});
colorInput.oninput = () => {
console.log('[RightSidebar] handleColorChange to:', colorInput.value);
this.context.handleUpdateElement({
...selectedElement,
style: { ...selectedElement.style, color: colorInput.value }
});
};
// 2.5 Thickness Slider
const thicknessGroup = editPanel.createDiv({ cls: 'control-group' });
const currentThickness = selectedElement.style.thickness ?? 1.0;
const thicknessLabel = thicknessGroup.createEl('label', {
attr: { for: 'thickness-slider' },
text: `Thickness (${currentThickness} pt)`
});
const thicknessSlider = thicknessGroup.createEl('input', {
type: 'range',
value: currentThickness.toString(),
attr: { id: 'thickness-slider', min: '0.1', max: '10', step: '0.1' }
});
thicknessSlider.oninput = () => {
const val = parseFloat(thicknessSlider.value) || 1.0;
thicknessLabel.textContent = `Thickness (${val} pt)`;
this.context.handleUpdateElement({
...selectedElement,
style: { ...selectedElement.style, thickness: val }
});
};
// 3. Style buttons
const styleGroup = editPanel.createDiv({ cls: 'control-group' });
styleGroup.createSpan({ cls: 'label-heading', text: 'Styles' });
const styleBtnsRow = styleGroup.createDiv({ cls: 'row style-btns' });
const boldBtn = styleBtnsRow.createEl('button', {
cls: selectedElement.style.bold ? 'active' : '',
text: 'B',
title: 'Bold'
});
boldBtn.onclick = () => {
console.log('[RightSidebar] toggleStyle bold to:', !selectedElement.style.bold);
this.context.handleUpdateElement({
...selectedElement,
style: { ...selectedElement.style, bold: !selectedElement.style.bold }
});
};
const italicBtn = styleBtnsRow.createEl('button', {
cls: selectedElement.style.italic ? 'active' : '',
text: 'I',
title: 'Italic'
});
italicBtn.onclick = () => {
console.log('[RightSidebar] toggleStyle italic to:', !selectedElement.style.italic);
this.context.handleUpdateElement({
...selectedElement,
style: { ...selectedElement.style, italic: !selectedElement.style.italic }
});
};
const mathBtn = styleBtnsRow.createEl('button', {
cls: selectedElement.style.math ? 'active' : '',
text: '$',
title: 'Math Formula ($...$)'
});
mathBtn.onclick = () => {
console.log('[RightSidebar] toggleStyle math to:', !selectedElement.style.math);
this.context.handleUpdateElement({
...selectedElement,
style: { ...selectedElement.style, math: !selectedElement.style.math }
});
};
// 4. Rotation Slider
const rotationGroup = editPanel.createDiv({ cls: 'control-group' });
const rotationLabel = rotationGroup.createEl('label', {
attr: { for: 'rotation-slider' },
text: `Rotation (${selectedElement.rotation} deg)`
});
const slider = rotationGroup.createEl('input', {
type: 'range',
value: selectedElement.rotation.toString(),
attr: { id: 'rotation-slider', min: '0', max: '359' }
});
const applyRotation = (angle: number) => {
if (selectedElement.type === 'wire' && selectedElement.x2 !== undefined && selectedElement.y2 !== undefined) {
const len = Math.hypot(selectedElement.x2 - selectedElement.x, selectedElement.y2 - selectedElement.y);
const rad = (angle * Math.PI) / 180;
let targetX2 = selectedElement.x + len * Math.cos(rad);
let targetY2 = selectedElement.y + len * Math.sin(rad);
targetX2 = Math.round(targetX2);
targetY2 = Math.round(targetY2);
this.context.handleUpdateElement({
...selectedElement,
rotation: angle,
x2: targetX2,
y2: targetY2
});
} else {
this.context.handleUpdateElement({
...selectedElement,
rotation: angle
});
}
};
slider.oninput = () => {
const angle = parseInt(slider.value) || 0;
rotationLabel.textContent = `Rotation (${angle} deg)`;
applyRotation(angle);
};
// Presets row
const presetsRow = rotationGroup.createDiv({ cls: 'presets-row' });
[-90, -45, 0, 45, 90].forEach(preset => {
const btn = presetsRow.createEl('button', { cls: 'preset-btn', text: `${preset} deg` });
btn.onclick = () => {
const newAngle = (preset + 360) % 360;
console.log('[RightSidebar] handleRotateChange preset to:', preset, '->', newAngle);
slider.value = newAngle.toString();
rotationLabel.textContent = `Rotation (${newAngle} deg)`;
applyRotation(newAngle);
};
});
// 4b. Radius Slider (only for node/circle components)
const isNode =
selectedElement.name.toLowerCase().includes('node') ||
selectedElement.name.toLowerCase().includes('circle') ||
selectedElement.radius !== undefined;
if (isNode) {
if (selectedElement.radius === undefined) {
if (selectedElement.name.toLowerCase().includes('junction')) {
selectedElement.radius = 2.5;
} else if (selectedElement.name.toLowerCase().includes('circle')) {
selectedElement.radius = 12.0;
} else {
selectedElement.radius = 2.0;
}
}
const isCircleShape = selectedElement.name.toLowerCase().includes('circle');
const minRadius = isCircleShape ? '2.0' : '1.0';
const maxRadius = isCircleShape ? '30.0' : '8.0';
const stepRadius = isCircleShape ? '1.0' : '0.5';
const radiusGroup = editPanel.createDiv({ cls: 'control-group' });
const radiusLabel = radiusGroup.createEl('label', {
attr: { for: 'radius-slider' },
text: `Radius (${selectedElement.radius} pt)`
});
const radiusSlider = radiusGroup.createEl('input', {
type: 'range',
value: selectedElement.radius.toString(),
attr: { id: 'radius-slider', min: minRadius, max: maxRadius, step: stepRadius }
});
radiusSlider.oninput = () => {
const rad = parseFloat(radiusSlider.value) || 2.0;
radiusLabel.textContent = `Radius (${rad} pt)`;
this.context.handleUpdateElement({
...selectedElement,
radius: rad
});
};
}
// 5. Delete element
const deleteWrap = editPanel.createDiv({ cls: 'delete-btn-wrap' });
const deleteBtn = deleteWrap.createEl('button', { cls: 'delete-btn' });
deleteBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg> Delete Element`;
deleteBtn.onclick = () => {
console.log('[RightSidebar] Click delete element:', selectedElement.id);
this.context.handleDeleteElement(selectedElement.id);
};
} else {
tabContent.createDiv({ cls: 'empty-state' }).innerHTML =
`<p>Select a component on the canvas to configure its styles and properties.</p>`;
}
} else {
// Code tab
const codePanel = tabContent.createDiv({ cls: 'code-panel' });
codePanel.createDiv({ cls: 'section-title', text: 'Generated TikZ Code' });
const textArea = codePanel.createEl('textarea');
textArea.value = this.context.getEditableCode();
textArea.oninput = () => {
console.log('[RightSidebar] Textarea edit code');
const codeValue = textArea.value;
this.context.setEditableCode(codeValue);
this.context.setCodeDirty(codeValue !== this.context.generateTikzSource());
};
const codeActions = codePanel.createDiv({ cls: 'code-actions' });
const copyBtn = codeActions.createEl('button', { cls: 'action-btn secondary' });
copyBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg> Copy Code`;
copyBtn.onclick = () => {
if (this.context.isCodeDirty()) {
console.log('[RightSidebar] Copy custom modified code');
navigator.clipboard.writeText(this.context.getEditableCode());
} else {
console.log('[RightSidebar] Copy generated code');
this.context.handleCopyCode();
}
};
// Update button is now in the header next to the close button
}
}
}

View file

@ -0,0 +1,749 @@
import { App, Modal } from 'obsidian';
import LatexReferencer from 'main';
import { AssetsManager } from './assets-manager';
import {
type EditorElement,
type ComponentTemplate,
type TikzPackage,
type TikzEditorContext
} from './types';
import { showNotice } from 'utils/obsidian';
import { LeftSidebar } from './components/left-sidebar';
import { CanvasGrid } from './components/canvas-grid';
import { RightSidebar } from './components/right-sidebar';
import { HistoryManager } from './utils/history-manager';
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 DEFAULT_STYLE = {
bold: false,
italic: false,
math: false,
color: '#f8e7ad',
fontSize: 12,
thickness: 1.0
};
// State variables
private elements: EditorElement[] = [];
private activeTool: 'select' | 'wire' | 'text' | 'erase' = 'select';
private activeTemplate: ComponentTemplate | null = null;
private selectedElementId: string | null = null;
private snapToGrid = true;
private halfGrid = false;
private pictureOptions = '';
private zoom = 1.0;
// Panning state
private isSpacePressed = false;
private isPanning = false;
private panStartMouse = { x: 0, y: 0 };
private panStartScroll = { left: 0, top: 0 };
// Sidebar / library state
private searchQuery = '';
private showPackageManager = false;
private packages: TikzPackage[] = [];
private installingPackage: string | null = null;
private pinnedComponents: string[] = [];
private activeTab: 'edit' | 'code' = 'edit';
private editableCode = '';
private codeDirty = false;
// Helpers / Managers
private historyManager = new HistoryManager(50);
private codec!: TikzCodec;
// Subcomponents
private leftSidebar!: LeftSidebar;
private canvasGrid!: CanvasGrid;
private rightSidebar!: RightSidebar;
// DOM elements
private uiEl!: HTMLDivElement;
private leftSidebarEl!: HTMLDivElement;
private canvasContainerEl!: HTMLDivElement;
private canvasWorkspaceEl!: HTMLDivElement;
private wiresOverlayEl!: SVGElement;
private rightSidebarEl!: HTMLDivElement;
constructor(
app: App,
private plugin: LatexReferencer,
private initialSource: string,
private onSaveCallback?: (newSource: string) => void
) {
super(app);
this.codec = new TikzCodec(
x => this.toCanvasX(x),
y => this.toCanvasY(y),
x => this.fromCanvasX(x),
y => this.fromCanvasY(y),
() => this.createId(),
this.DEFAULT_STYLE
);
}
// Getters / Setters
getElements() {
return this.elements;
}
setElements(elements: EditorElement[]) {
this.elements = elements;
}
getActiveTool() {
return this.activeTool;
}
getActiveTemplate() {
return this.activeTemplate;
}
getSelectedElementId() {
return this.selectedElementId;
}
isSnapToGrid() {
return this.snapToGrid;
}
isHalfGrid() {
return this.halfGrid;
}
getPictureOptions() {
return this.pictureOptions;
}
getZoom() {
return this.zoom;
}
setZoom(zoom: number) {
this.zoom = zoom;
}
getSearchQuery() {
return this.searchQuery;
}
setSearchQuery(query: string) {
this.searchQuery = query;
}
isShowPackageManager() {
return this.showPackageManager;
}
setShowPackageManager(show: boolean) {
this.showPackageManager = show;
}
getPackages() {
return this.packages;
}
getInstallingPackage() {
return this.installingPackage;
}
getPinnedComponents() {
return this.pinnedComponents;
}
setPinnedComponents(pinned: string[]) {
this.pinnedComponents = pinned;
}
getActiveTab() {
return this.activeTab;
}
getEditableCode() {
return this.editableCode;
}
setEditableCode(code: string) {
this.editableCode = code;
}
isCodeDirty() {
return this.codeDirty;
}
setCodeDirty(dirty: boolean) {
this.codeDirty = dirty;
}
getOnSaveCallback() {
return this.onSaveCallback;
}
// Helper coordinate conversions
toCanvasX(x: number) {
return this.ORIGIN_X + x * this.PX_PER_UNIT;
}
toCanvasY(y: number) {
return this.ORIGIN_Y - y * this.PX_PER_UNIT;
}
fromCanvasX(x: number) {
return (x - this.ORIGIN_X) / this.PX_PER_UNIT;
}
fromCanvasY(y: number) {
return -(y - this.ORIGIN_Y) / this.PX_PER_UNIT;
}
createId() {
return 'elem_' + Math.random().toString(36).substring(2, 9);
}
// History operations
saveHistoryState() {
this.historyManager.saveState(this.elements);
this.updateHistoryButtons();
}
handleUndo() {
const previous = this.historyManager.undo();
if (previous !== null) {
this.elements = previous;
this.selectedElementId = null;
this.renderCanvas();
this.renderRightSidebar();
this.updateHistoryButtons();
}
}
handleRedo() {
const next = this.historyManager.redo();
if (next !== null) {
this.elements = next;
this.selectedElementId = null;
this.renderCanvas();
this.renderRightSidebar();
this.updateHistoryButtons();
}
}
updateHistoryButtons() {
const undoBtn = this.contentEl.querySelector(
'.tikz-canvas-controls button[title^="Undo"]'
) as HTMLButtonElement;
const redoBtn = this.contentEl.querySelector(
'.tikz-canvas-controls button[title^="Redo"]'
) as HTMLButtonElement;
if (undoBtn) undoBtn.disabled = !this.historyManager.canUndo();
if (redoBtn) redoBtn.disabled = !this.historyManager.canRedo();
}
// Operations callbacks
handleSelectElement(id: string | null) {
this.selectedElementId = id;
if (id !== null) {
this.activeTool = 'select';
this.activeTemplate = null;
this.leftSidebar.updateToolbarClasses();
}
this.renderCanvas();
this.renderRightSidebar();
}
handleAddElement(elem: Omit<EditorElement, 'id'>) {
const newId = this.createId();
const newElem: EditorElement = {
...elem,
id: newId
};
this.elements = [...this.elements, newElem];
this.selectedElementId = newId;
if (this.activeTool === 'text') {
this.activeTool = 'select';
this.activeTemplate = null;
this.leftSidebar.updateToolbarClasses();
}
this.saveHistoryState();
this.renderCanvas();
this.renderRightSidebar();
}
handleUpdateElementPosition(id: string, x: number, y: number, x2?: number, y2?: number, saveHistory = true) {
this.elements = this.elements.map(el => {
if (el.id === id) {
return { ...el, x, y, x2, y2 };
}
return el;
});
if (saveHistory) {
this.saveHistoryState();
}
this.renderCanvas();
}
handleUpdateElement(updated: EditorElement, saveHistory = true) {
this.elements = this.elements.map(el => (el.id === updated.id ? updated : el));
if (saveHistory) {
this.saveHistoryState();
}
this.renderCanvas();
}
handleDeleteElement(id: string) {
this.elements = this.elements.filter(el => el.id !== id);
if (this.selectedElementId === id) {
this.selectedElementId = null;
}
this.saveHistoryState();
this.renderCanvas();
this.renderRightSidebar();
}
handleSelectTool(tool: 'select' | 'wire' | 'text' | 'erase') {
this.activeTool = tool;
this.activeTemplate = null;
this.selectedElementId = null;
this.leftSidebar.updateToolbarClasses();
this.renderLeftSidebar();
this.renderCanvas();
this.renderRightSidebar();
}
handleSelectTemplate(template: ComponentTemplate) {
this.activeTemplate = template;
this.selectedElementId = null;
if (template.type === 'text') {
this.activeTool = 'text';
} else if (template.type === 'wire') {
this.activeTool = 'wire';
} else {
this.activeTool = 'select';
}
this.leftSidebar.updateToolbarClasses();
this.renderLeftSidebar();
this.renderCanvas();
this.renderRightSidebar();
}
generateTikzSource(): string {
return this.codec.generate(this.elements, this.pictureOptions);
}
handleCopyCode() {
navigator.clipboard.writeText(this.generateTikzSource());
showNotice('TikZ code copied to clipboard!');
}
handleInsertCode() {
if (this.onSaveCallback) {
const code = (this.activeTab === 'code' && this.codeDirty)
? this.editableCode
: this.generateTikzSource();
this.onSaveCallback(code);
}
this.close();
}
async handleInstallPackage(pkgName: string) {
console.log('[LeftSidebar] handleInstallPackage package:', pkgName);
this.installingPackage = pkgName;
this.renderLeftSidebar();
try {
const success = await AssetsManager.installPackage(pkgName);
console.log('[LeftSidebar] handleInstallPackage success status:', success);
if (success) {
this.packages = [...AssetsManager.getRegistry()];
}
} catch (err) {
console.error('[LeftSidebar] Error installing package:', pkgName, err);
} finally {
this.installingPackage = null;
this.renderLeftSidebar();
}
}
async handleUninstallPackage(pkgName: string) {
console.log('[LeftSidebar] handleUninstallPackage package:', pkgName);
try {
const success = await AssetsManager.uninstallPackage(pkgName);
console.log('[LeftSidebar] handleUninstallPackage success status:', success);
if (success) {
this.packages = [...AssetsManager.getRegistry()];
}
} catch (err) {
console.error('[LeftSidebar] Error uninstalling package:', pkgName, err);
} finally {
this.renderLeftSidebar();
}
}
// Toggles & tab switchers
toggleSnapToGrid() {
if (this.snapToGrid) {
this.setSnappingMode('none');
} else {
this.setSnappingMode(this.halfGrid ? 'half' : 'grid');
}
}
toggleHalfGrid() {
if (this.halfGrid) {
this.setSnappingMode('grid');
} else {
this.setSnappingMode('half');
}
}
setSnappingMode(mode: 'grid' | 'half' | 'none') {
if (mode === 'grid') {
this.snapToGrid = true;
this.halfGrid = false;
} else if (mode === 'half') {
this.snapToGrid = true;
this.halfGrid = true;
} else {
this.snapToGrid = false;
}
this.renderLeftSidebar();
this.renderCanvas();
}
togglePackageManager() {
this.showPackageManager = !this.showPackageManager;
this.renderLeftSidebar();
}
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));
}
}
this.renderRightSidebar();
}
// Rendering delegates
renderLeftSidebar() {
this.leftSidebar.render();
}
renderCanvas() {
this.canvasGrid.render();
}
renderRightSidebar() {
this.rightSidebar.render();
}
private handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
console.log('[TikzEditorModal] Escape key pressed manually');
e.preventDefault();
e.stopPropagation();
this.close();
return;
}
// Check if typing in input/textarea
const activeEl = activeDocument.activeElement;
if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || (activeEl as HTMLElement).isContentEditable)) {
return;
}
if (e.key === ' ' || e.code === 'Space') {
e.preventDefault();
if (!this.isSpacePressed) {
this.isSpacePressed = true;
this.canvasContainerEl.removeClass('is-grabbing');
this.canvasContainerEl.addClass('is-grab');
}
return;
}
const key = e.key.toLowerCase();
if (key === 'v' || key === 's') {
e.preventDefault();
this.handleSelectTool('select');
} else if (key === 'w') {
e.preventDefault();
this.handleSelectTool('wire');
} else if (key === 't') {
e.preventDefault();
this.handleSelectTool('text');
} else if (key === 'e') {
e.preventDefault();
this.handleSelectTool('erase');
} else if (key === 'g') {
e.preventDefault();
this.setSnappingMode('grid');
} else if (key === 'h') {
e.preventDefault();
this.setSnappingMode('half');
} else if (key === 'u') {
e.preventDefault();
this.setSnappingMode('none');
}
};
private handleKeyUp = (e: KeyboardEvent) => {
if (e.key === ' ' || e.code === 'Space') {
const activeEl = activeDocument.activeElement;
if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || (activeEl as HTMLElement).isContentEditable)) {
return;
}
this.isSpacePressed = false;
this.canvasContainerEl.removeClass('is-grab');
this.canvasContainerEl.removeClass('is-grabbing');
}
};
// Modal Lifecycles
onOpen() {
const { contentEl, containerEl } = this;
contentEl.empty();
// Debug click interceptor
this.modalEl.addEventListener('click', (e) => {
console.log('[TikzEditorDebug] Click target:', e.target);
}, true);
// Set custom CSS classes and sizes
this.modalEl.addClass('tikz-editor-modal');
containerEl.style.setProperty('--dialog-width', '95vw');
containerEl.style.setProperty('--dialog-height', '90vh');
// Title of the modal
this.titleEl.setText('TikZ Graphical Editor');
this.titleEl.style.borderBottom = '1px solid var(--border-color)';
this.titleEl.style.paddingBottom = '10px';
this.titleEl.style.marginBottom = '0';
if (this.onSaveCallback) {
const headerBtn = this.modalEl.createEl('button', {
cls: 'tikz-header-save-btn',
text: 'Update Block'
});
headerBtn.onclick = () => {
console.log('[TikzEditorModal] Header Save clicked');
this.handleInsertCode();
};
}
// Build overall UI layout elements
this.uiEl = contentEl.createDiv({ cls: 'tikz-editor-ui' });
this.leftSidebarEl = this.uiEl.createDiv({ cls: 'left-sidebar' });
// Canvas area wrapper that stays fixed (so floating controls do not scroll)
const canvasAreaEl = this.uiEl.createDiv({ cls: 'canvas-area' });
this.canvasContainerEl = canvasAreaEl.createDiv({ cls: 'canvas-grid-container' });
this.rightSidebarEl = this.uiEl.createDiv({ cls: 'right-sidebar' });
// Initialize package manager listings
this.packages = [...AssetsManager.getRegistry()];
// Add spacebar and shortcut key listeners
activeDocument.addEventListener('keydown', this.handleKeyDown);
activeDocument.addEventListener('keyup', this.handleKeyUp);
// Add manual close button click handler
const closeBtn = this.modalEl.querySelector('.modal-close-button') as HTMLElement | null;
if (closeBtn) {
closeBtn.addEventListener('click', e => {
console.log('[TikzEditorModal] Close button clicked manually');
e.preventDefault();
e.stopPropagation();
this.close();
});
}
// Parse initial source code if present
this.elements = [];
if (this.initialSource) {
const match = this.initialSource.match(/^\s*%\s*\[ObsiTeXState:(.*)\]\s*$/m);
if (match && match[1]) {
try {
const parsed = JSON.parse(match[1]);
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;
}
}
// Initial history state
this.historyManager.clear();
this.saveHistoryState();
// Construct Subcomponents
this.leftSidebar = new LeftSidebar(this, this.leftSidebarEl);
this.rightSidebar = new RightSidebar(this, this.rightSidebarEl);
// Build canvas DOM layout inside modal
this.buildCanvasDOM();
// Render components
this.renderLeftSidebar();
this.renderCanvas();
this.renderRightSidebar();
}
onClose() {
const { contentEl } = this;
contentEl.empty();
this.plugin.isTikzEditorOpen = false;
this.plugin.updateEditorExtensions();
// Clean up key listeners
activeDocument.removeEventListener('keydown', this.handleKeyDown);
activeDocument.removeEventListener('keyup', this.handleKeyUp);
}
// DOM Builders
private buildCanvasDOM() {
this.canvasContainerEl.empty();
// Get the parent canvas-area element to float controls
const canvasAreaEl = this.canvasContainerEl.parentElement || this.canvasContainerEl;
canvasAreaEl.querySelectorAll('.tikz-canvas-controls').forEach(el => el.remove());
// Floating controls
const controls = canvasAreaEl.createDiv({ cls: 'tikz-canvas-controls' });
const zoomOutBtn = controls.createEl('button', { title: 'Zoom Out [Ctrl + -]' });
zoomOutBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>`;
zoomOutBtn.onclick = () => {
this.zoom = Math.max(this.zoom - 0.1, 0.5);
zoomLabel.textContent = `${Math.round(this.zoom * 100)}%`;
this.canvasWorkspaceEl.style.transform = `scale(${this.zoom})`;
};
const zoomLabel = controls.createSpan({ cls: 'zoom-label', text: '100%' });
const zoomInBtn = controls.createEl('button', { title: 'Zoom In [Ctrl + +]' });
zoomInBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>`;
zoomInBtn.onclick = () => {
this.zoom = Math.min(this.zoom + 0.1, 2.0);
zoomLabel.textContent = `${Math.round(this.zoom * 100)}%`;
this.canvasWorkspaceEl.style.transform = `scale(${this.zoom})`;
};
controls.createDiv({ cls: 'divider' });
const undoBtn = controls.createEl('button', { title: 'Undo [Ctrl + Z]' });
undoBtn.disabled = true;
undoBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/></svg>`;
undoBtn.onclick = () => this.handleUndo();
const redoBtn = controls.createEl('button', { title: 'Redo [Ctrl + Y]' });
redoBtn.disabled = true;
redoBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 2.7"/></svg>`;
redoBtn.onclick = () => this.handleRedo();
// Workspace
this.canvasWorkspaceEl = this.canvasContainerEl.createDiv({ cls: 'canvas-workspace' });
this.canvasWorkspaceEl.style.transformOrigin = '0 0';
this.canvasWorkspaceEl.style.transform = `scale(${this.zoom})`;
// Grid lines
this.canvasWorkspaceEl.createDiv({ cls: 'grid-background' });
// SVG elements
const svgNS = 'http://www.w3.org/2000/svg';
const axisOverlay = activeDocument.createElementNS(svgNS, 'svg');
axisOverlay.setAttribute('class', 'axis-overlay');
axisOverlay.setAttribute('width', this.CANVAS_WIDTH.toString());
axisOverlay.setAttribute('height', this.CANVAS_HEIGHT.toString());
this.canvasWorkspaceEl.appendChild(axisOverlay);
this.wiresOverlayEl = activeDocument.createElementNS(svgNS, 'svg') as unknown as SVGElement;
this.wiresOverlayEl.setAttribute('class', 'wires-overlay');
this.wiresOverlayEl.setAttribute('width', this.CANVAS_WIDTH.toString());
this.wiresOverlayEl.setAttribute('height', this.CANVAS_HEIGHT.toString());
this.canvasWorkspaceEl.appendChild(this.wiresOverlayEl);
// Instantiate CanvasGrid coordinator
this.canvasGrid = new CanvasGrid(
this,
this.canvasContainerEl,
this.canvasWorkspaceEl,
this.wiresOverlayEl
);
// Panning handler via spacebar holding
this.canvasContainerEl.addEventListener('mousedown', e => {
if (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
};
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;
};
const onMouseUp = () => {
this.isPanning = false;
this.canvasContainerEl.removeClass('is-grabbing');
if (this.isSpacePressed) {
this.canvasContainerEl.addClass('is-grab');
} else {
this.canvasContainerEl.removeClass('is-grab');
}
activeDocument.removeEventListener('mousemove', onMouseMove);
activeDocument.removeEventListener('mouseup', onMouseUp);
};
activeDocument.addEventListener('mousemove', onMouseMove);
activeDocument.addEventListener('mouseup', onMouseUp);
}
}, 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)
this.canvasContainerEl.addEventListener('wheel', e => {
const activeEl = activeDocument.activeElement;
if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || (activeEl as HTMLElement).isContentEditable)) {
return;
}
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);
} else {
this.zoom = Math.max(this.zoom - zoomFactor, 0.5);
}
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 });
}
}

View file

@ -0,0 +1,112 @@
export interface EditorElementStyle {
bold: boolean;
italic: boolean;
math: boolean;
color: string;
fontSize: number; // in pt
thickness?: number; // line width in pt
}
export interface EditorElement {
id: string;
type: string; // 'text' | 'wire' | 'component'
name: string; // Display name
x: number; // grid units or px
y: number;
x2?: number; // for wire/line
y2?: number;
label: string;
rotation: number; // in degrees
style: EditorElementStyle;
svgMarkup: string; // Inline SVG markup for display
tikzCommand: string; // TikZ / CircuiTikZ source snippet
radius?: number; // Radius in pt for node/circle components
}
export interface ComponentTemplate {
name: string;
type: string; // 'text' | 'wire' | 'component'
category: string;
svgMarkup: string;
tikzCommand: string; // Template command, e.g. "\draw ({x}, {y}) to[R, l={label}] ({x2}, {y2});"
}
export interface TikzPackage {
name: string; // e.g. 'circuitikz'
displayName: string;
installed: boolean;
components: ComponentTemplate[];
}
export interface TikzEditorContext {
// State getters
getElements(): EditorElement[];
setElements(elements: EditorElement[]): void;
getActiveTool(): 'select' | 'wire' | 'text' | 'erase';
getActiveTemplate(): ComponentTemplate | null;
getSelectedElementId(): string | null;
isSnapToGrid(): boolean;
isHalfGrid(): boolean;
getPictureOptions(): string;
getZoom(): number;
setZoom(zoom: number): void;
getSearchQuery(): string;
setSearchQuery(query: string): void;
isShowPackageManager(): boolean;
setShowPackageManager(show: boolean): void;
getPackages(): TikzPackage[];
getInstallingPackage(): string | null;
getPinnedComponents(): string[];
setPinnedComponents(pinned: string[]): void;
getActiveTab(): 'edit' | 'code';
getEditableCode(): string;
setEditableCode(code: string): void;
isCodeDirty(): boolean;
setCodeDirty(dirty: boolean): void;
getOnSaveCallback(): ((newSource: string) => void) | undefined;
// Constants / config
PX_PER_UNIT: number;
ORIGIN_X: number;
ORIGIN_Y: number;
CANVAS_WIDTH: number;
CANVAS_HEIGHT: number;
DEFAULT_STYLE: EditorElementStyle;
// Coordinate conversion helpers
toCanvasX(x: number): number;
toCanvasY(y: number): number;
fromCanvasX(x: number): number;
fromCanvasY(y: number): number;
createId(): string;
// Operations
saveHistoryState(): void;
handleUndo(): void;
handleRedo(): void;
updateHistoryButtons(): void;
handleSelectElement(id: string | null): void;
handleAddElement(elem: Omit<EditorElement, 'id'>): void;
handleUpdateElementPosition(id: string, x: number, y: number, x2?: number, y2?: number, saveHistory?: boolean): void;
handleUpdateElement(updated: EditorElement, saveHistory?: boolean): void;
handleDeleteElement(id: string): void;
handleSelectTool(tool: 'select' | 'wire' | 'text' | 'erase'): void;
handleSelectTemplate(template: ComponentTemplate): void;
generateTikzSource(): string;
handleCopyCode(): void;
handleInsertCode(): void;
handleInstallPackage(pkgName: string): Promise<void>;
handleUninstallPackage(pkgName: string): Promise<void>;
// View triggers
renderLeftSidebar(): void;
renderCanvas(): void;
renderRightSidebar(): void;
// State mutations
toggleSnapToGrid(): void;
toggleHalfGrid(): void;
togglePackageManager(): void;
switchTab(tab: 'edit' | 'code'): void;
setSnappingMode(mode: 'grid' | 'half' | 'none'): void;
}

View file

@ -0,0 +1,53 @@
import { type EditorElement } from '../types';
export class HistoryManager {
private historyStack: EditorElement[][] = [];
private redoStack: EditorElement[][] = [];
constructor(private maxDepth = 50) {}
saveState(elements: EditorElement[]) {
const currentJSON = JSON.stringify(elements);
if (this.historyStack.length > 0) {
const lastJSON = JSON.stringify(this.historyStack[this.historyStack.length - 1]);
if (currentJSON === lastJSON) return;
}
this.historyStack.push(JSON.parse(currentJSON));
if (this.historyStack.length > this.maxDepth) {
this.historyStack.shift();
}
this.redoStack = [];
}
undo(): EditorElement[] | null {
if (this.historyStack.length > 1) {
const popped = this.historyStack.pop()!;
this.redoStack.push(popped);
return JSON.parse(JSON.stringify(this.historyStack[this.historyStack.length - 1]));
}
return null;
}
redo(): EditorElement[] | null {
if (this.redoStack.length > 0) {
const next = this.redoStack.pop()!;
this.historyStack.push(next);
return JSON.parse(JSON.stringify(next));
}
return null;
}
canUndo(): boolean {
return this.historyStack.length > 1;
}
canRedo(): boolean {
return this.redoStack.length > 0;
}
clear() {
this.historyStack = [];
this.redoStack = [];
}
}

View file

@ -0,0 +1,349 @@
import { type EditorElement, type EditorElementStyle } from '../types';
import { AssetsManager } from '../assets-manager';
export class TikzCodec {
constructor(
private toCanvasX: (x: number) => number,
private toCanvasY: (y: number) => number,
private fromCanvasX: (x: number) => number,
private fromCanvasY: (y: number) => number,
private createId: () => string,
private defaultStyle: EditorElementStyle
) {}
private parseNumber(value: string) {
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 makeWire(x1: number, y1: number, x2: number, y2: number): EditorElement {
const wire = AssetsManager.getCoreComponents().find(t => t.name === 'Wire')!;
return {
id: this.createId(),
type: 'wire',
name: 'Wire',
x: this.toCanvasX(x1),
y: this.toCanvasY(y1),
x2: this.toCanvasX(x2),
y2: this.toCanvasY(y2),
label: '',
rotation: 0,
style: { ...this.defaultStyle },
svgMarkup: wire.svgMarkup,
tikzCommand: wire.tikzCommand
};
}
private makeNode(
name: 'Filled node' | 'Open node' | 'Circle' | 'Filled Circle',
x: number,
y: number,
label = '',
radius?: number
): EditorElement {
const template = AssetsManager.getCoreComponents().find(t => t.name === name)!;
return {
id: this.createId(),
type: 'component',
name,
x: this.toCanvasX(x),
y: this.toCanvasY(y),
label,
rotation: 0,
radius: radius ?? (name.toLowerCase().includes('circle') ? 12.0 : 2.0),
style: { ...this.defaultStyle },
svgMarkup: template.svgMarkup,
tikzCommand: template.tikzCommand
};
}
private makeText(x: number, y: number, rawLabel: string): EditorElement {
const template = AssetsManager.getCoreComponents().find(t => t.name === 'Text')!;
let label = rawLabel.trim();
let math = false;
if (label.startsWith('$') && label.endsWith('$')) {
label = label.substring(1, label.length - 1);
math = true;
}
return {
id: this.createId(),
type: 'text',
name: 'Text',
x: this.toCanvasX(x),
y: this.toCanvasY(y),
label,
rotation: 0,
style: { ...this.defaultStyle, math },
svgMarkup: template.svgMarkup,
tikzCommand: template.tikzCommand
};
}
public parse(source: string): { elements: EditorElement[]; pictureOptions: string } {
const parsedElements: EditorElement[] = [];
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');
for (const line of lines) {
const trimmed = line.trim();
if (
!trimmed ||
trimmed.startsWith('%') ||
trimmed.startsWith('\\begin{tikzpicture}') ||
trimmed.startsWith('\\end{tikzpicture}')
) {
continue;
}
const currentShift = shifts[shifts.length - 1];
const scopeMatch = trimmed.match(
/\\begin\{scope\}\s*\[shift=\{\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)\}\]/
);
if (scopeMatch) {
shifts.push({
x: currentShift.x + this.parseNumber(scopeMatch[1]),
y: currentShift.y + this.parseNumber(scopeMatch[2])
});
continue;
}
if (trimmed.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*;/
);
if (nodeMatch) {
const point = this.parsePoint(nodeMatch[1], currentShift);
if (point) parsedElements.push(this.makeText(point.x, point.y, nodeMatch[2]));
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*;/
);
if (circleMatch) {
const point = this.parsePoint(circleMatch[2], currentShift);
if (point) {
const parsedRadius = parseFloat(circleMatch[3]);
const isFilled = circleMatch[1] === 'fill';
const isLarge = !isNaN(parsedRadius) && parsedRadius >= 6.0;
let nodeName: 'Filled node' | 'Open node' | 'Circle' | 'Filled Circle' = 'Open node';
if (isFilled) {
nodeName = isLarge ? 'Filled Circle' : 'Filled node';
} else {
nodeName = isLarge ? 'Circle' : 'Open node';
}
parsedElements.push(
this.makeNode(
nodeName,
point.x,
point.y,
circleMatch[4] ?? '',
isNaN(parsedRadius) ? undefined : parsedRadius
)
);
}
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*;/
);
if (circuitMatch) {
const start = this.parsePoint(circuitMatch[1], currentShift);
const end = this.parsePoint(circuitMatch[4], currentShift);
if (start && end) {
const templates = [
...AssetsManager.getCoreComponents(),
...AssetsManager.getRegistry().flatMap(p => p.components)
];
const template =
templates.find(t => t.tikzCommand.includes(`to[${circuitMatch[2]}`)) ??
templates.find(t => t.name === 'Wire')!;
parsedElements.push({
id: this.createId(),
type: 'wire',
name: template.name,
x: this.toCanvasX(start.x),
y: this.toCanvasY(start.y),
x2: this.toCanvasX(end.x),
y2: this.toCanvasY(end.y),
label: circuitMatch[3] ?? template.name,
rotation: 0,
style: { ...this.defaultStyle },
svgMarkup: template.svgMarkup,
tikzCommand: template.tikzCommand
});
}
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*;/
);
if (rectMatch) {
const point = this.parsePoint(rectMatch[2], currentShift);
if (point) {
const template = AssetsManager.getCoreComponents().find(t => t.name === 'Rectangle')!;
parsedElements.push({
id: this.createId(),
type: 'component',
name: 'Rectangle',
x: this.toCanvasX(point.x),
y: this.toCanvasY(point.y),
label: '',
rotation: 0,
style: { ...this.defaultStyle },
svgMarkup: template.svgMarkup,
tikzCommand: template.tikzCommand
});
}
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')!;
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;
}
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 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)!;
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
});
}
}
}
return { elements: parsedElements, pictureOptions };
}
public generate(elements: EditorElement[], pictureOptions: string): string {
let output = '';
output += `\\begin{tikzpicture}${pictureOptions}\n`;
const stateObj = { elements, pictureOptions };
output += ` % [ObsiTeXState:${JSON.stringify(stateObj)}]\n\n`;
elements.forEach(elem => {
const xVal = this.fromCanvasX(elem.x).toFixed(2);
const yVal = this.fromCanvasY(elem.y).toFixed(2);
let cmd = elem.tikzCommand;
if (!cmd) return;
if (elem.radius !== undefined) {
cmd = cmd.replace(/circle\s*\([^)]*\)/g, `circle (${elem.radius}pt)`);
}
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, `);
} else if (cmd.startsWith('\\draw')) {
cmd = cmd.replace(/\\draw/, `\\draw[line width=${elem.style.thickness}pt]`);
}
}
let formattedLabel = elem.label || '';
if (formattedLabel) {
if (elem.style.bold) formattedLabel = `\\textbf{${formattedLabel}}`;
if (elem.style.italic) formattedLabel = `\\textit{${formattedLabel}}`;
if (elem.style.math) formattedLabel = `$${formattedLabel}$`;
}
const fontCommand =
elem.style.fontSize <= 10
? '\\small'
: elem.style.fontSize <= 12
? '\\normalsize'
: elem.style.fontSize <= 14
? '\\large'
: elem.style.fontSize <= 18
? '\\Large'
: '\\Huge';
cmd = cmd.replace(/{x}/g, xVal);
cmd = cmd.replace(/{y}/g, yVal);
cmd = cmd.replace(/{label}/g, formattedLabel);
cmd = cmd.replace(/{fontSize}/g, fontCommand);
if (elem.x2 !== undefined && elem.y2 !== undefined) {
const x2Val = this.fromCanvasX(elem.x2).toFixed(2);
const y2Val = this.fromCanvasY(elem.y2).toFixed(2);
cmd = cmd.replace(/{x2}/g, x2Val);
cmd = cmd.replace(/{y2}/g, y2Val);
}
output += ` ${cmd}\n`;
});
output += '\\end{tikzpicture}';
return output;
}
}

View file

@ -95,6 +95,13 @@ class TikzLivePreviewOverlay {
titleEl.textContent = 'TikZ live preview';
handleEl.appendChild(titleEl);
const buttonGroup = doc.createElement('div');
setCssProps(buttonGroup, {
display: 'flex',
gap: '6px',
'align-items': 'center'
});
const exportBtn = doc.createElement('button');
exportBtn.textContent = 'Export svg';
setCssProps(exportBtn, {
@ -118,7 +125,59 @@ class TikzLivePreviewOverlay {
this.exportSvg();
};
handleEl.appendChild(exportBtn);
const pencilBtn = doc.createElement('button');
pencilBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-pencil"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>`;
setCssProps(pencilBtn, {
padding: '4px',
display: 'flex',
'align-items': 'center',
'justify-content': 'center',
'border-radius': '4px',
border: '1px solid var(--border-color)',
'background-color': 'var(--background-secondary)',
color: 'var(--text-normal)',
cursor: 'pointer'
});
pencilBtn.title = 'Open TikZ Editor';
pencilBtn.onmousedown = (e: MouseEvent) => {
e.stopPropagation();
};
pencilBtn.onclick = (e: MouseEvent) => {
e.stopPropagation();
const state = this.view.state;
const pos = state.selection.main.head;
const blockRange = this.getTikzBlockRangeAtPos(state, pos);
if (blockRange) {
this.plugin.isTikzEditorOpen = true;
this.destroy();
const { TikzEditorModal } = require('../tikz-editor/tikz-editor-modal');
new TikzEditorModal(
this.plugin.app,
this.plugin,
blockRange.source,
(newSource: string) => {
this.view.dispatch({
changes: {
from: blockRange.from,
to: blockRange.to,
insert: newSource
}
});
showNotice('TikZ block updated.');
}
).open();
} else {
showNotice('Please place your cursor inside a tikz block to edit.');
}
};
buttonGroup.appendChild(pencilBtn);
buttonGroup.appendChild(exportBtn);
handleEl.appendChild(buttonGroup);
this.overlayEl.appendChild(handleEl);
// Container for TikZ render
@ -265,6 +324,64 @@ class TikzLivePreviewOverlay {
showNotice('TikZ diagram exported as svg successfully.');
}
private getTikzBlockRangeAtPos(
state: EditorState,
pos: number
): { source: string; from: number; to: number } | null {
try {
const doc = state.doc;
const curLine = doc.lineAt(pos).number;
let isInside = false;
let blockStartLine = -1;
let blockEndLine = -1;
// Scan backwards to find block start
for (let l = curLine; l >= 1; l--) {
const text = doc.line(l).text.trim();
if (text.startsWith('```tikz')) {
isInside = true;
blockStartLine = l;
break;
} else if (text === '```' && l < curLine) {
break;
}
}
if (!isInside) return null;
// Scan forwards to find block end
for (let l = curLine; l <= doc.lines; l++) {
const text = doc.line(l).text.trim();
if (text === '```') {
blockEndLine = l;
break;
} else if (text.startsWith('```tikz') && l > curLine) {
break;
}
}
if (blockStartLine !== -1 && blockEndLine !== -1) {
const lines: string[] = [];
for (let l = blockStartLine + 1; l < blockEndLine; l++) {
lines.push(doc.line(l).text);
}
const from = doc.line(blockStartLine + 1).from;
const to = doc.line(blockEndLine - 1).to;
return {
source: lines.join('\n'),
from,
to
};
}
} catch {
// Fail silently
}
return null;
}
public destroy() {
if (this.debounceTimeout) {
window.clearTimeout(this.debounceTimeout);
@ -290,7 +407,7 @@ export const createTikzLivePreviewPlugin = (plugin: LatexReferencer): Extension
constructor(private view: EditorView) {}
update(update: ViewUpdate) {
if (!plugin.settings.enableTikzjax) {
if (!plugin.settings.enableTikzjax || plugin.isTikzEditorOpen) {
this.cleanup();
return;
}

View file

@ -572,106 +572,116 @@ class RowLayoutWidget extends WidgetType {
}
}
export const createLivePreviewRowLayoutPlugin = (plugin: LatexReferencer): Extension => {
const layoutField = StateField.define<DecorationSet>({
create() {
return Decoration.none;
},
update(decorations, tr) {
// Re-evaluate whenever the document contents or selections change
if (!tr.docChanged && !tr.selection) {
return decorations;
}
let layoutField: StateField<DecorationSet> | null = null;
let activePlugin: LatexReferencer | null = null;
const { state } = tr;
const livePreview = state.field(editorLivePreviewField, false);
if (!livePreview) {
function getLayoutField(plugin: LatexReferencer): StateField<DecorationSet> {
activePlugin = plugin;
if (!layoutField) {
layoutField = StateField.define<DecorationSet>({
create() {
return Decoration.none;
}
},
update(decorations, tr) {
// Re-evaluate whenever the document contents or selections change
if (!tr.docChanged && !tr.selection) {
return decorations;
}
const info = state.field(editorInfoField, false);
const file = info?.file;
const sourcePath = file?.path ?? '';
if (!sourcePath) {
return Decoration.none;
}
const { state } = tr;
const builder = new RangeSetBuilder<Decoration>();
const docText = state.doc.toString();
const lines = docText.split(/\r?\n/);
const livePreview = state.field(editorLivePreviewField, false);
if (!livePreview) {
return Decoration.none;
}
let inRow = false;
let startPos = -1;
let startLineIdx = -1;
let widths: string[] = [];
const info = state.field(editorInfoField, false);
const file = info?.file;
const sourcePath = file?.path ?? '';
if (!sourcePath) {
return Decoration.none;
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!inRow) {
if (line.startsWith(';;;row')) {
inRow = true;
startLineIdx = i;
startPos = state.doc.line(i + 1).from;
const builder = new RangeSetBuilder<Decoration>();
const docText = state.doc.toString();
const lines = docText.split(/\r?\n/);
const widthsPart = line.substring(';;;row'.length).trim().replace(/^:/, '').trim();
if (widthsPart) {
widths = widthsPart
.split(/\s*\|\s*|\s*,\s*|\s+/)
.map(w => w.trim())
.filter(w => w)
.map(formatWidth);
} else {
widths = [];
}
}
} else {
if (line === ';;;') {
inRow = false;
const endPos = state.doc.line(i + 1).to;
let inRow = false;
let startPos = -1;
let startLineIdx = -1;
let widths: string[] = [];
// Decorate if cursor selection is completely outside this block
if (!selectionAndRangeOverlap(state.selection, startPos, endPos)) {
const columnsMarkdown: string[] = [];
const rowLines = lines.slice(startLineIdx + 1, i);
let currentColLines: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!inRow) {
if (line.startsWith(';;;row')) {
inRow = true;
startLineIdx = i;
startPos = state.doc.line(i + 1).from;
for (let rIdx = 0; rIdx < rowLines.length; rIdx++) {
const rLine = rowLines[rIdx];
if (rLine.trim() === ';;') {
columnsMarkdown.push(currentColLines.join('\n'));
currentColLines = [];
} else {
currentColLines.push(rLine);
}
const widthsPart = line.substring(';;;row'.length).trim().replace(/^:/, '').trim();
if (widthsPart) {
widths = widthsPart
.split(/\s*\|\s*|\s*,\s*|\s+/)
.map(w => w.trim())
.filter(w => w)
.map(formatWidth);
} else {
widths = [];
}
columnsMarkdown.push(currentColLines.join('\n'));
}
} else {
if (line === ';;;') {
inRow = false;
const endPos = state.doc.line(i + 1).to;
builder.add(
startPos,
endPos,
Decoration.replace({
widget: new RowLayoutWidget(
plugin,
sourcePath,
widths,
columnsMarkdown,
startPos
),
block: true
})
);
// Decorate if cursor selection is completely outside this block
if (!selectionAndRangeOverlap(state.selection, startPos, endPos)) {
const columnsMarkdown: string[] = [];
const rowLines = lines.slice(startLineIdx + 1, i);
let currentColLines: string[] = [];
for (let rIdx = 0; rIdx < rowLines.length; rIdx++) {
const rLine = rowLines[rIdx];
if (rLine.trim() === ';;') {
columnsMarkdown.push(currentColLines.join('\n'));
currentColLines = [];
} else {
currentColLines.push(rLine);
}
}
columnsMarkdown.push(currentColLines.join('\n'));
builder.add(
startPos,
endPos,
Decoration.replace({
widget: new RowLayoutWidget(
activePlugin!,
sourcePath,
widths,
columnsMarkdown,
startPos
),
block: true
})
);
}
}
}
}
return builder.finish();
},
provide(field) {
return EditorView.decorations.from(field);
}
});
}
return layoutField;
}
return builder.finish();
},
provide(field) {
return EditorView.decorations.from(field);
}
});
return Prec.highest(layoutField);
export const createLivePreviewRowLayoutPlugin = (plugin: LatexReferencer): Extension => {
return Prec.highest(getLayoutField(plugin));
};

View file

@ -167,7 +167,7 @@ export class TikzJaxLoader {
if (!pluginDir) return null;
const localPath = `${pluginDir}/tikzjax-assets/${filename}`;
const cdnUrl = `https://cdn.jsdelivr.net/gh/YouFoundJK/ObsiTeXcore@main/tikzjax-assets/${filename}`;
const cdnUrl = `https://raw.githubusercontent.com/YouFoundJK/TeXcore/main/tikzjax-assets/${filename}`;
let notice: Notice | null = null;
try {

View file

@ -423,6 +423,9 @@ export function put(descriptor: number, pointer: number, length: number) {
async function compile(code: string, filesMap: Record<string, Uint8Array>): Promise<Uint8Array> {
deleteEverything();
// Strip the ObsiTeXState comment to prevent WebAssembly/TeX line buffer out-of-bounds crashes
const strippedCode = code.replace(/%.*\[ObsiTeXState:.*\].*/g, '');
// Populate preloaded file dict (excludes the WASM binary and core dump)
for (const [filepath, content] of Object.entries(filesMap)) {
if (filepath !== 'tex.wasm' && filepath !== 'core.dump') {
@ -441,12 +444,12 @@ async function compile(code: string, filesMap: Record<string, Uint8Array>): Prom
// and \usepackage{tikz} already executed, so we must strip any duplicate declarations
// of \documentclass or \usepackage{tikz} to avoid "Two \documentclass" or package errors.
let input: string;
if (code.includes('\\begin{document}')) {
let cleanCode = code.replace(/\\documentclass\s*(\[[^\]]*\])?\s*\{[^}]*\}/g, '');
if (strippedCode.includes('\\begin{document}')) {
let cleanCode = strippedCode.replace(/\\documentclass\s*(\[[^\]]*\])?\s*\{[^}]*\}/g, '');
cleanCode = cleanCode.replace(/\\usepackage\s*(\[[^\]]*\])?\s*\{tikz\}/g, '');
input = `\n${cleanCode}`;
} else {
let cleanCode = code.replace(/\\documentclass\s*(\[[^\]]*\])?\s*\{[^}]*\}/g, '');
let cleanCode = strippedCode.replace(/\\documentclass\s*(\[[^\]]*\])?\s*\{[^}]*\}/g, '');
cleanCode = cleanCode.replace(/\\usepackage\s*(\[[^\]]*\])?\s*\{tikz\}/g, '');
// Split cleanCode into preamble and body. Preamble commands (like \usepackage,

View file

@ -55,6 +55,7 @@ export default class LatexReferencer extends Plugin {
snippetManager!: SnippetManager;
customNoteManager!: CustomNoteManager;
tikzRenderer!: TikzRenderer;
isTikzEditorOpen = false;
async onload() {
await this.loadSettings();

View file

@ -355,6 +355,63 @@
/* Ensure wrapper takes full width */
}
/* ====== TikZ Graphical Editor Modal ====== */
.tikz-editor-modal {
width: min(1500px, 96vw);
max-width: 96vw;
height: min(900px, 92vh);
max-height: 92vh;
display: flex;
flex-direction: column;
}
.tikz-editor-modal .modal-close-button {
z-index: 10000 !important;
pointer-events: auto !important;
}
.tikz-editor-modal .tikz-header-save-btn {
position: absolute;
top: 14px;
right: 50px;
z-index: 10005;
background-color: var(--interactive-accent);
color: var(--text-on-accent);
font-weight: 600;
border: none;
border-radius: var(--radius-s);
padding: 6px 14px;
cursor: pointer;
font-size: 0.85em;
height: auto !important;
line-height: normal;
transition: background-color 0.15s ease;
}
.tikz-editor-modal .tikz-header-save-btn:hover {
background-color: var(--interactive-accent-hover);
}
.tikz-editor-modal .modal-content {
display: flex;
flex-direction: column;
flex: 1 1 auto;
overflow: hidden;
padding: 0;
height: auto;
}
.tikz-editor-modal .modal-header {
flex: 0 0 auto;
padding: 14px 18px;
border-bottom: 1px solid var(--background-modifier-border);
}
.tikz-editor-modal .modal-title {
margin: 0;
}
/* ====== Settings Redesign (Tabs, Search, Footer, and Changelog) ====== */
.obsitexcore-settings-shell {
@ -590,4 +647,906 @@
position: sticky;
bottom: 0;
z-index: 1;
}
}
/* ====== TikZ Graphical Editor Workspace & Components ====== */
.tikz-editor-ui {
display: flex;
flex-direction: row;
height: 100%;
width: 100%;
overflow: hidden;
font-family: var(--font-interface);
}
/* Left Sidebar */
.tikz-editor-ui .left-sidebar {
width: 280px;
height: 100%;
display: flex;
flex-direction: column;
border-right: 1px solid var(--background-modifier-border);
background-color: var(--background-primary);
position: relative;
user-select: none;
z-index: 5;
flex-shrink: 0;
}
.tikz-editor-ui .toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 8px;
border-bottom: 1px solid var(--background-modifier-border);
background-color: var(--background-secondary);
}
.tikz-editor-ui .tool-btn {
width: 32px;
height: 32px !important;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: var(--radius-s);
background: transparent;
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s ease;
}
.tikz-editor-ui .tool-btn:hover {
background-color: var(--background-modifier-hover);
color: var(--text-normal);
}
.tikz-editor-ui .tool-btn.active {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
border-color: var(--interactive-accent);
}
.tikz-editor-ui .text-toggle {
font-size: 0.75em;
font-weight: 700;
}
.tikz-editor-ui .wire-icon {
width: 16px;
height: 2px;
background-color: currentColor;
position: relative;
}
.tikz-editor-ui .wire-icon::before,
.tikz-editor-ui .wire-icon::after {
content: '';
position: absolute;
width: 4px;
height: 4px;
border-radius: 50%;
background-color: currentColor;
top: -1px;
}
.tikz-editor-ui .wire-icon::before { left: -2px; }
.tikz-editor-ui .wire-icon::after { right: -2px; }
.tikz-editor-ui .divider {
width: 1px;
height: 20px;
background-color: var(--background-modifier-border);
margin: 0 4px;
}
.tikz-editor-ui .search-box {
padding: 8px;
position: relative;
border-bottom: 1px solid var(--background-modifier-border);
}
.tikz-editor-ui .search-box input {
width: 100%;
padding: 6px 10px 6px 28px;
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
background-color: var(--background-secondary);
color: var(--text-normal);
font-size: 0.85em;
}
.tikz-editor-ui .search-box input:focus {
outline: none;
border-color: var(--interactive-accent);
}
.tikz-editor-ui .search-icon {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
pointer-events: none;
display: inline-flex;
align-items: center;
}
.tikz-editor-ui .library {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 16px;
}
.tikz-editor-ui .category-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.tikz-editor-ui .category-header {
font-size: 0.75em;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
padding-bottom: 4px;
border-bottom: 1px solid var(--background-modifier-border);
}
.tikz-editor-ui .grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.tikz-editor-ui .comp-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 10px 6px;
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
background-color: var(--background-secondary-alt);
cursor: pointer;
transition: all 0.2s ease;
gap: 8px;
height: auto !important;
min-height: 72px !important;
}
.tikz-editor-ui .comp-card:hover {
border-color: var(--interactive-accent);
background-color: var(--background-modifier-hover);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.tikz-editor-ui .comp-card.active-template {
border-color: var(--interactive-accent);
box-shadow: 0 0 0 1px var(--interactive-accent);
}
.tikz-editor-ui .pin-btn {
position: absolute;
top: 6px;
right: 6px;
width: 20px;
height: 20px !important;
padding: 0;
border-radius: var(--radius-s);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
background-color: var(--background-primary);
border: 1px solid var(--background-modifier-border);
}
.tikz-editor-ui .pin-btn:hover,
.tikz-editor-ui .pin-btn.pinned {
color: var(--text-on-accent);
background-color: var(--interactive-accent);
border-color: var(--interactive-accent);
}
.tikz-editor-ui .svg-container {
height: 36px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-normal);
}
.tikz-editor-ui .comp-label {
font-size: 0.75em;
text-align: center;
color: var(--text-normal);
line-height: 1.2;
word-break: break-word;
}
.tikz-editor-ui .empty-library {
padding: 24px;
text-align: center;
color: var(--text-muted);
font-size: 0.85em;
}
.tikz-editor-ui .packages-section {
padding: 10px 12px;
border-top: 1px solid var(--background-modifier-border);
background-color: var(--background-secondary);
position: relative;
flex-shrink: 0; /* Prevents squishing in sidebar */
}
.tikz-editor-ui .packages-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.tikz-editor-ui .packages-header .title {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.85em;
font-weight: 600;
color: var(--text-normal);
}
.tikz-editor-ui .add-pkg-btn {
width: 24px;
height: 24px !important;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: none;
background-color: var(--interactive-accent);
color: var(--text-on-accent);
cursor: pointer;
transition: background-color 0.2s ease;
}
.tikz-editor-ui .add-pkg-btn:hover {
background-color: var(--interactive-accent-hover);
}
.tikz-editor-ui .pkg-manager {
position: absolute;
bottom: 45px;
left: 10px;
right: 10px;
background-color: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.15);
z-index: 100;
display: flex;
flex-direction: column;
max-height: 220px;
overflow: hidden;
}
.tikz-editor-ui .pkg-manager-header {
padding: 8px 12px;
background-color: var(--background-secondary);
border-bottom: 1px solid var(--background-modifier-border);
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.8em;
font-weight: bold;
color: var(--text-normal);
}
.tikz-editor-ui .pkg-manager-header .close-btn {
font-size: 1.2em;
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
height: auto !important;
}
.tikz-editor-ui .pkg-list {
flex: 1;
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.tikz-editor-ui .pkg-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
border-radius: var(--radius-s);
background-color: var(--background-secondary-alt);
border: 1px solid var(--background-modifier-border);
}
.tikz-editor-ui .pkg-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.tikz-editor-ui .pkg-name {
font-size: 0.8em;
font-weight: 600;
color: var(--text-normal);
}
.tikz-editor-ui .pkg-status {
font-size: 0.7em;
color: var(--text-muted);
}
.tikz-editor-ui .pkg-btn {
padding: 4px 8px;
font-size: 0.75em;
border-radius: var(--radius-s);
cursor: pointer;
border: none;
display: flex;
align-items: center;
gap: 4px;
font-weight: 500;
height: auto !important;
}
.tikz-editor-ui .pkg-btn.install {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
}
.tikz-editor-ui .pkg-btn.install:hover {
background-color: var(--interactive-accent-hover);
}
.tikz-editor-ui .pkg-btn.uninstall {
background-color: transparent;
border: 1px solid var(--background-modifier-border);
color: var(--text-muted);
}
.tikz-editor-ui .pkg-btn.uninstall:hover {
background-color: var(--background-modifier-hover);
color: var(--text-normal);
}
.tikz-editor-ui .pkg-btn.loading {
background-color: var(--background-secondary);
color: var(--text-muted);
cursor: not-allowed;
}
.tikz-editor-ui .spin {
animation: spin-anim 1s linear infinite;
display: inline-flex;
align-items: center;
}
@keyframes spin-anim {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Canvas Area */
.tikz-editor-ui .canvas-area {
flex: 1;
min-width: 0;
height: 100%;
position: relative;
overflow: hidden;
}
.tikz-editor-ui .canvas-grid-container {
width: 100%;
height: 100%;
overflow: auto;
background-color: var(--background-secondary-alt);
position: relative;
}
.tikz-editor-ui .tikz-canvas-controls {
position: absolute;
top: 16px;
left: 16px;
display: flex;
align-items: center;
background-color: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
overflow: hidden;
z-index: 10;
padding: 2px 4px;
}
.tikz-editor-ui .tikz-canvas-controls button {
padding: 6px 8px;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
height: auto !important;
}
.tikz-editor-ui .tikz-canvas-controls button:hover {
background-color: var(--background-modifier-hover);
color: var(--text-normal);
}
.tikz-editor-ui .tikz-canvas-controls button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.tikz-editor-ui .tikz-canvas-controls .zoom-label {
font-size: 0.8em;
font-weight: 600;
min-width: 44px;
text-align: center;
color: var(--text-normal);
padding: 0 8px;
user-select: none;
}
.tikz-editor-ui .tikz-canvas-controls .divider {
width: 1px;
height: 16px;
background-color: var(--background-modifier-border);
margin: 0 6px;
}
.tikz-editor-ui .canvas-workspace {
width: 1120px;
height: 720px;
position: relative;
transform-origin: 0 0;
cursor: crosshair;
}
.tikz-editor-ui .grid-background {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image: radial-gradient(circle at 0px 0px, var(--text-muted) 1.8px, transparent 1.8px);
background-size: 80px 80px;
background-position: 120px 360px;
opacity: 0.35;
pointer-events: none;
}
.tikz-editor-ui .grid-background.half-grid {
background-image: radial-gradient(circle at 0px 0px, var(--text-muted) 1.5px, transparent 1.5px);
background-size: 40px 40px;
background-position: 120px 360px;
opacity: 0.35;
}
.tikz-editor-ui .canvas-grid-container.is-grab,
.tikz-editor-ui .canvas-grid-container.is-grab * {
cursor: grab !important;
}
.tikz-editor-ui .canvas-grid-container.is-grabbing,
.tikz-editor-ui .canvas-grid-container.is-grabbing * {
cursor: grabbing !important;
}
.tikz-editor-ui .axis-overlay {
position: absolute;
top: 0;
left: 0;
z-index: 1;
pointer-events: none;
overflow: visible;
}
.tikz-editor-ui .axis-overlay line {
stroke: var(--text-muted);
stroke-width: 1;
opacity: 0.15;
}
.tikz-editor-ui .axis-overlay text {
fill: var(--text-muted);
font-size: 11px;
font-weight: 600;
text-anchor: middle;
user-select: none;
opacity: 0.12;
}
.tikz-editor-ui .wires-overlay {
position: absolute;
top: 0;
left: 0;
pointer-events: auto;
z-index: 2;
}
.tikz-editor-ui .wire-group {
pointer-events: auto;
}
.tikz-editor-ui .wire-group line {
transition: stroke 0.15s ease;
}
.tikz-editor-ui .wire-group:hover line {
stroke: var(--text-accent);
}
.tikz-editor-ui .placed-element {
position: absolute;
width: 0;
height: 0;
z-index: 3;
pointer-events: auto;
cursor: move;
}
.tikz-editor-ui .placed-element.selected .node-visual {
outline: 2px solid var(--interactive-accent);
outline-offset: 2px;
border-radius: 50%;
}
.tikz-editor-ui .placed-element:hover .node-visual {
outline: 1.5px dashed var(--background-modifier-border);
outline-offset: 2px;
border-radius: 50%;
}
.tikz-editor-ui .node-visual {
position: absolute;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
}
.tikz-editor-ui .node-label {
position: absolute;
top: 14px;
left: 0;
transform: translateX(-50%);
white-space: nowrap;
pointer-events: none;
font-weight: 500;
text-align: center;
}
.tikz-editor-ui .placed-element.is-text .text-element-content {
position: absolute;
transform: translate(-50%, -50%);
white-space: nowrap;
user-select: none;
padding: 4px 8px;
background-color: var(--background-primary);
border: 1px dashed var(--background-modifier-border);
border-radius: var(--radius-s);
}
.tikz-editor-ui .placed-element.is-text:hover .text-element-content {
border-color: var(--interactive-accent);
}
.tikz-editor-ui .placed-element.is-text.selected .text-element-content {
border: 1px solid var(--interactive-accent);
background-color: rgba(var(--interactive-accent), 0.05);
}
.tikz-editor-ui .comp-svg-fill svg {
fill: var(--background-primary);
}
/* Right Sidebar */
.tikz-editor-ui .right-sidebar {
width: 320px;
height: 100%;
display: flex;
flex-direction: column;
border-left: 1px solid var(--background-modifier-border);
background-color: var(--background-primary);
position: relative;
z-index: 5;
flex-shrink: 0;
}
.tikz-editor-ui .tabs-header {
display: flex;
border-bottom: 1px solid var(--background-modifier-border);
background-color: var(--background-secondary);
}
.tikz-editor-ui .tab-btn {
flex: 1;
padding: 12px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--text-muted);
cursor: pointer;
font-size: 0.9em;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.2s ease;
height: auto !important;
}
.tikz-editor-ui .tab-btn * {
pointer-events: none;
}
.tikz-editor-ui .tab-btn:hover {
color: var(--text-normal);
background-color: var(--background-modifier-hover);
}
.tikz-editor-ui .tab-btn.active {
color: var(--text-accent);
border-bottom-color: var(--interactive-accent);
font-weight: 600;
}
.tikz-editor-ui .tab-content {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
}
.tikz-editor-ui .section-title {
font-size: 0.95em;
font-weight: bold;
color: var(--text-normal);
margin-bottom: 16px;
border-bottom: 1px solid var(--background-modifier-border);
padding-bottom: 8px;
}
.tikz-editor-ui .comp-name {
color: var(--text-accent);
}
.tikz-editor-ui .control-group {
margin-bottom: 20px;
display: flex;
flex-direction: column;
gap: 6px;
}
.tikz-editor-ui .control-group label,
.tikz-editor-ui .control-group .label-heading {
font-size: 0.85em;
font-weight: 600;
color: var(--text-muted);
}
.tikz-editor-ui .row {
display: flex;
align-items: center;
}
.tikz-editor-ui .row.gap {
gap: 10px;
}
.tikz-editor-ui input[type="text"],
.tikz-editor-ui select {
width: 100%;
padding: 6px 10px;
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
background-color: var(--background-secondary);
color: var(--text-normal);
font-size: 0.9em;
}
.tikz-editor-ui input[type="text"]:focus,
.tikz-editor-ui select:focus {
outline: none;
border-color: var(--interactive-accent);
}
.tikz-editor-ui .color-picker-wrap {
width: 32px;
height: 32px;
border-radius: var(--radius-s);
border: 1px solid var(--background-modifier-border);
overflow: hidden;
position: relative;
cursor: pointer;
flex-shrink: 0;
}
.tikz-editor-ui .color-picker-wrap input[type="color"] {
position: absolute;
top: -5px;
left: -5px;
width: 42px;
height: 42px;
border: none;
background: transparent;
cursor: pointer;
}
.tikz-editor-ui .style-btns {
gap: 8px;
}
.tikz-editor-ui .style-btns button {
flex: 1;
padding: 6px;
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
background-color: var(--background-secondary);
color: var(--text-muted);
font-weight: bold;
cursor: pointer;
transition: all 0.15s ease;
height: auto !important;
}
.tikz-editor-ui .style-btns button:hover {
color: var(--text-normal);
background-color: var(--background-modifier-hover);
}
.tikz-editor-ui .style-btns button.active {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
border-color: var(--interactive-accent);
}
.tikz-editor-ui input[type="range"] {
width: 100%;
accent-color: var(--interactive-accent);
}
.tikz-editor-ui .presets-row {
display: flex;
gap: 4px;
margin-top: 6px;
}
.tikz-editor-ui .preset-btn {
flex: 1;
padding: 4px 0;
font-size: 0.8em;
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
background-color: var(--background-secondary);
color: var(--text-normal);
cursor: pointer;
height: auto !important;
}
.tikz-editor-ui .preset-btn:hover {
background-color: var(--background-modifier-hover);
}
.tikz-editor-ui .delete-btn-wrap {
margin-top: 32px;
}
.tikz-editor-ui .delete-btn {
width: 100%;
padding: 10px;
border: 1px solid var(--color-red);
border-radius: var(--radius-m);
background-color: transparent;
color: var(--color-red);
cursor: pointer;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.2s ease;
height: auto !important;
}
.tikz-editor-ui .delete-btn:hover {
background-color: var(--color-red);
color: white;
}
.tikz-editor-ui .empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
color: var(--text-muted);
font-size: 0.9em;
padding: 32px;
}
.tikz-editor-ui .code-panel {
display: flex;
flex-direction: column;
height: 100%;
gap: 12px;
}
.tikz-editor-ui .code-panel textarea {
flex: 1;
width: 100%;
font-family: var(--font-monospace);
font-size: 0.85em;
padding: 10px;
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
background-color: var(--background-secondary);
color: var(--text-normal);
resize: none;
}
.tikz-editor-ui .code-panel textarea:focus {
outline: none;
border-color: var(--interactive-accent);
}
.tikz-editor-ui .code-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.tikz-editor-ui .action-btn {
width: 100%;
padding: 10px;
border-radius: var(--radius-m);
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border: none;
height: auto !important;
}
.tikz-editor-ui .action-btn.primary {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
}
.tikz-editor-ui .action-btn.primary:hover {
background-color: var(--interactive-accent-hover);
}
.tikz-editor-ui .action-btn.secondary {
background-color: var(--background-secondary);
color: var(--text-normal);
border: 1px solid var(--background-modifier-border);
}
.tikz-editor-ui .action-btn.secondary:hover {
background-color: var(--background-modifier-hover);
}

View file

@ -54,8 +54,7 @@ import {
safeParseInt,
traverseFolder
} from 'features/export-pdf/utils';
import Progress from 'features/export-pdf/Progress.svelte';
import { mount, unmount } from 'svelte';
import { Progress } from 'features/export-pdf/Progress';
import pLimit from 'p-limit';
export type PageSizeType = string | { width: number; height: number };
@ -125,11 +124,7 @@ export class ExportConfigModal extends Modal {
title!: string;
frontMatter!: FrontMatterCache;
scale!: number;
// @ts-ignore
svelte: {
initRenderStates(data: ParamType[]): void;
updateRenderStates(i: number): void;
} | null = null;
svelte: Progress | null = null;
constructor(
public plugin: LatexReferencer,
@ -396,8 +391,7 @@ export class ExportConfigModal extends Modal {
el.empty();
if (render) {
// await this.renderFiles(el);
// @ts-ignore
this.svelte = mount(Progress, {
this.svelte = new Progress({
target: el,
props: {
startCount: 5
@ -748,8 +742,7 @@ export class ExportConfigModal extends Modal {
const { contentEl } = this;
contentEl.empty();
if (this.svelte) {
// Remove the Counter from the ItemView.
void unmount(this.svelte);
this.svelte.destroy();
}
}