mirror of
https://github.com/youfoundjk/TeXcore.git
synced 2026-07-22 07:33:31 +00:00
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:
parent
2bf059a65f
commit
f17bc66c4e
21 changed files with 5218 additions and 3673 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
4708
pnpm-lock.yaml
4708
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -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)];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
97
src/features/export-pdf/Progress.ts
Normal file
97
src/features/export-pdf/Progress.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
204
src/features/tikz-editor/assets-manager.ts
Normal file
204
src/features/tikz-editor/assets-manager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
661
src/features/tikz-editor/components/canvas-grid.ts
Normal file
661
src/features/tikz-editor/components/canvas-grid.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
277
src/features/tikz-editor/components/left-sidebar.ts
Normal file
277
src/features/tikz-editor/components/left-sidebar.ts
Normal 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 = '×';
|
||||
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');
|
||||
}
|
||||
}
|
||||
309
src/features/tikz-editor/components/right-sidebar.ts
Normal file
309
src/features/tikz-editor/components/right-sidebar.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
749
src/features/tikz-editor/tikz-editor-modal.ts
Normal file
749
src/features/tikz-editor/tikz-editor-modal.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
112
src/features/tikz-editor/types.ts
Normal file
112
src/features/tikz-editor/types.ts
Normal 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;
|
||||
}
|
||||
53
src/features/tikz-editor/utils/history-manager.ts
Normal file
53
src/features/tikz-editor/utils/history-manager.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
349
src/features/tikz-editor/utils/tikz-codec.ts
Normal file
349
src/features/tikz-editor/utils/tikz-codec.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ export default class LatexReferencer extends Plugin {
|
|||
snippetManager!: SnippetManager;
|
||||
customNoteManager!: CustomNoteManager;
|
||||
tikzRenderer!: TikzRenderer;
|
||||
isTikzEditorOpen = false;
|
||||
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue