diff --git a/README.md b/README.md
index 87212b1..5b05e3f 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,9 @@
# TeXcore for Obsidian
-A minimalistic, high-performance LaTeX assistant for automatic equation numbering, referencing, and on-demand TikZ rendering in [Obsidian.md](https://obsidian.md/).
+A minimalistic, high-performance LaTeX assistant for automatic equation numbering, referencing, and TikZ rendering with basic GUI editing in [Obsidian.md](https://obsidian.md/) to transforms your vault into a rich ecosystem for scientific drafting and study.
+
+
๐ **[Full Documentation & Guides](https://youfoundjk.github.io/TeXcore/)**
diff --git a/docs/assets/branding/v0.0.2-hero.mp4 b/docs/assets/branding/v0.0.2-hero.mp4
new file mode 100644
index 0000000..e538afd
Binary files /dev/null and b/docs/assets/branding/v0.0.2-hero.mp4 differ
diff --git a/docs/assets/branding/v0.0.2.gif b/docs/assets/branding/v0.0.2.gif
new file mode 100644
index 0000000..68e52b8
Binary files /dev/null and b/docs/assets/branding/v0.0.2.gif differ
diff --git a/docs/configuration/index.md b/docs/configuration/index.md
new file mode 100644
index 0000000..dbc5f26
--- /dev/null
+++ b/docs/configuration/index.md
@@ -0,0 +1,39 @@
+# Configuration Overview Matrix
+
+TeXcore is highly customizable. Use this matrix to understand the setting groups and click the links to explore detailed configurations on the [Settings Reference](settings.md) page.
+
+---
+
+## Settings Group Matrix
+
+The plugin settings are divided into five main categories. Below is an overview of what is in each section:
+
+| Settings Category | One-Line Overview | Core Customization | Detailed Link |
+| :--- | :--- | :--- | :--- |
+| **Numbering & Referencing** | Adjust equation counting, prefixes, suffixes, and numbering styles. | Arabic/Roman numerals, tags display, and lazy numbering toggle. | [Go to Section :octicons-arrow-right-16:](settings.md#equation-numbering-referencing) |
+| **Autocomplete & Search** | Customize editor triggers and math suggestions popups. | Suggestions trigger sequence, fuzzy/simple search method. | [Go to Section :octicons-arrow-right-16:](settings.md#autocomplete-search) |
+| **PDF Export Options** | Adjust layout geometry, titles, background, and header templates. | Orientation, print background toggle, outline heading depth. | [Go to Section :octicons-arrow-right-16:](settings.md#pdf-export) |
+| **Advanced PDF Settings** | Configure HTML page decoration headers/footers and sniper selections. | HTML variables class name mapping, CSS snippet filters. | [Go to Section :octicons-arrow-right-16:](settings.md#advanced-pdf-settings) |
+| **Snippet Macros** | Manage custom LaTeX snippets inserted via command palette. | LaTeX template commands, custom snippet shortcuts. | [Go to Section :octicons-arrow-right-16:](settings.md#snippets) |
+
+---
+
+## Highlighted Settings
+
+Explore settings key categories to customize your math publishing workspace:
+
+
+
+- :material-tune: **[Typographic Numbering](settings.md#equation-numbering-referencing)**
+
+ Change equation counter styles to Arabic (`1`), uppercase Roman (`I`), or alphabetic (`a`). Append custom text like `Eq.` prefix or `.` suffix.
+
+- :material-laptop: **[Dynamic Suggestions](settings.md#autocomplete-search)**
+
+ Control whether autocomplete triggers via `\eqref` or another custom code. Toggle whether the suggestion list renders math or shows raw LaTeX syntax.
+
+- :material-file-document-outline: **[Print Design](settings.md#pdf-export)**
+
+ Customize output HTML headers/footers with variables such as page number, document date, and document title.
+
+
diff --git a/docs/configuration/settings.md b/docs/configuration/settings.md
index decac7f..e4d3f7d 100644
--- a/docs/configuration/settings.md
+++ b/docs/configuration/settings.md
@@ -1,171 +1,77 @@
# Settings Reference
-Complete reference for all TeXcore settings.
+Configure TeXcore to adapt to your academic writing workflow. This page outlines all settings grouped by feature categories.
+
+---
## Equation Numbering & Referencing
-### Number only referenced equations
-- **Type:** Toggle
-- **Default:** On
-- **Description:** Only add equation numbers to equations that have at least one reference in the note. Unreferenced equations remain unnumbered.
+Adjust how equations are counted, tagged, and linked inside Obsidian. For details on how math indexing operates, see the [Equation Numbering Guide](../features/equations.md).
-### Equation number prefix
-- **Type:** Text
-- **Default:** (empty)
-- **Description:** Text to prepend to equation numbers. Example: `ยง` โ `(ยง1)`
-
-### Equation number suffix
-- **Type:** Text
-- **Default:** (empty)
-- **Description:** Text to append to equation numbers. Example: `.` โ `(1.)`
-
-### Equation number initial count
-- **Type:** Number
-- **Default:** `1`
-- **Description:** Starting number for equation counting. Set to `0` to start from zero.
-
-### Equation number style
-- **Type:** Dropdown
-- **Default:** `arabic`
-- **Options:**
-
-| Style | Output |
-|-------|--------|
-| `arabic` | 1, 2, 3, ... |
-| `alph` | a, b, c, ... |
-| `Alph` | A, B, C, ... |
-| `roman` | i, ii, iii, ... |
-| `Roman` | I, II, III, ... |
-
-### Reference link prefix
-- **Type:** Text
-- **Default:** (empty)
-- **Description:** Text before equation references. Example: `Eq.` โ `Eq.(1)`
-
-### Reference link suffix
-- **Type:** Text
-- **Default:** (empty)
-- **Description:** Text after equation references.
-
-### Show note title in equation link
-- **Type:** Toggle
-- **Default:** On
-- **Description:** Display format for links. On: `Note Title > (1)`, Off: `(1)`
+| Setting Name | Type & Default | Description |
+| :--- | :--- | :--- |
+| **Number only referenced equations** | Toggle (`On`) | Only add number tags to equations that have at least one active reference in the note. Unreferenced equations remain unnumbered. |
+| **Equation number prefix** | Text (empty) | Appends custom characters before the equation number. Example: `ยง` renders as `(ยง1)`. |
+| **Equation number suffix** | Text (empty) | Appends custom characters after the equation number. Example: `.` renders as `(1.)`. |
+| **Equation number initial count** | Number (`1`) | The starting integer for equation counting. Set to `0` to begin from zero. |
+| **Equation number style** | Dropdown (`arabic`) | The numeric formatting system used. Options: `arabic` (1, 2, 3), `alph` (a, b, c), `Alph` (A, B, C), `roman` (i, ii, iii), `Roman` (I, II, III). |
+| **Reference link prefix** | Text (empty) | Character string injected before references. Example: `Eq.` renders as `Eq.(1)`. |
+| **Reference link suffix** | Text (empty) | Character string injected after references. |
+| **Show note title in equation link** | Toggle (`On`) | Format for display links. `On` resolves to `Note Title > (1)`; `Off` resolves to `(1)`. |
---
## Autocomplete & Search
-### Enable autocompletion
-- **Type:** Toggle
-- **Default:** On
-- **Description:** Enable the `\eqref` trigger autocomplete system.
+Customize the autocompletion popup and fuzzy search indexing behaviors. For search behavior explanations, consult the [Search and Autocomplete Guide](../features/search.md).
-### Trigger for autocompletion
-- **Type:** Text
-- **Default:** `\eqref`
-- **Description:** Character sequence that opens the equation suggestion popup.
+| Setting Name | Type & Default | Description |
+| :--- | :--- | :--- |
+| **Enable autocompletion** | Toggle (`On`) | Enables the popup suggestion list while editing. |
+| **Trigger for autocompletion** | Text (`\eqref`) | The character trigger sequence that opens the autocomplete suggestions dropdown. |
+| **Render math in suggestions** | Toggle (`On`) | Render LaTeX formulas dynamically inside suggestion listings. Disabling shows raw code. |
+| **Search method** | Dropdown (`Fuzzy`) | Matches query to equations. Options: `Fuzzy` (allows typos/partial matching) or `Simple` (exact substring matching). |
+| **Modifier to jump** | Key Selection (`Mod`) (1) | Keyboard modifier key held down when selecting a suggestion to jump to the equation instead of inserting it. |
+| **Show modifier instruction** | Toggle (`On`) | Shows hint key instructions inside the autocomplete popup window. |
+| **Open location** | Dropdown (`Current tab`) | Select editor layout focus when jumping. Options: `Current tab`, `Split right`, `Split down`, `New tab`, `New window`. |
-### Render math in suggestions
-- **Type:** Toggle
-- **Default:** On
-- **Description:** Show rendered LaTeX in the suggestion list. Off shows raw LaTeX text.
+1. `Mod` defaults to ++ctrl++ on Windows/Linux and ++cmd++ on macOS.
---
## PDF Export
-### Add file name as title
-- **Type:** Toggle
-- **Default:** On
-- **Description:** Include the file name as an `` heading at the top of the PDF.
+Configure default options for the compiled PDF output. Learn about layout customization on the [PDF Export Page](../features/pdf-export.md).
-### Display headers
-- **Type:** Toggle
-- **Default:** On
-- **Description:** Show header on each PDF page.
-
-### Display footer
-- **Type:** Toggle
-- **Default:** On
-- **Description:** Show footer on each PDF page.
-
-### Print background
-- **Type:** Toggle
-- **Default:** Off
-- **Description:** Include background colors and images in PDF output.
-
-### Generate tagged PDF
-- **Type:** Toggle
-- **Default:** Off
-- **Description:** Create accessible PDF with document structure tags (experimental).
-
-### Max headings level of the outline
-- **Type:** Dropdown (1-6)
-- **Default:** `6`
-- **Description:** Maximum heading level to include in PDF bookmarks/outline.
-
-### PDF metadata
-- **Type:** Toggle
-- **Default:** Off
-- **Description:** Include frontmatter fields (title, author, keywords) in PDF metadata.
+| Setting Name | Type & Default | Description |
+| :--- | :--- | :--- |
+| **Add file name as title** | Toggle (`On`) | Prepends the markdown file name as an `` element at the start of the compiled PDF. |
+| **Display headers** | Toggle (`On`) | Renders headers on each page. Custom templates can be set under Advanced settings. |
+| **Display footer** | Toggle (`On`) | Renders footers on each page. |
+| **Print background** | Toggle (`Off`) | Includes editor background colors and styling assets in PDF print output. |
+| **Generate tagged PDF** | Toggle (`Off`) | Generates accessible PDF document structures containing layout tags (experimental). |
+| **Max headings level of outline** | Dropdown (`6`) | Maximum outline depth (h1โh6) parsed to build PDF bookmark trees. |
+| **PDF metadata** | Toggle (`Off`) | Extracts yaml frontmatter parameters to populate PDF document metadata fields. |
---
## Advanced PDF Settings
-### Header Template
-- **Type:** Textarea
-- **Default:**
-```html
-
-
-
-```
-- **Description:** HTML template for page headers. Use classes: `date`, `title`, `url`, `pageNumber`, `totalPages`.
+Customize HTML layout templates and manage style overrides during compilation.
-### Footer Template
-- **Type:** Textarea
-- **Default:**
-```html
-
- /
-
-```
-- **Description:** HTML template for page footers.
-
-### Add timestamp to output file name
-- **Type:** Toggle
-- **Default:** Off
-- **Description:** Append export timestamp to PDF filename.
-
-### Select CSS snippets
-- **Type:** Toggle
-- **Default:** Off
-- **Description:** Enable selection of disabled CSS snippets to apply to PDF export only.
-
-### Limit concurrent renders
-- **Type:** Text
-- **Default:** `5`
-- **Description:** Maximum parallel file renders during batch export.
+| Setting Name | Type & Default | Description |
+| :--- | :--- | :--- |
+| **Header Template** | Textarea (standard layout) | Custom HTML template for page headers. Supports class variable tags (e.g. `date`, `title`). |
+| **Footer Template** | Textarea (standard layout) | Custom HTML template for page footers. Supports class variable tags (e.g. `pageNumber`, `totalPages`). |
+| **Add timestamp to filename** | Toggle (`Off`) | Appends the export compile timestamp to the output `.pdf` filename. |
+| **Select CSS snippets** | Toggle (`Off`) | Allows choosing disabled CSS snippets to compile and apply to PDF layout only. |
+| **Limit concurrent renders** | Text (`5`) | Limit concurrent parallel rendering tasks during folder/batch exports. |
---
-## Debug
+## Snippets & Debugging
-### Debug mode
-- **Type:** Toggle
-- **Default:** Off
-- **Description:** Enable debug button in export modal to open DevTools for the preview webview.
-
----
-
-## Snippets
-
-### Manage Snippets
-Access via the **Add Snippet** button. Each snippet has:
-
-- **Name:** Display name in command palette
-- **Content:** LaTeX code to insert
-
-Snippets are stored in `data.json` with the plugin settings.
+| Setting Name | Type & Default | Description |
+| :--- | :--- | :--- |
+| **Manage Snippets** | Modal Dialog | Add, edit, or remove custom LaTeX command snippets triggered via the Command Palette. |
+| **Debug mode** | Toggle (`Off`) | Enables the **Debug** button in the PDF export dialog to open Electron DevTools for inspecting the render webview. |
diff --git a/docs/features/callout-support.md b/docs/features/callout-support.md
index eb40337..216c031 100644
--- a/docs/features/callout-support.md
+++ b/docs/features/callout-support.md
@@ -1,141 +1,53 @@
# Callout Support
-Use math blocks inside Obsidian callouts with automatic indentation handling.
+TeXcore seamlessly integrates with Obsidian's native Callout system. Writing display math inside block quotes requires prepending the callout character `>` to every single line. TeXcore parses these prefixes automatically to keep your formula coordinates indexed, numbered, and styled correctly without breaking the callout container.
-## The Challenge
+---
-Obsidian callouts require `>` prefixes on every line:
+## Indentation Syntax
-```markdown
-> [!note]
-> This is a callout with
-> multiple lines.
-```
+When drafting formulas inside a callout box, make sure that all lines inside the math block maintain the correct prefix.
-Math blocks inside callouts need the same treatment:
+=== "Standard Callout"
+ ```markdown
+ > [!theorem] Green's Theorem
+ > $$
+ > \oint_C (L \, dx + M \, dy) = \iint_D \left(\frac{\partial M}{\partial x} - \frac{\partial L}{\partial y}\right) dx \, dy
+ > % id: eq-greens
+ > $$
+ ```
-```markdown
-> [!theorem]
-> $$
-> E = mc^2
-> % id: eq-callout
-> $$
-```
+=== "Nested Callouts"
+ ```markdown
+ > [!note] Parent Callout
+ > Outer text body.
+ > > [!tip] Nested Equation
+ > > $$
+ > > e^{i\pi} + 1 = 0
+ > > % id: eq-euler
+ > > $$
+ ```
-Without proper indentation, the math block breaks the callout.
+---
-## Automatic Handling
+## Automatic Fix Command
-The plugin automatically handles callout prefixes when:
+Inconsistent callout prefixes (like a missing `>` on the `% id:` line or closing `$$`) will cause Obsidian to break out of the callout box. To resolve this, use the automatic fix tool:
-- Adding `\tag{}` commands
-- Inserting ID comments
-- Managing multi-line equations
+1. Open the Command Palette using ++ctrl+p++ (or ++cmd+p++ on macOS).
+2. Type and select `Fix callout equations in active note`.
+3. TeXcore will scan your active note, detect broken display blocks inside callouts, and automatically prepend missing prefixes or normalize spacing to a clean `> `.
-### Example
+| Prefix Error Pattern | Fix Applied | Result |
+| :--- | :--- | :--- |
+| **Missing `>` prefix on `% id` line** | Injects missing callout character. | `> % id: eq-name` |
+| **Missing `>` prefix on closing `$$`** | Injects missing callout character. | `> $$` |
+| **Inconsistent spacing (e.g. `> $$`)** | Normalizes prefix spacing. | `> $$` |
-**Input (with proper prefixes):**
-```markdown
-> [!theorem] Einstein's Equation
-> $$
-> E = mc^2
-> % id: eq-einstein
-> $$
-```
+---
-**After plugin processing:**
-```markdown
-> [!theorem] Einstein's Equation
-> $$
-> E = mc^2 \tag{1}
-> % id: eq-einstein
-> $$
-```
+## PDF Export & Preview Compatibility
-The plugin preserves the `>` prefix on all lines.
-
-## Fix Command
-
-If your callout equations have broken indentation, use the fix command:
-
-1. Open Command Palette (`Ctrl/Cmd + P`)
-2. Search for "Fix callout equations in active note"
-3. Run the command
-
-This scans all math blocks and fixes any incorrect indentation.
-
-### What It Fixes
-
-| Before | After |
-|--------|-------|
-| Missing `>` on ID line | Adds `>` prefix |
-| Missing `>` on closing `$$` | Adds `>` prefix |
-| Inconsistent spacing | Normalizes to `> ` |
-
-## Nested Callouts
-
-The plugin supports nested callouts:
-
-```markdown
-> [!note]
-> Outer callout
-> > [!tip]
-> > $$
-> > x^2 + y^2 = z^2
-> > % id: eq-nested
-> > $$
-```
-
-The prefix `> > ` is detected and preserved on all lines.
-
-## Supported Formats
-
-The plugin recognizes these callout prefix patterns:
-
-| Pattern | Example |
-|---------|---------|
-| Standard | `> ` |
-| Nested | `> > ` |
-| Triple nested | `> > > ` |
-| With leading space | ` > ` |
-
-## Live Preview Behavior
-
-In Live Preview mode:
-
-1. The opening `$$` line's prefix is detected
-2. All subsequent lines use the same prefix
-3. The closing `$$` line uses the same prefix
-
-This ensures consistent rendering across the entire math block.
-
-## Common Issues
-
-### Breaking Out of Callout
-
-If your math appears below the callout:
-
-```markdown
-> [!note]
-> Some text
-$$
-E = mc^2
-$$
-```
-
-**Fix:** Add `>` prefix to the math block lines, or use the fix command.
-
-### Missing ID After Fix
-
-If the ID disappears after a fix:
-
-1. The ID line may have had invalid characters
-2. Manually re-add: `> % id: eq-xxx`
-
-### PDF Export
-
-Callout math blocks export correctly to PDF with:
-
-- Callout styling preserved
-- Math rendering intact
-- Equation numbers displayed
+Callouts containing math blocks fully support all other TeXcore features:
+- **Hover Previews**: Hovering over inline references correctly renders the callout box context inside the [Page Preview Popup](quick-preview.md).
+- **PDF Compilation**: The callout color styles, borders, and equation number tags are preserved in the compiled [PDF Export](pdf-export.md).
diff --git a/docs/features/equations.md b/docs/features/equations.md
index f83c753..566acd2 100644
--- a/docs/features/equations.md
+++ b/docs/features/equations.md
@@ -1,131 +1,94 @@
-# Equation Numbering & Referencing
+# Equation & Sub-Equation Numbering
-The core feature of TeXcore is automatic equation numbering using LaTeX `\tag{}` commands and smart referencing via internal links.
+The core engine of TeXcore provides automatic equation numbering using LaTeX `\tag{}` commands, allowing you to generate academic-grade numbering schemes inside Obsidian. This feature is heavily integrated with the plugin's [Autocomplete System](search.md) and [Quick Hover Previews](quick-preview.md) to keep references synchronized in both Live Preview and Reading View.
-## How Equation IDs Work
+---
-Unlike Obsidian's built-in block references (`^block-id` after content), this plugin uses LaTeX comments inside math blocks:
+## Defining Equation Identifiers
+
+Unlike Obsidian's standard markdown block references which append identifiers at the end of elements, TeXcore indexes equations using LaTeX comments inside the display math block. Add a `% id: eq-name` comment line immediately before the closing `$$` delimiter. The prefix `eq-` is required, followed by alphanumeric characters or hyphens.
```latex
$$
E = mc^2
-% id: eq-einstein
+% id: eq-einstein (1)
$$
```
-The `% id: eq-einstein` comment:
+1. This comment is invisible in the final rendered note but is parsed by TeXcore's background indexing system to build the active note cache.
-- Is invisible in rendered output (it's a LaTeX comment)
-- Creates a unique identifier for the equation
-- Must use the format `eq-` followed by alphanumeric characters and hyphens
-- Must be on its own line before the closing `$$`
+---
-## Automatic Numbering
+## Lazy Numbering Mechanism
-When an equation has an ID and is referenced somewhere in the note, the plugin automatically inserts a `\tag{}` command:
+To keep your document layout clean, TeXcore uses **lazy numbering** by default. An equation is only assigned a right-aligned number tag if it is actively referenced somewhere in the vault. If the equation is not referenced, no tag is injected, keeping margins clear. You can disable this behavior to force-number all equations in the [Settings Reference](configuration/settings.md#number-only-referenced-equations).
-**Before (what you write):**
-```latex
-$$
-E = mc^2
-% id: eq-einstein
-$$
+=== "1. Editor Code"
+ Write the math block and reference it inline using Obsidian double brackets:
+
+ ```latex
+ $$
+ E = mc^2
+ % id: eq-einstein
+ $$
+
+ As shown in [[#^eq-einstein]], mass is equivalent to energy.
+ ```
-See equation [[#^eq-einstein]].
-```
+=== "2. Generated Output"
+ The compiler automatically appends the `\tag{}` macro and renders the link with the assigned index:
+
+ $$
+ E = mc^2 \tag{1}
+ $$
+
+ As shown in [(1)](#), mass is equivalent to energy.
-**After (what the plugin generates):**
-```latex
-$$
-E = mc^2 \tag{1}
-% id: eq-einstein
-$$
+---
-See equation [[#^eq-einstein]].
-```
+## Numbering Customization
-!!! note "Number Only Referenced Equations"
- By default, equations are only numbered if they're referenced somewhere. This can be changed in settings.
+Configure the layout of your math indices globally via the [Settings Panel](configuration/settings.md#equation-numbering-referencing). Numbering formats support a variety of typographic styles, prefixes, and suffixes.
-## Number Styles
+### Number Styles Matrix
-Configure the numbering style in plugin settings:
+| Style Name | Description | Output Example |
+| :--- | :--- | :--- |
+| `arabic` | Standard Hindu-Arabic numerals. | `(1)`, `(2)`, `(3)` |
+| `alph` | Lowercase Latin alphabetical indexing. | `(a)`, `(b)`, `(c)` |
+| `Alph` | Uppercase Latin alphabetical indexing. | `(A)`, `(B)`, `(C)` |
+| `roman` | Lowercase Roman numerals. | `(i)`, `(ii)`, `(iii)` |
+| `Roman` | Uppercase Roman numerals. | `(I)`, `(II)`, `(III)` |
-| Style | Example Output |
-|-------|----------------|
-| `arabic` | 1, 2, 3, ... |
-| `alph` | a, b, c, ... |
-| `Alph` | A, B, C, ... |
-| `roman` | i, ii, iii, ... |
-| `Roman` | I, II, III, ... |
+!!! info "Prefixes & Suffixes"
+ Prepending prefixes (e.g., `Eq.`) or appending suffixes (e.g., `.`) will modify the output. For example, a prefix of `Eq.` with style `arabic` renders as `(Eq.1)`. A suffix of `.` renders as `(1.)`. See [Reference Link Customization](configuration/settings.md#reference-link-prefix) for additional details.
-## Prefix and Suffix
+---
-Add custom text before/after equation numbers:
+## Sub-Equation System
-| Setting | Value | Result |
-|---------|-------|--------|
-| Prefix: `ยง` | - | (ยง1) |
-| Suffix: `.` | - | (1.) |
-| Prefix: `Eq.` | - | (Eq.1) |
-
-## Sub-Equations
-
-For multi-line equations, reference individual lines using sub-indices:
+Multi-line equations (such as systems of equations using LaTeX `align` or split blocks) can be individually referenced using sub-indices. Reference rows sequentially by appending the row number to the parent ID:
```latex
$$
+\begin{aligned}
a &= b + c \\
d &= e + f
+\end{aligned}
% id: eq-system
$$
-First equation: [[#^eq-system-1]]
-Second equation: [[#^eq-system-2]]
+Referencing row 1: [[#^eq-system-1]]
+Referencing row 2: [[#^eq-system-2]]
```
-The plugin generates:
+The plugin automatically parses the structure and appends sub-tags such as `\tag{1.1}` to the first equation line and `\tag{1.2}` to the second.
-- `\tag{1.1}` for the first row
-- `\tag{1.2}` for the second row
+---
-## Referencing Equations
+## Inserting References
-### Using Autocomplete
+To reference an equation quickly, type your trigger (default is `\eqref`) to activate the autocomplete dialog. Use your keyboard arrow keys to scroll, and press ++enter++ to insert the formatted wiki-link.
-1. Type your trigger string (default: `\eqref`)
-2. A suggestion popup appears with all equations in the current note
-3. Select an equation using arrow keys or mouse
-4. Press Enter to insert `[[#^eq-id]]`
-
-### Jump to Equation
-
-Hold the modifier key (default: `Ctrl`/`Cmd`) while pressing Enter on a suggestion to jump to the equation instead of inserting a link.
-
-### Reference Display Format
-
-Configure how equation links are displayed:
-
-| Setting | Display Format |
-|---------|----------------|
-| Show note title: ON | `Note Title > (1)` |
-| Show note title: OFF | `(1)` |
-| Reference prefix: `Eq.` | `Eq.(1)` |
-| Reference suffix: none | `(1)` |
-
-## Live Preview vs Reading View
-
-The plugin works in both Obsidian views:
-
-- **Live Preview**: Uses CodeMirror extensions for real-time numbering
-- **Reading View**: Uses Markdown post-processors for rendered output
-
-Both views stay synchronized - editing in one updates the other.
-
-## Equation Cache
-
-The plugin maintains a cache of all equations in the current note for fast lookup. This cache is:
-
-- Built when a note is opened
-- Updated on every document change
-- Used for autocomplete suggestions and hover previews
+!!! tip "Jump-to-Equation Shortcut"
+ Holding the ++ctrl++ key (or ++cmd++ on macOS) while pressing ++enter++ on a suggestion in the autocomplete list will instantly jump your cursor to the equation definition instead of inserting the link.
diff --git a/docs/features/index.md b/docs/features/index.md
new file mode 100644
index 0000000..77250d6
--- /dev/null
+++ b/docs/features/index.md
@@ -0,0 +1,41 @@
+# Features Overview Matrix
+
+Discover the full capabilities of TeXcore. Use this matrix to quickly navigate between features and learn how to configure them inside your vault.
+
+---
+
+## Interactive Feature Matrix
+
+The following matrix provides a side-by-side view of all available modules, how they enhance your Obsidian writing, and where to configure their properties.
+
+| Feature Module | One-Line Overview | Documentation | Configuration Property |
+| :--- | :--- | :--- | :--- |
+| **Equation Numbering** | Automatic indexing and tag insertion for LaTeX math blocks. | [Read Guide :octicons-arrow-right-16:](equations.md) | [Number Style & Prefix](configuration/settings.md#equation-numbering-referencing) |
+| **PDF Export** | Advanced PDF renderer featuring live preview pane and snippets. | [Read Guide :octicons-arrow-right-16:](pdf-export.md) | [Templates & Margins](configuration/settings.md#pdf-export) |
+| **LaTeX Snippets** | Custom LaTeX macros map to shortcut commands for fast editing. | [Read Guide :octicons-arrow-right-16:](snippets.md) | [Manage Snippets](configuration/settings.md#snippets) |
+| **Equation Search** | Vault-wide fuzzy search index modal to locate and insert links. | [Read Guide :octicons-arrow-right-16:](search.md) | [Autocomplete & Search](configuration/settings.md#autocomplete-search) |
+| **Quick Preview** | Real-time preview popups and link resolution on hover. | [Read Guide :octicons-arrow-right-16:](quick-preview.md) | [Render Math in Suggestions](configuration/settings.md#render-math-in-suggestions) |
+| **TikZ Diagrams** | Render vector graphics using native TikZ scripts and component packages. | [Read Guide :octicons-arrow-right-16:](tikz.md) | [Component Library](configuration/settings.md#snippets) |
+| **Callout Support** | Auto-alignment and indentation parsing inside Obsidian callouts. | [Read Guide :octicons-arrow-right-16:](callout-support.md) | [Fix Indentation](configuration/settings.md#debug) |
+
+---
+
+## Advanced Feature Highlights
+
+To help you decide which features fit your academic drafting workflow, explore the specialized sub-systems:
+
+
+
+- :material-sigma: **[Equation & Sub-equation Numbering](equations.md)**
+
+ Supports both standard single-line formulas and multi-line sub-equations with custom templates (e.g., `(1.1)`, `(1.2)`). Includes customizable numbering bases (Arabic, Roman, Alphabetic).
+
+- :material-file-pdf-box: **[Advanced PDF Printing](pdf-export.md)**
+
+ Features a dual-pane interface with responsive styling, page counters, and margins. Directly utilizes Obsidian's CSS snippets to style headers and footers.
+
+- :material-draw-pen: **[TikZ & SVG Vector Rendering](tikz.md)**
+
+ Write vector scripts using LaTeX packages (e.g., `circuitikz`, `pgfplots`) and see them compiled dynamically. Features an interactive sidebar library for drag-and-drop circuit components.
+
+
diff --git a/docs/features/pdf-export.md b/docs/features/pdf-export.md
index f403948..9b81e41 100644
--- a/docs/features/pdf-export.md
+++ b/docs/features/pdf-export.md
@@ -1,176 +1,75 @@
-# PDF Export
+# PDF Compilation & Export
-Export your notes to PDF with full equation rendering, customizable layouts, and live preview.
+TeXcore provides an advanced PDF rendering module to export your mathematical notes with full math layout support. Accessible via ++ctrl+p++ (search `Export current file to PDF`) or by right-clicking any file/folder in the file explorer to choose **Better Export PDF**, this modal splits into a real-time responsive 2/3 preview pane and a 1/3 export configuration sidebar.
-## Opening the Export Dialog
-
-### Via Command Palette
-1. Press `Ctrl/Cmd + P`
-2. Search for "Export current file to PDF"
-3. The export modal opens with a live preview
-
-### Via File Menu
-Right-click a file or folder in the file explorer โ **Better Export PDF**
-
-### Folder Export Options
-For folders, you get additional options:
-
-- **Export each file to PDF**: Creates separate PDFs for each file
-- **Generate TOC.md file**: Creates a table of contents file
-
-## Export Modal
-
-The export dialog has two panels:
-
-| Left Panel (2/3) | Right Panel (1/3) |
-|------------------|-------------------|
-| Live PDF preview | Export settings |
-
-### Preview Features
-- Real-time preview updates as you change settings
-- Scales to fit the container
-- Shows print dimensions for custom page sizes
-
-## Page Settings
-
-### Page Size
-Choose from standard sizes or custom dimensions:
-
-| Size | Dimensions (mm) |
-|------|-----------------|
-| A0 - A6 | Standard ISO sizes |
-| Letter | 215.9 ร 279.4 |
-| Legal | 215.9 ร 355.6 |
-| Tabloid | 279.4 ร 431.8 |
-| Ledger | 431.8 ร 279.4 |
-| Custom | User-defined |
-
-For custom sizes, enter width and height in millimeters.
-
-### Margins
-
-| Type | Description |
-|------|-------------|
-| None | No margins |
-| Default | 10mm all sides |
-| Small | Minimal margins |
-| Custom | User-defined per side |
-
-Custom margins are specified in millimeters for top, bottom, left, and right.
-
-### Orientation
-- **Portrait**: Standard vertical layout
-- **Landscape**: Horizontal layout
-
-### Downscale Percent
-Reduce content size (0-100%). Useful for fitting more content per page.
-
-## Header & Footer
-
-### Toggle Display
-Enable/disable headers and footers separately.
-
-### Template Variables
-Use these classes in your HTML templates:
-
-| Class | Content |
-|-------|---------|
-| `date` | Formatted print date |
-| `title` | Document title |
-| `url` | Document location |
-| `pageNumber` | Current page number |
-| `totalPages` | Total page count |
-
-### Example Templates
-
-**Header (centered title):**
-```html
-
-
-
-```
-
-**Footer (page numbers):**
-```html
-
- /
-
-```
-
-## Additional Options
-
-### Include File Name as Title
-Adds an `` with the file name at the top of the PDF.
-
-### Print Background
-Include background colors and images in the PDF.
-
-### Generate Tagged PDF
-Creates an accessible PDF with document structure tags. Note: This is experimental.
-
-### Open After Export
-Automatically open the PDF after export completes.
-
-### CSS Snippets
-Apply custom CSS to the exported PDF:
-
-1. Create a CSS file in `.obsidian/snippets/`
-2. Keep the snippet **disabled** in Obsidian settings
-3. Select it in the export dialog dropdown
-
-The snippet CSS will only apply to the PDF, not your regular Obsidian view.
-
-## Advanced Settings
-
-### Max Heading Level
-Control which headings appear in the PDF outline/bookmarks (h1-h6).
-
-### PDF Metadata
-Add frontmatter fields to PDF metadata:
-
-```yaml
---
-title: My Document
-author: John Doe
-keywords: math, equations
-subject: Academic Paper
+
+## Core Layout Settings
+
+Customize document geometry, printing limits, and margins before compilation.
+
+| Control Option | Description | Typical Options |
+| :--- | :--- | :--- |
+| **Page Size** | Select standard sheet geometry or define millimeters. | `A4`, `Letter`, `Legal`, `Tabloid`, or `Custom` |
+| **Orientation** | Vertical portrait layout or horizontal landscape. | `Portrait`, `Landscape` |
+| **Margins** | Inner safety margins specified in millimeters per edge. | `None`, `Default (10mm)`, `Small`, or `Custom` |
+| **Downscale** | Scales content down (0โ100%) to fit complex tables/formulas. | Integer slider percentage |
+
---
-```
-### Timestamp in Filename
-Append export timestamp to the output filename.
+## Headers & Footers Configuration
-### Concurrent Renders
-For batch exports, limit concurrent file processing (default: 5).
+Headers and footers render HTML dynamically using dedicated style selectors.
-## Batch Export
+### HTML Template Classes
+Include these target element class names in your header/footer HTML code. TeXcore will parse and replace them with print values:
-When exporting a folder:
+- ` `: Resolves to note file title.
+- ` `: Resolves to current page index.
+- ` `: Resolves to total compiled pages.
+- ` `: Inserts local print timestamp.
+- ` `: Inserts workspace source path.
-1. Right-click the folder
-2. Select **Export to PDF...** โ **Export each file to PDF**
-3. Choose output directory
-4. All files export in parallel with progress indicator
+=== "Header Template"
+ ```html
+
+
+
+ ```
-## Table of Contents
+=== "Footer Template"
+ ```html
+
+ /
+
+ ```
-Generate a TOC file for folder exports:
+---
-1. Right-click a folder
-2. Select **Export to PDF...** โ **Generate TOC.md file**
-3. A `_TOC_.md` file is created with links to all files
+## Accessing Custom CSS Styling
-The TOC uses frontmatter `toc: true` to enable special processing during export.
+Style your PDF print outputs independently of Obsidian's active theme.
-## Troubleshooting
+1. Write a custom `.css` template sheet and save it inside your vault folder: `.obsidian/snippets/`.
+2. Keep this snippet **disabled** in Obsidian settings. This prevents it from altering your editor UI.
+3. Open the TeXcore Export Dialog, and choose your stylesheet in the dropdown menu. The snippet's styling will be injected exclusively during PDF compilation.
-### Debug Mode
-Enable debug mode in settings to access the **Debug** button in the export modal, which opens browser DevTools for the preview webview.
+---
-### Common Issues
+## Advanced Compiler Operations
-| Issue | Solution |
-|-------|----------|
-| Equations not rendering | Wait for preview to fully load |
-| CSS not applying | Ensure snippet is disabled in Obsidian |
-| Export fails | Check file permissions on output path |
+???+ info "Batch Compilation & Tables of Contents"
+ When right-clicking a folder, you can choose **Export each file to PDF** to compile all children in parallel. The **Generate TOC.md file** action generates a table of contents file (`_TOC_.md`) automatically. Configure parallel limits under the [Settings Reference](configuration/settings.md#limit-concurrent-renders).
+
+???+ warning "Metadata & Bookmark Outlines"
+ If enabled in [Settings Panel](configuration/settings.md#pdf-metadata), TeXcore compiles Frontmatter fields (`title`, `author`, `keywords`, `subject`) into metadata tags inside the output PDF. Outlines index heading levels 1 through 6 dynamically depending on user preferences.
+
+---
+
+## Troubleshooting Print Errors
+
+!!! failure "Common Rendering Obstacles"
+ - **Broken Equations / Untagged Math**: If preview is incomplete, wait a few seconds before hitting export. MathJax runs asynchronously and needs time to process large nodes.
+ - **Missing Snippet Styles**: Double check that your snippet stylesheet is in `.obsidian/snippets/` and is *disabled* in standard Obsidian settings.
+ - **Write Failures**: Ensure the output path is writable and not locked by another PDF reader app.
+ - **Webview Crashes**: If errors persist, enable **Debug Mode** in the [Settings Reference](configuration/settings.md#debug-mode) and click the **Debug** button in the modal to inspect the Electron DevTools container.
diff --git a/docs/features/quick-preview.md b/docs/features/quick-preview.md
index 3be7fae..12365cd 100644
--- a/docs/features/quick-preview.md
+++ b/docs/features/quick-preview.md
@@ -1,72 +1,42 @@
-# Quick Preview
+# Quick Hover Previews
-Hover over equation links to see a rendered preview popup.
+TeXcore integrates with Obsidian's core page preview mechanics to display real-time, mathematical popups whenever you hover over an equation link like `[[#^eq-einstein]]` or highlight items in the [Autocomplete Suggestions Menu](search.md).
-## Overview
+---
-When you hover over an equation reference like `[[#^eq-einstein]]`, a popup appears showing:
+## Technical Architecture
-- The rendered equation
-- Context around the equation
-
-This uses Obsidian's built-in Page Preview plugin, enhanced to work with equation block references.
-
-## How It Works
-
-The plugin patches Obsidian's `page-preview` internal plugin to:
-
-1. Detect equation links (format: `[[#^eq-xxx]]`)
-2. Look up the equation in the cache
-3. Scroll the preview to the correct line
+The preview lookup hooks into Obsidian's internal `page-preview` engine. TeXcore intercepts link hover events, references the identifier against the active document's equation cache, locates the line offset, and scrolls the popup container to display the equation in focus.
```mermaid
sequenceDiagram
- participant User
- participant PagePreview
- participant Plugin
- participant Cache
+ participant User as Editor hover
+ participant PP as Page Preview Plugin
+ participant C as TeXcore Cache
+ participant V as Preview Popup View
- User->>PagePreview: Hover on [[#^eq-id]]
- PagePreview->>Plugin: onLinkHover
- Plugin->>Cache: Lookup eq-id
- Cache-->>Plugin: Line number
- Plugin->>PagePreview: Show preview at line
- PagePreview->>User: Popup displayed
+ User->>PP: Hover [[#^eq-id]]
+ PP->>C: Query eq-id coordinate
+ C-->>PP: Return line number
+ PP->>V: Compile MathJax & scroll to line
+ V->>User: Display preview popup
```
-## Requirements
+---
-The Page Preview core plugin must be enabled:
+## Requirements & Setup
-1. **Settings** โ **Core plugins**
-2. Enable **Page preview**
+!!! important "Enable Core Page Preview"
+ This feature relies on Obsidian's core architecture. Open **Settings** โ **Core plugins** and toggle **Page preview** to **Enable**. If this core dependency is deactivated, TeXcore will log a console warning and hover previews will fallback to standard text previews without viewport scrolling.
-If disabled, the plugin logs a message and hover previews won't work.
+---
-## Preview Content
+## Troubleshooting Previews
-The popup shows the full note context around the equation, similar to hovering over any Obsidian internal link. The equation is scrolled into view automatically.
+If preview boxes fail to load or scroll incorrectly, consult the diagnostic table below:
-## Autocomplete Preview
-
-When using the `\eqref` autocomplete:
-
-- Hover over suggestions to preview equations
-- Uses the same preview system
-- Works in both search modal and editor suggest
-
-## Configuration
-
-No additional configuration needed. The feature works automatically when:
-
-- Page Preview core plugin is enabled
-- Equations have valid `% id: eq-xxx` comments
-- Equation cache is up to date
-
-## Troubleshooting
-
-| Issue | Solution |
-|-------|----------|
-| No popup appears | Enable Page Preview core plugin |
-| Wrong content shown | Equation cache may be stale, edit the file to refresh |
-| Preview doesn't scroll | Equation ID may be malformed |
+| Symptom | Cause | Solution |
+| :--- | :--- | :--- |
+| **No preview modal opens** | Page Preview core plugin is toggled off. | Toggle on Page Preview in Obsidian settings. |
+| **Formula text displays raw LaTeX** | MathJax rendering is disabled for suggestions. | Toggle on [Render Math in Suggestions](configuration/settings.md#render-math-in-suggestions). |
+| **Preview focuses the wrong line** | Malformed `% id: eq-name` or stale index cache. | Touch-edit the note content to trigger a cache re-index. |
diff --git a/docs/features/search.md b/docs/features/search.md
index 12e0211..e6507ad 100644
--- a/docs/features/search.md
+++ b/docs/features/search.md
@@ -1,152 +1,52 @@
# Equation Search & Autocomplete
-Find and reference equations quickly using fuzzy search and autocomplete.
+TeXcore offers a robust, keyboard-driven interface to search, preview, and link equations inside your vault. This module consists of two main interfaces: an editor-integrated **Autocomplete Popup** and a dedicated **Search Modal**.
-## Autocomplete
+---
-### Triggering Autocomplete
+## Editor Autocomplete Popup
-Type the trigger string (default: `\eqref`) in the editor to open the suggestion popup:
+Trigger autocompletion anywhere in your notes by typing your configured trigger string (default is `\eqref`). A dropdown menu instantly pops up at your cursor, displaying all equations matching your query along with their parsed line numbers and live math previews.
-```markdown
-As shown in \eqref
- โ popup appears here
-```
+| Action Key | Result | Description |
+| :--- | :--- | :--- |
+| `โ` / `โ` | Highlight suggestion | Navigate the list of cached equations. |
+| ++enter++ | Insert reference link | Appends `[[#^eq-id]]` at cursor. |
+| ++ctrl+enter++ (1) | Jump to definition | Instantly shifts editor focus to the math block declaration. |
+| ++escape++ | Close popup | Discards current search query. |
-### Suggestion List
+1. Maps to ++cmd+enter++ on macOS environments. Instructions are displayed inline inside the popup if configured in settings.
-The popup shows all equations in the current note with:
+!!! info "Automatic Identifier Generation"
+ If you reference an equation that has no identifier tag, TeXcore automatically generates a unique ID (e.g. `% id: eq-b5x3e2`), appends it before the closing `$$`, and inserts the resolved link to ensure referencing consistency.
-- Rendered math preview (can be disabled)
-- Line number where the equation appears
-- Sorted by relevance to your query
+---
-### Selecting a Suggestion
+## Vault Search Modal
-| Action | Result |
-|--------|--------|
-| `โ` / `โ` | Navigate suggestions |
-| `Enter` | Insert `[[#^eq-id]]` link |
-| `Mod + Enter` | Jump to equation location |
-| `Esc` | Close popup |
-| Click | Select and insert |
+Access the dedicated note-wide search dialog by triggering ++ctrl+p++ and running `Search equations in active note`. This modal provides an expanded viewport displaying equation previews on hover. It uses two search algorithms configurable in your [Autocomplete Settings](configuration/settings.md#autocomplete-search):
-!!! tip "Modifier Key"
- The "jump" modifier is `Ctrl` on Windows/Linux or `Cmd` on macOS by default. Configure this in settings.
+- **Fuzzy Search (Default)**: Matches partial strings and typos (e.g., query `intgl` matches `\integral` or `\int`) using Obsidian's internal `prepareFuzzySearch` utility.
+- **Simple Search**: Matches exact substrings (e.g., query `int` matches `\int` and `point` but not `frac`) using Obsidian's `prepareSimpleSearch` utility.
-### After Selection
+---
-When you select an equation:
+## Navigation Target Configuration
-1. If the equation has an ID, the link is inserted immediately
-2. If no ID exists, one is auto-generated and inserted
+When jumping to equation definitions via the autocomplete suggestion list, you can configure the editor target split layout in [Settings Reference](configuration/settings.md#open-location):
-Inserted link format:
-```markdown
-[[#^eq-gaussian]]
-```
+=== "Standard Navigation (Default)"
+ Resolves the jump within your active tab view, replacing current focus.
-With `insertSpace` enabled (default):
-```markdown
-[[#^eq-gaussian]]
-```
+=== "Split Tab Right"
+ Splits the active pane vertically and focuses the equation on the right-hand panel.
-## Search Modal
+=== "New Window / Split Down"
+ Opens a detached editor window or horizontal split viewport to show equations side-by-side.
-### Opening the Modal
+---
-1. Press `Ctrl/Cmd + P`
-2. Search for "Search equations in active note"
-3. Or use the hotkey if you've assigned one
+## Performance Notes & Best Practices
-### Modal Features
-
-The search modal provides:
-
-- Full equation search across the active note
-- Same fuzzy/simple search as autocomplete
-- Keyboard navigation
-- Quick preview on hover
-
-## Search Methods
-
-### Fuzzy Search (Default)
-
-Matches partial strings with typos allowed:
-
-| Query | Matches |
-|-------|---------|
-| `intgl` | `\integral`, `\int` |
-| `frac` | `\frac`, `fraction` |
-| `sumx` | `\sum_{x}`, `\sum_x` |
-
-Uses Obsidian's built-in `prepareFuzzySearch` algorithm.
-
-### Simple Search
-
-Exact substring matching:
-
-| Query | Matches |
-|-------|---------|
-| `int` | `\int`, `\integral`, `point` |
-| `frac` | `\frac`, `fraction` |
-
-Uses Obsidian's `prepareSimpleSearch` algorithm.
-
-Toggle between methods in plugin settings.
-
-## Configuration
-
-### Autocomplete Settings
-
-| Setting | Description | Default |
-|---------|-------------|---------|
-| Enable autocompletion | Toggle autocomplete feature | โ
On |
-| Trigger string | Text that opens the popup | `\eqref` |
-| Render math in suggestions | Show rendered or raw LaTeX | โ
On |
-
-### Navigation Settings
-
-| Setting | Description | Default |
-|---------|-------------|---------|
-| Modifier to jump | Key for jump action | `Mod` |
-| Show modifier instruction | Display keyboard hints | โ
On |
-| Open location | Where to open file when jumping | Current tab |
-
-### Jump Location Options
-
-When jumping to an equation, choose target:
-
-| Option | Behavior |
-|--------|----------|
-| Current tab | Replace current view |
-| Split right | Open in right split |
-| Split down | Open in bottom split |
-| New tab | Open in new tab |
-| New window | Open in new window |
-
-## Tips
-
-### Custom Trigger
-
-Change the trigger string to avoid conflicts:
-
-- `\ref` - Shorter alternative
-- `#eq` - Hash-based trigger
-- `@eq` - Symbol-based trigger
-
-### Performance
-
-The search operates on the active note only:
-
-- No vault-wide scanning
-- Instant results
-- Works offline
-
-### Combining with Snippets
-
-1. Create a snippet with equation template
-2. Insert using snippet command
-3. Fill in the equation
-4. Add ID immediately
-5. Reference later with `\eqref`
+- **Vault Performance**: The search indexing index is scoped to the **active note only**. This prevents vault-wide scan lag, resulting in instant search evaluations even on low-end hardware.
+- **Custom Triggers**: You can change the trigger string in [Autocomplete & Search Settings](configuration/settings.md#autocomplete-search) to `#eq` or `@eq` if you want to avoid overlaps with other LaTeX parser plugins.
diff --git a/docs/features/snippets.md b/docs/features/snippets.md
index d86ffe0..8e90733 100644
--- a/docs/features/snippets.md
+++ b/docs/features/snippets.md
@@ -1,51 +1,36 @@
-# Command Snippets
+# Editor Command Snippets
-Use command palette snippets for fast note metadata and text transformations.
+TeXcore features quick helper shortcuts to automate note metadata setup and perform text capitalization transforms inside the editor. These commands are registered globally and can be triggered via the command palette using ++ctrl+p++ (or ++cmd+p++ on macOS).
-## Available Commands
+---
-### Add Tags
+## Frontmatter Metadata Injection
-Adds this frontmatter block at the top of the active note:
+The **Add Tags** command injects a standardized frontmatter YAML block at the very top of your active note, facilitating quick categorization and alias indexing:
```yaml
---
tags:
- -
+ -
aliases:
- -
+ -
---
```
-How to use:
+!!! note "Duplication Prevention"
+ If the plugin detects that your note already starts with a valid YAML frontmatter block (delimiters `---`), the command terminates safely and does not write duplicates.
-1. Open Command Palette (`Ctrl/Cmd + P`)
-2. Search for "Add Tags"
-3. Run the command
+---
-Behavior:
+## Editor Text Transformations
-- Inserts the frontmatter block at the top of the file
-- If frontmatter already exists at the top of the note, no change is made
+The **Run Text Transform Snippet** command processes your highlighted editor text (or the active cursor line if no text is selected) through predefined formatting filters. You can use this with multi-cursor selections.
-### Run Text Transform Snippet
+| Transformation | Output Example | Typical Use Case |
+| :--- | :--- | :--- |
+| **Kebab Case** | `example-text-string` | Fast URL or note alias linking. |
+| **Title Kebab Case** | `Example-Text-String` | Title heading slugifications. |
+| **Title Case** | `Example Text String` | Formatting article or note headings. |
+| **Clean Zotero Highlight** | Reformats HTML tags to raw markdown | Converting Zotero desktop highlights into blockquotes. |
-Applies a built-in transformation to the selected text, or to the current line if no text is selected.
-
-How to use:
-
-1. Open Command Palette (`Ctrl/Cmd + P`)
-2. Search for "Run Text Transform Snippet"
-3. Choose a transform from the list
-
-Built-in transforms:
-
-- Kebab Case (`my-selected-text`)
-- Title Kebab Case (`My-Selected-Text`)
-- Title Case (`My Selected Text`)
-- Clean Zotero Highlight Line (`... ` to quote + source format)
-
-## Notes
-
-- Multi-cursor selections are supported for text transforms
-- Transform commands show a notice indicating whether changes were applied
+When any transformation runs, TeXcore displays a notice toast in the top-right corner indicating whether the change was successfully applied.
diff --git a/docs/features/tikz.md b/docs/features/tikz.md
index 86e63dd..529b812 100644
--- a/docs/features/tikz.md
+++ b/docs/features/tikz.md
@@ -1,83 +1,72 @@
-# TikZ Diagrams
+# TikZ Diagrams & Graphical Editor
-Render LaTeX TikZ diagrams (` ```tikz `) fully offline and on-demand inside your notes.
-
-## Overview
-
-Unlike other plugins that require an internet connection to fetch heavy dependencies or freeze your UI during TeX compilation, this plugin leverages a decoupled, worker-based compiler.
-
-- **Fully Offline** โ Requires no internet connection. All TeX format files and packages are cached locally in the plugin folder.
-- **Lazy Loaded Assets** โ Core components (`tex.wasm.gz` and `core.dump.gz`) are read from disk, decompressed, and cached in memory on the first compile. Supplementary packages and libraries (like `circuitikz`, `pgfplots`, or `tikz-cd`) are only loaded and decompressed from disk when requested in your TikZ block's preamble.
-- **Web Worker Concurrency** โ TeX compilation is CPU-bound. Offloading it to a background Web Worker ensures your Obsidian interface remains smooth and responsive.
-- **Theme Integration** โ Hardcoded dark strokes and fills adapt automatically to Obsidian's active dark or light themes if dark-mode color inversion is enabled.
+TeXcore integrates an offline, background-compiled TikZ vector graphics renderer and a fully interactive **TikZ Graphical Editor**. You can write standard LaTeX TikZ scripts directly inside code blocks or edit them visually using a drag-and-drop CAD-like interface.
---
-## How It Works
+## Architecture & Worker Compilation
-1. The Markdown code block processor detects a ` ```tikz ` block.
-2. The plugin parses the code preamble for package requirements (e.g. `\usepackage{pgfplots}` or `\usetikzlibrary{calc}`).
-3. The main thread loads the WASM engine, core dump, and resolved package files, decompresses them natively using Node's `zlib` library, and transfers the buffers to the Web Worker.
-4. The background worker spins up a virtual filesystem in memory, loads the TeX format dump, and compiles the TikZ block to a binary DVI format.
-5. The main thread receives the DVI binary, translates it to SVG using `dvi2html` and the secure browser `DOMParser` API, and inserts it into the note.
+To prevent Obsidian UI freezes during heavy TeX compilations, TeXcore utilizes a decoupled **Web Worker Compiler** that operates fully offline:
```mermaid
sequenceDiagram
- participant Main as Obsidian Main Thread
+ participant Main as Obsidian Editor
participant Worker as TikZJax Web Worker
+ participant Disk as Local Storage (WASM)
- Note over Main: Markdown block processor triggered
- Main->>Main: Parse preamble (detect packages & libraries)
- Main->>Main: Read & decompress required .gz assets from local disk
- Main->>Worker: Spawn worker & postMessage(startCompile, { code, files })
- Note over Worker: Populate virtual filesystem in memory
- Worker->>Worker: Run TeX WebAssembly engine (Sync)
- Worker->>Worker: Output DVI format
- Worker->>Main: postMessage(compileSuccess, { dviData })
- Main->>Main: Translate DVI to SVG using dvi2html & DOMParser
- Main->>Main: Render SVG inside Note View
+ Main->>Disk: Read & decompress core assets (tex.wasm.gz, core.dump.gz)
+ Main->>Worker: Spawn thread & send decompressed WebAssembly buffers
+ Note over Worker: Spin up virtual memory filesystem
+ Main->>Worker: Post compile command (TikZ code)
+ Worker->>Worker: Sync compile to DVI format
+ Worker-->>Main: Return DVI binary output
+ Main->>Main: Translate DVI to SVG using dvi2html & insert into note
```
---
-## Usage
+## Editor Workflows
-Create a code block in your markdown document and identify it with `tikz`. You can include packages in the preamble:
+=== "Using Code Blocks"
+ Create a code block in your markdown document and identify it with `tikz`. You can include packages like `pgfplots` or libraries in the preamble:
+
+ ```tikz
+ \usepackage{pgfplots}
+ \begin{document}
+ \begin{tikzpicture}
+ \begin{axis}[xlabel=$x$, ylabel=$y$]
+ \addplot[color=red,mark=x] coordinates {
+ (2,-3) (3,5) (4,7)
+ };
+ \end{axis}
+ \end{tikzpicture}
+ \end{document}
+ ```
-```tikz
-\usepackage{pgfplots}
-\begin{document}
-\begin{tikzpicture}
- \begin{axis}[
- xlabel=$x$,
- ylabel=$y$
- ]
- \addplot[color=red,mark=x] coordinates {
- (2,-3)
- (3,5)
- (4,7)
- };
- \end{axis}
-\end{tikzpicture}
-\end{document}
-```
+=== "Using the Graphical Editor"
+ 1. Click the **Edit visually** button on any rendered TikZ SVG block, or use the Command Palette (++ctrl+p++ โ `Insert display math` or search for the TikZ Editor commands).
+ 2. A CAD-style modal opens:
+ - **Left Sidebar**: Component library featuring core shapes (lines, circles, rectangles) and package modules.
+ - **Center Canvas**: Interactive grid with zoom (++ctrl+scroll++), panning (++spacebar+drag++), and snapping (toggle via ++g++ or ++h++).
+ - **Right Sidebar**: Property editor (adjust color, bold, italics, font size, stroke thickness) and raw TikZ Code synchronization.
+ 3. Press ++ctrl+z++ to undo and ++ctrl+y++ to redo actions.
+ 4. Click the **Update block** button to compile and insert the code block into your note.
---
-## Asset Origins & Lineage
+## Component Package Manager
-The lazy-loaded assets stored inside the `tikzjax-assets` folder are compiled/extracted from:
+For complex diagrams (like circuits or flowcharts), the Graphical Editor features a **Component Package Manager** accessed via the footer button in the Left Sidebar.
-1. **[kisonecat/tikzjax](https://github.com/kisonecat/tikzjax)**: The original browser-based TeX compiler created by Jim Fowler, which compiles TeX's Pascal source to WebAssembly via `web2js`.
-2. **[drgrice1/tikzjax](https://github.com/drgrice1/tikzjax)**: Glenn Rice's fork which introduced Web Worker support and support for additional LaTeX packages and libraries.
-3. **[artisticat1/obsidian-tikzjax](https://github.com/artisticat1/obsidian-tikzjax)**: The Obsidian plugin wrapper created by `artisticat1` which packaged these components for offline usage in Obsidian.
+!!! info "Package Installation Flow"
+ Click **Manage Packages** and search for libraries like `circuitikz`. Installing a package decompresses and caches its TeX dependency files (`.tex`/`.sty`) in memory, dynamically expanding your visual element sidebar with custom shapes (e.g. resistors, logic gates, operational amplifiers) that can be drag-dropped onto the grid canvas.
---
-## Troubleshooting
+## Diagnostics & Troubleshooting
-| Issue | Cause | Solution |
-|-------|-------|----------|
-| **Diagram shows empty container** | Core assets are missing or disabled | Ensure the `tikzjax-assets` folder is present inside the plugin directory under `.obsidian/plugins/TeXcore/tikzjax-assets/`. |
-| **Error: Package not found** | The requested LaTeX package is not supported or not present | Only packages and libraries pre-compiled into the assets are supported. Ensure the package name matches standard packages (like `pgfplots`, `circuitikz`, `chemfig`, `tikz-cd`). |
-| **Compile hangs or freezes** | Complex intersections or recursive computations exceed WebAssembly boundaries | Break down complex calculations or verify diagram logic for syntax loops. |
+!!! warning "Common TikZ Compilation Hurdles"
+ - **Empty Container / Failed rendering**: Verify that the `tikzjax-assets` folder exists inside your plugin installation directory: `.obsidian/plugins/TeXcore/tikzjax-assets/`.
+ - **Package Not Found Error**: Double-check spelling inside your block preamble. Only packages included in the asset registry are available offline.
+ - **Canvas Panning Locked**: Ensure you hold down the ++spacebar++ while dragging on the canvas container to pan the grid layout.
+ - **Compilation Timed Out**: Avoid infinite loops or extremely dense coordinate calculations that exceed WebAssembly heap size allocations.
diff --git a/docs/getting-started.md b/docs/getting-started.md
index a794a31..4e99cc0 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -1,86 +1,79 @@
-# Getting Started
+# Getting Started with TeXcore
+
+Follow this guide to install, configure, and draft your first indexed equations in Obsidian.
+
+---
## Installation
-### From Community Plugins
+You can install TeXcore either directly from the official Obsidian Community Plugins store or manually by compiling the releases.
-1. Open Obsidian Settings โ **Community plugins**
-2. Click **Browse** and search for "TeXcore"
-3. Click **Install**, then **Enable**
+=== "Community Store (Recommended)"
+ 1. Navigate to ++ctrl+comma++ (or ++cmd+comma++ on macOS) to open **Obsidian Settings** and select **Community plugins**.
+ 2. Click **Browse**, search for `TeXcore`, and click **Install**.
+ 3. Once installed, click **Enable** to activate the plugin.
-### Manual Installation
+=== "Manual Installation"
+ 1. Download the latest `main.js`, `manifest.json`, and `styles.css` from the [Latest Releases](https://github.com/YouFoundJK/TeXcore/releases).
+ 2. Open your Obsidian Vault directory and navigate to the hidden directory: `.obsidian/plugins/`.
+ 3. Create a folder named `TeXcore` and paste the three downloaded files into it.
+ 4. Go to **Settings** โ **Community plugins** and toggle TeXcore to **Enable**.
-1. Download `main.js`, `manifest.json`, and `styles.css` from the [latest release](https://github.com/YouFoundJK/TeXcore/releases)
-2. Create folder: `/.obsidian/plugins/TeXcore/`
-3. Copy the downloaded files into this folder
-4. Restart Obsidian and enable the plugin
+---
-## Your First Equation
+## Step-by-Step Setup
-### Step 1: Create a Display Math Block
+Let's write, trigger, and link your first math reference.
-Write a display math block using `$$` delimiters:
+```mermaid
+graph TD
+ A[Create display math $$ block] --> B[Add % id: eq-name inside]
+ B --> C[Type \eqref autocomplete trigger]
+ C --> D[Select reference from popup]
+ D --> E[Link resolved with tag number]
+```
+### 1. Write the Math Block
+Open any note in your vault and create a standard display math block:
```latex
$$
\int_0^\infty e^{-x^2} dx = \frac{\sqrt{\pi}}{2}
$$
```
-### Step 2: Add an Equation ID
-
-Add a LaTeX comment with a unique ID on a new line before the closing `$$`:
-
+### 2. Add the Identifier
+To index the equation, insert a LaTeX comment (`% id: `) on the line immediately preceding the closing `$$` delimiter:
```latex
$$
\int_0^\infty e^{-x^2} dx = \frac{\sqrt{\pi}}{2}
% id: eq-gaussian
$$
```
+!!! tip "ID Format Constraints"
+ Your identifier must start with `eq-` followed by letters, numbers, or hyphens. Examples: `eq-gaussian`, `eq-step-1`, or `eq-ref-3`.
-!!! tip "ID Format"
- IDs must start with `eq-` followed by alphanumeric characters and hyphens. Examples: `eq-main`, `eq-theorem-1`, `eq-proof-step-2`
-
-### Step 3: Reference the Equation
-
-Type `\eqref` to trigger the autocomplete menu, then select your equation:
+### 3. Insert the Reference
+Type `\eqref` (the default trigger) in your text. An autocomplete dropdown listing all active note equations will pop up. Select `eq-gaussian` and press ++enter++ to insert `[[#^eq-gaussian]]`.
```markdown
-The Gaussian integral [[#^eq-gaussian]] is fundamental in probability theory.
+The integral [[#^eq-gaussian]] resolves to a constant.
```
-### Step 4: View the Result
+### 4. Observe the Rendered Result
+Obsidian will now display a right-aligned number tag `(1)` next to the equation, and the inline link will render as clickable text displaying `(1)`. Hovering over it displays a rich popup preview of the math block.
-The plugin automatically:
+---
-- Adds `\tag{1}` to number the equation
-- Renders the reference as a clickable link showing `(1)`
-- Shows a preview popup when you hover over the link
+## Interactive Command Palette Actions
-## Basic Workflow
+Press ++ctrl+p++ (or ++cmd+p++) to trigger the command palette. TeXcore exposes several key commands:
-```mermaid
-graph LR
- A[Write equation] --> B[Add % id: eq-xxx]
- B --> C[Type \eqref]
- C --> D[Select from suggestions]
- D --> E[Link inserted]
-```
+| Command | Action Description | Core Feature Link |
+| :--- | :--- | :--- |
+| `Insert display math` | Inserts a template display math block containing the `% id:` line. | [Equations](features/equations.md) |
+| `Search equations in active note` | Opens the search modal to browse all equation cache references. | [Search](features/search.md) |
+| `Fix callout equations in active note` | Fixes missing or misaligned callout markers on math blocks. | [Callouts](features/callout-support.md) |
+| `Export current file to PDF` | Opens the PDF compilation window with preview and print settings. | [PDF Export](features/pdf-export.md) |
+| `Insert LaTeX Snippet` | Displays saved snippets for fast editor insertion. | [Snippets](features/snippets.md) |
-## Commands
-
-Access these commands via the Command Palette (`Ctrl/Cmd + P`):
-
-| Command | Description |
-|---------|-------------|
-| **Insert display math** | Insert `$$...$$` block with ID template |
-| **Search equations in active note** | Open equation search modal |
-| **Fix callout equations in active note** | Fix indentation in callout math blocks |
-| **Export current file to PDF** | Open PDF export dialog |
-| **Insert LaTeX Snippet** | Insert a saved LaTeX snippet |
-
-## Next Steps
-
-- [Equation Numbering](features/equations.md) - Learn about numbering styles and sub-equations
-- [PDF Export](features/pdf-export.md) - Export notes to PDF with equations
-- [Settings Reference](configuration/settings.md) - Configure all plugin options
+For further customization, consult our [Settings Reference](configuration/settings.md) or explore the [Features Matrix Overview](features/index.md) to see what is possible.
diff --git a/docs/index.md b/docs/index.md
index e44aa5d..180b58f 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,75 +1,79 @@
-# TeXcore
+# TeXcore: LaTeX-Grade Referencing in Obsidian
-A minimalistic Obsidian plugin for automatic equation numbering, referencing with support for PDE exports and .
+Welcome to **TeXcore** (:material-sigma:), the ultimate Obsidian plugin designed to bridge the gap between markdown notes and publication-ready LaTeX documents. By introducing automated equation numbering, smart referencing, and advanced styling modules, TeXcore transforms your vault into a rich ecosystem for scientific drafting and study.
+[Getting Started Guide :octicons-arrow-right-24:](getting-started.md){: .md-button .md-button--primary }
+[Settings Reference :octicons-gear-24:](configuration/settings.md){: .md-button }
+---
-## Key Features
+## Capabilities Overview
+
+Our core features are organized into logical modules designed to enhance your equation workflows:
-- :material-numeric: **Automatic Numbering**
+- :material-numeric: **[Equation Numbering](features/equations.md)**
- ---
+ LaTeX-like automatic tag insertion with multi-style options (`arabic`, `roman`, `alph`) and multi-line sub-equation tracking.
- Equations are automatically numbered using LaTeX `\tag{}` commands with 5 numbering styles.
+- :material-link-variant: **[Smart Referencing](features/quick-preview.md)**
-- :material-link-variant: **Smart Referencing**
+ Autocompleted backlinks utilizing `[[#^eq-id]]` linking. Supports hover popups, click-navigation, and live synchronizations.
- ---
+- :material-file-pdf-box: **[PDF Export](features/pdf-export.md)**
- Reference equations using `[[#^eq-id]]` links with live preview and hover popups.
+ Interactive PDF compiling with side-by-side preview widgets, customizable page headers/footers, margins, and custom CSS snippet injection.
-- :material-file-pdf-box: **PDF Export**
+- :material-code-braces: **[LaTeX Snippets](features/snippets.md)**
- ---
+ Quick-insertion presets mapped to keyboard triggers, allowing faster math notation drafting without repetitive typing.
- Full-featured PDF export with live preview, customizable margins, headers, and CSS support.
+- :material-magnify: **[Equation Search](features/search.md)**
-- :material-code-braces: **LaTeX Snippets**
+ Note-wide lookup indexing that simplifies equation retrieval and reference updates through an intuitive, keyboard-first modal.
- ---
+- :material-card-text-outline: **[Callout Support](features/callout-support.md)**
- Create custom LaTeX snippets accessible via command palette.
+ Seamless integration with native callouts, preserving Markdown formatting rules while automatically indexing equations.
-- :material-magnify: **Equation Search**
+- :material-vector-square: **[TikZ Diagrams](features/tikz.md)**
- ---
-
- Search and insert equation references using fuzzy or simple search.
-
-- :material-card-text-outline: **Callout Support**
-
- ---
-
- Math blocks work inside Obsidian callouts with automatic indentation handling.
+ Real-time rendering of vector graphical TikZ scripts directly inside your notes, backed by an isolated component library.
-## Quick Example
+---
-Write your equation with an ID comment:
+## Quick Start Demonstration
-```latex
-$$
-E = mc^2
-% id: eq-einstein
-$$
-```
+Writing and referencing equations in TeXcore is extremely straightforward.
-Reference it anywhere in the same note:
+=== "1. Writing the Equation"
+ To set up a trackable equation, use standard display math delimiters `$$` and insert a LaTeX comment stating the identifier:
+
+ ```latex
+ $$
+ E = mc^2
+ % id: eq-einstein
+ $$
+ ```
-```markdown
-As shown in [[#^eq-einstein]], energy and mass are equivalent.
-```
+=== "2. Referencing the Equation"
+ Simply type your trigger (default: `\eqref`) to select the equation via autocompletion, inserting an Obsidian reference link:
+
+ ```markdown
+ As demonstrated by Einstein in [[#^eq-einstein]], mass and energy are equivalent.
+ ```
-The equation will be automatically numbered as `(1)` and the link will display as a clickable reference.
+=== "3. Rendered Output"
+ The equation will be automatically indexed, adding a right-aligned numbering tag. The reference link resolves to a neat hyperlink:
+
+ $$
+ E = mc^2 \tag{1}
+ $$
+
+ As demonstrated by Einstein in [(1)](#), mass and energy are equivalent.
-## Getting Started
-
-[:octicons-arrow-right-24: Installation Guide](getting-started.md)
-
-## Special Thanks to
-- [LaTeX-like Theorem & Equation Referencer](https://ryotaushio.github.io/obsidian-latex-theorem-equation-referencer/) by Ryota Ushio on which this is inspired.
-- PDF export motivated by [better-export-pdf](https://github.com/l1xnan/obsidian-better-export-pdf)
-- [obsidian-tikzjax](https://github.com/artisticat1/obsidian-tikzjax) by [artisticat1](https://github.com/artisticat1)
\ No newline at end of file
+!!! note "Smart Numbering"
+ By default, TeXcore employs **lazy numbering**. Equations are only assigned a tag when they are actively referenced elsewhere in your note. This ensures clean note margins and prevents cluttered layouts. Read more in the [Settings Reference](configuration/settings.md#number-only-referenced-equations).
\ No newline at end of file
diff --git a/esbuild.config.mjs b/esbuild.config.mjs
index 101ca0a..e0ebb6b 100644
--- a/esbuild.config.mjs
+++ b/esbuild.config.mjs
@@ -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}`)])
@@ -26,8 +26,7 @@ async function build() {
entryPoints: ['src/main.ts'],
bundle: true,
plugins: [
- inlineWorkerPlugin(),
- esbuildSvelte({ compilerOptions: { css: 'injected' } }),
+ inlineWorkerPlugin({ format: 'iife' }),
{
name: 'copy-to-dev-vault-plugin',
setup(build) {
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 618145e..a550884 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -112,7 +112,9 @@ export default tseslint.config(
// Type assertions
'@typescript-eslint/no-unnecessary-type-assertion': 'warn',
+ '@typescript-eslint/no-unsafe-argument': 'warn',
'@typescript-eslint/prefer-as-const': 'warn',
+ '@typescript-eslint/no-unsafe-return': 'warn',
// Code style
eqeqeq: ['error', 'always'], // Require === and !== instead of == and !=
diff --git a/manifest-beta.json b/manifest-beta.json
index 4d15b62..234d5de 100644
--- a/manifest-beta.json
+++ b/manifest-beta.json
@@ -1,9 +1,9 @@
{
"id": "texcore",
"name": "TeXcore",
- "version": "0.0.2",
+ "version": "0.0.3",
"minAppVersion": "1.3.5",
- "description": "Automatic equation numbering, Tex Diagrams rendering, and more.",
+ "description": "Automatic equation numbering, Tex Diagrams rendering, and more to transforms your vault into a rich ecosystem for scientific drafting and study.",
"author": "Jovi Koikkara",
"authorUrl": "https://github.com/YouFoundJK",
"fundingUrl": "https://github.com/YouFoundJK",
diff --git a/manifest.json b/manifest.json
index 4d15b62..93f5385 100644
--- a/manifest.json
+++ b/manifest.json
@@ -3,7 +3,7 @@
"name": "TeXcore",
"version": "0.0.2",
"minAppVersion": "1.3.5",
- "description": "Automatic equation numbering, Tex Diagrams rendering, and more.",
+ "description": "Automatic equation numbering, Tex Diagrams rendering, and more to transforms your vault into a rich ecosystem for scientific drafting and study.",
"author": "Jovi Koikkara",
"authorUrl": "https://github.com/YouFoundJK",
"fundingUrl": "https://github.com/YouFoundJK",
diff --git a/mkdocs.yml b/mkdocs.yml
index bba8e3d..0f93b78 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -112,6 +112,7 @@ nav:
- Home: index.md
- Getting Started: getting-started.md
- Features:
+ - Overview: features/index.md
- Equation Numbering: features/equations.md
- PDF Export: features/pdf-export.md
- LaTeX Snippets: features/snippets.md
@@ -120,4 +121,5 @@ nav:
- TikZ Diagrams: features/tikz.md
- Callout Support: features/callout-support.md
- Configuration:
+ - Overview: configuration/index.md
- Settings Reference: configuration/settings.md
diff --git a/package.json b/package.json
index 4474e30..fdde892 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "TeXcore",
"version": "0.0.1",
- "description": "Automatic equation numbering, Tex Diagrams rendering, and more.",
+ "description": "Automatic equation numbering, Tex Diagrams rendering, and more to transforms your vault into a rich ecosystem for scientific drafting and study.",
"main": "main.js",
"packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499",
"scripts": {
@@ -41,11 +41,9 @@
"@types/node": "^25.9.1",
"@typescript-eslint/eslint-plugin": "^8.60.1",
"@typescript-eslint/parser": "^8.60.1",
- "builtin-modules": "5.2.0",
"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 +56,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 +66,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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index dd543bb..e27513f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -20,9 +20,6 @@ importers:
'@lezer/common':
specifier: ^1.5.2
version: 1.5.2
- '@lucide/svelte':
- specifier: ^1.17.0
- version: 1.17.0(svelte@5.56.1(@typescript-eslint/types@8.60.1))
dvi2html:
specifier: 0.0.2
version: 0.0.2
@@ -66,9 +63,6 @@ importers:
'@typescript-eslint/parser':
specifier: ^8.60.1
version: 8.60.1(eslint@10.4.1)(typescript@6.0.3)
- builtin-modules:
- specifier: 5.2.0
- version: 5.2.0
deepmerge:
specifier: ^4.3.1
version: 4.3.1
@@ -78,9 +72,6 @@ importers:
esbuild:
specifier: ^0.28.0
version: 0.28.0
- esbuild-svelte:
- specifier: ^0.9.5
- version: 0.9.5(esbuild@0.28.0)(svelte@5.56.1(@typescript-eslint/types@8.60.1))
eslint:
specifier: ^10.4.1
version: 10.4.1
@@ -117,9 +108,6 @@ importers:
sass:
specifier: ^1.100.0
version: 1.100.0
- svelte:
- specifier: ^5.56.1
- version: 5.56.1(@typescript-eslint/types@8.60.1)
ts-jest:
specifier: ^29.4.11
version: 29.4.11(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7))(esbuild@0.28.0)(jest-util@30.4.1)(jest@30.4.2(@types/node@25.9.1))(typescript@6.0.3)
@@ -734,11 +722,6 @@ packages:
'@lezer/lr@1.4.10':
resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==}
- '@lucide/svelte@1.17.0':
- resolution: {integrity: sha512-q06YCFBN5CO8cd1ADmLCxWRVMVb7xxvHzqC0lvNoxGa+FLW6Cd1Y1AOxgbQk4Iwe68vkAMCRveNHint4WoaVKg==}
- peerDependencies:
- svelte: ^5
-
'@marijn/find-cluster-break@1.0.2':
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
@@ -872,11 +855,6 @@ packages:
'@sinonjs/fake-timers@15.4.0':
resolution: {integrity: sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==}
- '@sveltejs/acorn-typescript@1.0.10':
- resolution: {integrity: sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==}
- peerDependencies:
- acorn: ^8.9.0
-
'@tybys/wasm-util@0.10.2':
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
@@ -943,9 +921,6 @@ packages:
'@types/tough-cookie@4.0.5':
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
- '@types/trusted-types@2.0.7':
- resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
-
'@types/yargs-parser@21.0.3':
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
@@ -1191,10 +1166,6 @@ packages:
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
- aria-query@5.3.1:
- resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==}
- engines: {node: '>= 0.4'}
-
array-buffer-byte-length@1.0.2:
resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
engines: {node: '>= 0.4'}
@@ -1235,10 +1206,6 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
- axobject-query@4.1.0:
- resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
- engines: {node: '>= 0.4'}
-
babel-jest@30.4.1:
resolution: {integrity: sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -1304,10 +1271,6 @@ packages:
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
- builtin-modules@5.2.0:
- resolution: {integrity: sha512-02yxLeyxF4dNl6SlY6/5HfRSrSdZ/sCPoxy2kZNP5dZZX8LSAD9aE2gtJIUgWrsQTiMPl3mxESyrobSwvRGisQ==}
- engines: {node: '>=18.20'}
-
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
@@ -1358,10 +1321,6 @@ packages:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
- clsx@2.1.1:
- resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
- engines: {node: '>=6'}
-
co@4.6.0:
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
@@ -1463,9 +1422,6 @@ packages:
resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==}
engines: {node: '>=8'}
- devalue@5.8.1:
- resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==}
-
doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
@@ -1555,13 +1511,6 @@ packages:
esbuild-plugin-inline-worker@0.1.1:
resolution: {integrity: sha512-VmFqsQKxUlbM51C1y5bRiMeyc1x2yTdMXhKB6S//++g9aCBg8TfGsbKxl5ZDkCGquqLY+RmEk93TBNd0i35dPA==}
- esbuild-svelte@0.9.5:
- resolution: {integrity: sha512-16FUSj64aiS28CCxYeK3hVxCyU4PwGBSkeArawAtnWSV7l0Gc+frIOP88MNj8Q7tDAGyEJAHT6OpOvM24BG46w==}
- engines: {node: '>=18'}
- peerDependencies:
- esbuild: '>=0.17.0'
- svelte: '>=4.2.1 <6'
-
esbuild@0.28.0:
resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==}
engines: {node: '>=18'}
@@ -1700,9 +1649,6 @@ packages:
jiti:
optional: true
- esm-env@1.2.2:
- resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
-
espree@10.4.0:
resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1724,14 +1670,6 @@ packages:
resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
engines: {node: '>=0.10'}
- esrap@2.2.11:
- resolution: {integrity: sha512-gPdx+I+BjYEinNMQaBXFjbaJVyoPMU4ZODg5mE+M4DqVG9VusAVHHjcBX+zqyITlI0DIARwDMMzZwAWj36dRoQ==}
- peerDependencies:
- '@typescript-eslint/types': ^8.2.0
- peerDependenciesMeta:
- '@typescript-eslint/types':
- optional: true
-
esrecurse@4.3.0:
resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
engines: {node: '>=4.0'}
@@ -2085,9 +2023,6 @@ packages:
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
- is-reference@3.0.3:
- resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}
-
is-regex@1.2.1:
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
engines: {node: '>= 0.4'}
@@ -2379,9 +2314,6 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
- locate-character@3.0.0:
- resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
-
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
@@ -2407,9 +2339,6 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
- magic-string@0.30.21:
- resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
-
make-dir@3.1.0:
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
engines: {node: '>=8'}
@@ -2928,10 +2857,6 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
- svelte@5.56.1:
- resolution: {integrity: sha512-eArsJmvl3xZVuTYD852PzIEdg2wgDdIZ1NEsIPbzAukHwi284B18No4nK2rCO9AwsWUDza4Cjvmoa4HaojTl5g==}
- engines: {node: '>=18'}
-
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
@@ -3241,9 +3166,6 @@ packages:
resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
engines: {node: '>=12.20'}
- zimmerframe@1.1.4:
- resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
-
zod-validation-error@4.0.2:
resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
engines: {node: '>=18.0.0'}
@@ -3934,10 +3856,6 @@ snapshots:
dependencies:
'@lezer/common': 1.5.2
- '@lucide/svelte@1.17.0(svelte@5.56.1(@typescript-eslint/types@8.60.1))':
- dependencies:
- svelte: 5.56.1(@typescript-eslint/types@8.60.1)
-
'@marijn/find-cluster-break@1.0.2': {}
'@microsoft/eslint-plugin-sdl@1.1.0(eslint@10.4.1)':
@@ -4042,10 +3960,6 @@ snapshots:
dependencies:
'@sinonjs/commons': 3.0.1
- '@sveltejs/acorn-typescript@1.0.10(acorn@8.16.0)':
- dependencies:
- acorn: 8.16.0
-
'@tybys/wasm-util@0.10.2':
dependencies:
tslib: 2.8.1
@@ -4130,8 +4044,6 @@ snapshots:
'@types/tough-cookie@4.0.5': {}
- '@types/trusted-types@2.0.7': {}
-
'@types/yargs-parser@21.0.3': {}
'@types/yargs@17.0.35':
@@ -4394,8 +4306,6 @@ snapshots:
argparse@2.0.1: {}
- aria-query@5.3.1: {}
-
array-buffer-byte-length@1.0.2:
dependencies:
call-bound: 1.0.4
@@ -4469,8 +4379,6 @@ snapshots:
dependencies:
possible-typed-array-names: 1.1.0
- axobject-query@4.1.0: {}
-
babel-jest@30.4.1(@babel/core@7.29.7):
dependencies:
'@babel/core': 7.29.7
@@ -4562,8 +4470,6 @@ snapshots:
buffer-from@1.1.2: {}
- builtin-modules@5.2.0: {}
-
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
@@ -4610,8 +4516,6 @@ snapshots:
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
- clsx@2.1.1: {}
-
co@4.6.0: {}
collect-v8-coverage@1.0.3: {}
@@ -4697,8 +4601,6 @@ snapshots:
detect-newline@3.1.0: {}
- devalue@5.8.1: {}
-
doctrine@2.1.0:
dependencies:
esutils: 2.0.3
@@ -4854,12 +4756,6 @@ snapshots:
esbuild: 0.28.0
find-cache-dir: 3.3.2
- esbuild-svelte@0.9.5(esbuild@0.28.0)(svelte@5.56.1(@typescript-eslint/types@8.60.1)):
- dependencies:
- '@jridgewell/trace-mapping': 0.3.31
- esbuild: 0.28.0
- svelte: 5.56.1(@typescript-eslint/types@8.60.1)
-
esbuild@0.28.0:
optionalDependencies:
'@esbuild/aix-ppc64': 0.28.0
@@ -5109,8 +5005,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- esm-env@1.2.2: {}
-
espree@10.4.0:
dependencies:
acorn: 8.16.0
@@ -5135,12 +5029,6 @@ snapshots:
dependencies:
estraverse: 5.3.0
- esrap@2.2.11(@typescript-eslint/types@8.60.1):
- dependencies:
- '@jridgewell/sourcemap-codec': 1.5.5
- optionalDependencies:
- '@typescript-eslint/types': 8.60.1
-
esrecurse@4.3.0:
dependencies:
estraverse: 5.3.0
@@ -5506,10 +5394,6 @@ snapshots:
is-potential-custom-element-name@1.0.1: {}
- is-reference@3.0.3:
- dependencies:
- '@types/estree': 1.0.9
-
is-regex@1.2.1:
dependencies:
call-bound: 1.0.4
@@ -6017,8 +5901,6 @@ snapshots:
lines-and-columns@1.2.4: {}
- locate-character@3.0.0: {}
-
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
@@ -6041,10 +5923,6 @@ snapshots:
dependencies:
yallist: 3.1.1
- magic-string@0.30.21:
- dependencies:
- '@jridgewell/sourcemap-codec': 1.5.5
-
make-dir@3.1.0:
dependencies:
semver: 6.3.1
@@ -6583,27 +6461,6 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
- svelte@5.56.1(@typescript-eslint/types@8.60.1):
- dependencies:
- '@jridgewell/remapping': 2.3.5
- '@jridgewell/sourcemap-codec': 1.5.5
- '@sveltejs/acorn-typescript': 1.0.10(acorn@8.16.0)
- '@types/estree': 1.0.9
- '@types/trusted-types': 2.0.7
- acorn: 8.16.0
- aria-query: 5.3.1
- axobject-query: 4.1.0
- clsx: 2.1.1
- devalue: 5.8.1
- esm-env: 1.2.2
- esrap: 2.2.11(@typescript-eslint/types@8.60.1)
- is-reference: 3.0.3
- locate-character: 3.0.0
- magic-string: 0.30.21
- zimmerframe: 1.1.4
- transitivePeerDependencies:
- - '@typescript-eslint/types'
-
symbol-tree@3.2.4: {}
synckit@0.11.13:
@@ -6945,8 +6802,6 @@ snapshots:
yocto-queue@1.2.2: {}
- zimmerframe@1.1.4: {}
-
zod-validation-error@4.0.2(zod@4.4.3):
dependencies:
zod: 4.4.3
diff --git a/src/core/equations/live-preview-equations.ts b/src/core/equations/live-preview-equations.ts
index 3ac220b..5e79577 100644
--- a/src/core/equations/live-preview-equations.ts
+++ b/src/core/equations/live-preview-equations.ts
@@ -344,20 +344,29 @@ function createTagManagerPlugin(
);
}
-function createEquationField(plugin: LatexReferencer): StateField {
- return StateField.define({
- create(state) {
- return parseEquationInfo(state, plugin);
- },
- update(value, tr) {
- if (!tr.docChanged) return value;
- return parseEquationInfo(tr.state, plugin);
- }
- });
+let equationField: StateField | null = null;
+let activePlugin: LatexReferencer | null = null;
+
+function getEquationField(): StateField {
+ if (!equationField) {
+ equationField = StateField.define({
+ 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)];
}
diff --git a/src/core/linker/live-preview-link-renderer.ts b/src/core/linker/live-preview-link-renderer.ts
index 4ce807f..d055725 100644
--- a/src/core/linker/live-preview-link-renderer.ts
+++ b/src/core/linker/live-preview-link-renderer.ts
@@ -186,7 +186,11 @@ export const createLivePreviewLinkRendererPlugin = (plugin: LatexReferencer): Ex
this.decorations = builder.finish();
if (this.decorations.size > 0) {
- void finishRenderMath();
+ const MathJax = (window as typeof window & { MathJax?: { chtmlStylesheet?: unknown } })
+ .MathJax;
+ if (MathJax && typeof MathJax.chtmlStylesheet === 'function') {
+ void finishRenderMath();
+ }
}
}
},
diff --git a/src/features/export-pdf/Progress.svelte b/src/features/export-pdf/Progress.svelte
deleted file mode 100644
index 4f18f3e..0000000
--- a/src/features/export-pdf/Progress.svelte
+++ /dev/null
@@ -1,41 +0,0 @@
-
-
-
-
Rendering...
- {#each renderStates as item}
-
- {#if item.status}
-
- {:else}
-
- {/if}
- {item.filename}
-
- {/each}
-
-
-
diff --git a/src/features/export-pdf/Progress.ts b/src/features/export-pdf/Progress.ts
new file mode 100644
index 0000000..fcd2d98
--- /dev/null
+++ b/src/features/export-pdf/Progress.ts
@@ -0,0 +1,98 @@
+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 }) {
+ this.container = options.target.createDiv({ cls: 'progress' });
+ this.container.setCssStyles({ fontSize: '14px' });
+ this.container.createDiv({ text: 'Rendering...' });
+ }
+
+ initRenderStates(data: ParamType[]) {
+ 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.setCssStyles({ display: 'flex', alignItems: 'center', gap: '6px', marginTop: '4px' });
+
+ const iconSpan = itemDiv.createSpan({ cls: 'progress-icon' });
+ // Lucide-like loader SVG (with some basic style to rotate if desired)
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(
+ `
+
+
+
+
+
+
+
+
+ `,
+ 'image/svg+xml'
+ );
+ const svg = doc.documentElement;
+ iconSpan.appendChild(activeDocument.importNode(svg, true));
+
+ 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 (!activeDocument.getElementById('progress-spin-style')) {
+ const style = activeDocument.createElement('style');
+ style.id = 'progress-spin-style';
+ style.textContent = `
+ @keyframes spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+ }
+ `;
+ activeDocument.head?.appendChild(style);
+ }
+ }
+
+ updateRenderStates(i: number) {
+ 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) {
+ iconSpan.textContent = '';
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(
+ `
+
+
+ `,
+ 'image/svg+xml'
+ );
+ const svg = doc.documentElement;
+ iconSpan.appendChild(activeDocument.importNode(svg, true));
+ }
+ }
+ }
+ }
+
+ destroy() {
+ if (this.container) {
+ this.container.remove();
+ }
+ }
+}
diff --git a/src/features/tikz-editor/assets-manager.ts b/src/features/tikz-editor/assets-manager.ts
new file mode 100644
index 0000000..9fdb06d
--- /dev/null
+++ b/src/features/tikz-editor/assets-manager.ts
@@ -0,0 +1,204 @@
+import { ComponentTemplate, TikzPackage } from './types';
+
+export class AssetsManager {
+ private static corePackages: ComponentTemplate[] = [
+ {
+ name: 'Text',
+ type: 'text',
+ category: 'Basic',
+ svgMarkup: `Aa `,
+ tikzCommand: '\\node[font={fontSize}] at ({x}, {y}) {{label}};'
+ },
+ {
+ name: 'Wire',
+ type: 'wire',
+ category: 'Basic',
+ svgMarkup: ` `,
+ tikzCommand: '\\draw[line width=0.8pt] ({x}, {y}) -- ({x2}, {y2});'
+ },
+ {
+ name: 'End node',
+ type: 'component',
+ category: 'Basic',
+ svgMarkup: ` `,
+ tikzCommand: '\\fill ({x}, {y}) circle (2pt) node[anchor=west] {{label}};'
+ },
+ {
+ name: 'Filled node',
+ type: 'component',
+ category: 'Basic',
+ svgMarkup: ` `,
+ tikzCommand: '\\fill ({x}, {y}) circle (2pt);'
+ },
+ {
+ name: 'Open node',
+ type: 'component',
+ category: 'Basic',
+ svgMarkup: ` `,
+ tikzCommand: '\\draw ({x}, {y}) circle (2pt);'
+ },
+ {
+ name: 'Junction node',
+ type: 'component',
+ category: 'Basic',
+ svgMarkup: ` `,
+ tikzCommand: '\\fill ({x}, {y}) circle (2.5pt);'
+ },
+ {
+ name: 'Dashed Wire',
+ type: 'wire',
+ category: 'Basic',
+ svgMarkup: ` `,
+ tikzCommand: '\\draw[line width=0.8pt, dashed] ({x}, {y}) -- ({x2}, {y2});'
+ },
+ {
+ name: 'Arrow',
+ type: 'wire',
+ category: 'Basic',
+ svgMarkup: ` `,
+ tikzCommand: '\\draw[line width=0.8pt, -stealth] ({x}, {y}) -- ({x2}, {y2});'
+ },
+ {
+ name: 'Circle',
+ type: 'component',
+ category: 'Basic',
+ svgMarkup: ` `,
+ tikzCommand: '\\draw ({x}, {y}) circle (12pt);'
+ },
+ {
+ name: 'Filled Circle',
+ type: 'component',
+ category: 'Basic',
+ svgMarkup: ` `,
+ tikzCommand: '\\fill ({x}, {y}) circle (12pt);'
+ },
+ {
+ name: 'Rectangle',
+ type: 'component',
+ category: 'Basic',
+ svgMarkup: ` `,
+ tikzCommand: '\\draw ({x}, {y}) +(-0.4,-0.4) rectangle +(0.4,0.4);'
+ },
+ {
+ name: 'Triangle',
+ type: 'component',
+ category: 'Basic',
+ svgMarkup: ` `,
+ 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: ` `,
+ tikzCommand: '\\draw ({x}, {y}) to[R, l={label}] ({x2}, {y2});'
+ },
+ {
+ name: 'American Resistor',
+ type: 'wire',
+ category: 'Circuits',
+ svgMarkup: ` `,
+ tikzCommand: '\\draw ({x}, {y}) to[R, style=american, l={label}] ({x2}, {y2});'
+ },
+ {
+ name: 'Capacitor (IEC)',
+ type: 'wire',
+ category: 'Circuits',
+ svgMarkup: ` `,
+ tikzCommand: '\\draw ({x}, {y}) to[C, l={label}] ({x2}, {y2});'
+ },
+ {
+ name: 'Variable Capacitor',
+ type: 'wire',
+ category: 'Circuits',
+ svgMarkup: ` `,
+ tikzCommand: '\\draw ({x}, {y}) to[vC, l={label}] ({x2}, {y2});'
+ },
+ {
+ name: 'Polarized Capacitor',
+ type: 'wire',
+ category: 'Circuits',
+ svgMarkup: ` `,
+ tikzCommand: '\\draw ({x}, {y}) to[pC, l={label}] ({x2}, {y2});'
+ },
+ {
+ name: 'Inductor',
+ type: 'wire',
+ category: 'Circuits',
+ svgMarkup: ` `,
+ 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: ` `,
+ tikzCommand:
+ '\\node[and gate US, draw, logic gate inputs=nn] at ({x}, {y}) (and1) {{label}};'
+ },
+ {
+ name: 'OR Gate',
+ type: 'component',
+ category: 'Logic',
+ svgMarkup: ` `,
+ tikzCommand:
+ '\\node[or gate US, draw, logic gate inputs=nn] at ({x}, {y}) (or1) {{label}};'
+ },
+ {
+ name: 'NOT Gate',
+ type: 'component',
+ category: 'Logic',
+ svgMarkup: ` `,
+ 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 {
+ const pkg = this.registry.find(p => p.name === packageName);
+ if (!pkg) return false;
+
+ // Simulate package metadata and asset download latency
+ await new Promise(resolve => window.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 {
+ const pkg = this.registry.find(p => p.name === packageName);
+ if (!pkg) return false;
+ pkg.installed = false;
+ return true;
+ }
+}
diff --git a/src/features/tikz-editor/components/canvas-grid.ts b/src/features/tikz-editor/components/canvas-grid.ts
new file mode 100644
index 0000000..4f504f0
--- /dev/null
+++ b/src/features/tikz-editor/components/canvas-grid.ts
@@ -0,0 +1,810 @@
+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 dragInitialState: EditorElement[] = [];
+ private wireStart: { x: number; y: number } | null = null;
+ private wireCurrent: { x: number; y: number } | null = null;
+ private lassoStart: { x: number; y: number } | null = null;
+ private lassoCurrent: { 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 selectedVertices = this.context.getSelectedVertices();
+ const elements = this.context.getElements();
+
+ // Draw elements
+ elements.forEach(elem => {
+ if (elem.type !== 'wire' || elem.x2 === undefined || elem.y2 === undefined) return;
+
+ const group = activeDocument.createElementNS(svgNS, 'g');
+ group.setAttribute(
+ 'class',
+ `wire-group${selectedVertices.some(v => v.elementId === 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.setCssStyles({ 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',
+ selectedVertices.some(v => v.elementId === 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',
+ selectedVertices.some(v => v.elementId === 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',
+ selectedVertices.some(v => v.elementId === 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 = selectedVertices.some(v => v.elementId === elem.id)
+ ? 'var(--text-accent)'
+ : elem.style.color || 'var(--text-normal)';
+
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(elem.svgMarkup, 'image/svg+xml');
+ svgWrap.appendChild(activeDocument.importNode(doc.documentElement, true));
+ 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.setCssStyles({ overflow: 'visible' });
+
+ const div = activeDocument.createElement('div');
+ div.setCssStyles({
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ width: '100%',
+ height: '100%',
+ fontSize: `${elem.style.fontSize}px`,
+ 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 (selectedVertices.some(v => v.elementId === 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.setCssStyles({ 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.setCssStyles({ 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 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.setCssStyles({ 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.setCssStyles({ color: 'var(--text-accent)' });
+
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(activeTemplate.svgMarkup, 'image/svg+xml');
+ svgWrap.appendChild(activeDocument.importNode(doc.documentElement, true));
+ 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);
+ }
+ }
+
+ // Draw lasso
+ if (this.lassoStart && this.lassoCurrent) {
+ const rect = activeDocument.createElementNS(svgNS, 'rect');
+ const left = Math.min(this.lassoStart.x, this.lassoCurrent.x);
+ const right = Math.max(this.lassoStart.x, this.lassoCurrent.x);
+ const top = Math.min(this.lassoStart.y, this.lassoCurrent.y);
+ const bottom = Math.max(this.lassoStart.y, this.lassoCurrent.y);
+
+ rect.setAttribute('x', left.toString());
+ rect.setAttribute('y', top.toString());
+ rect.setAttribute('width', (right - left).toString());
+ rect.setAttribute('height', (bottom - top).toString());
+ rect.setAttribute('fill', 'var(--interactive-accent)');
+ rect.setAttribute('fill-opacity', '0.2');
+ rect.setAttribute('stroke', 'var(--interactive-accent)');
+ rect.setAttribute('stroke-width', '1');
+ rect.setAttribute('stroke-dasharray', '4,4');
+ this.wiresOverlayEl.appendChild(rect);
+ }
+ }
+
+ private renderPlacedElements() {
+ this.workspaceEl.querySelectorAll('.placed-element').forEach(el => el.remove());
+ const selectedVertices = this.context.getSelectedVertices();
+ 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${selectedVertices.some(v => v.elementId === 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}"`);
+ }
+
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(svg, 'image/svg+xml');
+ visual.appendChild(activeDocument.importNode(doc.documentElement, true));
+ 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();
+
+ if (e.button !== 0) return;
+
+ const coords = this.getCanvasCoords(e);
+
+ 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);
+
+ this.wireCurrent = mCoords;
+ this.renderWires();
+ }
+ };
+
+ const onMouseUp = (upEvent: MouseEvent) => {
+ 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: ` `,
+ 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: `Aa `,
+ 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.handleSelectVertices([]);
+
+ this.lassoStart = coords;
+ this.lassoCurrent = coords;
+
+ const onMouseMove = (moveEvent: MouseEvent) => {
+ const mCoords = this.getCanvasCoords(moveEvent);
+ this.lassoCurrent = mCoords;
+ this.renderWires();
+ };
+
+ const onMouseUp = () => {
+ if (this.lassoStart && this.lassoCurrent) {
+ this.applyLassoSelection();
+ }
+ this.lassoStart = null;
+ this.lassoCurrent = null;
+ this.renderWires();
+ activeDocument.removeEventListener('mousemove', onMouseMove);
+ activeDocument.removeEventListener('mouseup', onMouseUp);
+ };
+
+ activeDocument.addEventListener('mousemove', onMouseMove);
+ activeDocument.addEventListener('mouseup', onMouseUp);
+ }
+ }
+
+ private applyLassoSelection() {
+ if (!this.lassoStart || !this.lassoCurrent) return;
+
+ const left = Math.min(this.lassoStart.x, this.lassoCurrent.x);
+ const right = Math.max(this.lassoStart.x, this.lassoCurrent.x);
+ const top = Math.min(this.lassoStart.y, this.lassoCurrent.y);
+ const bottom = Math.max(this.lassoStart.y, this.lassoCurrent.y);
+
+ const isInside = (x: number, y: number) => {
+ return x >= left && x <= right && y >= top && y <= bottom;
+ };
+
+ const newSelection: import('../types').SelectedVertex[] = [];
+ this.context.getElements().forEach(elem => {
+ if (elem.type === 'wire' && elem.x2 !== undefined && elem.y2 !== undefined) {
+ if (isInside(elem.x, elem.y)) {
+ newSelection.push({ elementId: elem.id, vertex: 'start' });
+ }
+ if (isInside(elem.x2, elem.y2)) {
+ newSelection.push({ elementId: elem.id, vertex: 'end' });
+ }
+ } else {
+ if (isInside(elem.x, elem.y)) {
+ newSelection.push({ elementId: elem.id, vertex: 'center' });
+ }
+ }
+ });
+
+ this.context.handleSelectVertices(newSelection);
+ }
+
+ private handleElementMouseDown(e: MouseEvent, elem: EditorElement) {
+ const activeTool = this.context.getActiveTool();
+ e.stopPropagation();
+ if (e.button !== 0) return;
+
+ if (activeTool === 'erase') {
+ this.context.handleDeleteElement(elem.id);
+ return;
+ }
+
+ const clickedVertices: import('../types').SelectedVertex[] =
+ elem.type === 'wire'
+ ? [
+ { elementId: elem.id, vertex: 'start' as const },
+ { elementId: elem.id, vertex: 'end' as const }
+ ]
+ : [{ elementId: elem.id, vertex: 'center' as const }];
+
+ const selectedVertices = this.context.getSelectedVertices();
+ const isAlreadySelected = selectedVertices.some(v => v.elementId === elem.id);
+
+ if (!isAlreadySelected) {
+ this.context.handleSelectVertices(clickedVertices);
+ }
+
+ if (activeTool === 'select') {
+ this.dragStartCoords = { x: e.clientX, y: e.clientY };
+ // Copy initial state of all elements to calculate relative moves
+ this.dragInitialState = JSON.parse(
+ JSON.stringify(this.context.getElements())
+ ) as EditorElement[];
+
+ const onMouseMove = (moveEvent: MouseEvent) => {
+ if (!this.dragStartCoords) return;
+
+ const zoom = this.context.getZoom();
+ const dx = (moveEvent.clientX - this.dragStartCoords.x) / zoom;
+ const dy = (moveEvent.clientY - this.dragStartCoords.y) / zoom;
+
+ let newPrimaryX = elem.x + dx;
+ let newPrimaryY = elem.y + dy;
+
+ if (this.context.isSnapToGrid() && !moveEvent.ctrlKey) {
+ const gridSize = this.context.isHalfGrid()
+ ? this.context.PX_PER_UNIT / 2
+ : this.context.PX_PER_UNIT;
+ newPrimaryX =
+ Math.round((newPrimaryX - this.context.ORIGIN_X) / gridSize) * gridSize +
+ this.context.ORIGIN_X;
+ newPrimaryY =
+ Math.round((newPrimaryY - this.context.ORIGIN_Y) / gridSize) * gridSize +
+ this.context.ORIGIN_Y;
+ } else {
+ newPrimaryX = Math.round(newPrimaryX);
+ newPrimaryY = Math.round(newPrimaryY);
+ }
+
+ const snappedDx = newPrimaryX - elem.x;
+ const snappedDy = newPrimaryY - elem.y;
+
+ const currentSelVertices = this.context.getSelectedVertices();
+
+ const newElements = this.dragInitialState.map(initialEl => {
+ const el = { ...initialEl };
+
+ if (el.type === 'wire' && el.x2 !== undefined && el.y2 !== undefined) {
+ const hasStart = currentSelVertices.some(
+ v => v.elementId === el.id && v.vertex === 'start'
+ );
+ const hasEnd = currentSelVertices.some(
+ v => v.elementId === el.id && v.vertex === 'end'
+ );
+ if (hasStart) {
+ el.x = initialEl.x + snappedDx;
+ el.y = initialEl.y + snappedDy;
+ }
+ if (hasEnd) {
+ el.x2 = (initialEl.x2 ?? 0) + snappedDx;
+ el.y2 = (initialEl.y2 ?? 0) + snappedDy;
+ }
+ } else {
+ const hasCenter = currentSelVertices.some(
+ v => v.elementId === el.id && v.vertex === 'center'
+ );
+ if (hasCenter) {
+ el.x = initialEl.x + snappedDx;
+ el.y = initialEl.y + snappedDy;
+ }
+ }
+ return el;
+ });
+
+ this.context.setElements(newElements);
+ this.context.renderCanvas();
+ };
+
+ const onMouseUp = () => {
+ this.context.saveHistoryState();
+ this.dragStartCoords = null;
+ this.dragInitialState = [];
+ 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 ?? 0;
+ let newY2 = elem.y2 ?? 0;
+
+ 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);
+ }
+}
diff --git a/src/features/tikz-editor/components/left-sidebar.ts b/src/features/tikz-editor/components/left-sidebar.ts
new file mode 100644
index 0000000..0f94d0f
--- /dev/null
+++ b/src/features/tikz-editor/components/left-sidebar.ts
@@ -0,0 +1,324 @@
+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 });
+
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(iconHtml, 'image/svg+xml');
+ btn.appendChild(activeDocument.importNode(doc.documentElement, true));
+
+ if (this.context.getActiveTool() === tool) btn.addClass('active');
+ btn.onclick = () => {
+ this.context.handleSelectTool(tool);
+ };
+ };
+
+ addToolBtn(
+ 'select',
+ 'Select / Move [V]',
+ ` `
+ );
+
+ // Wire button
+ const wireBtn = toolbar.createEl('button', { cls: 'tool-btn', title: 'Wire [w]' });
+
+ wireBtn.createDiv({ cls: 'wire-icon' });
+ if (this.context.getActiveTool() === 'wire') wireBtn.addClass('active');
+ wireBtn.onclick = () => {
+ this.context.handleSelectTool('wire');
+ };
+
+ addToolBtn(
+ 'text',
+ 'Text [T]',
+ ` `
+ );
+ addToolBtn(
+ 'erase',
+ 'Eraser [E]',
+ ` `
+ );
+
+ toolbar.createDiv({ cls: 'divider' });
+
+ // Snap to grid (Full)
+ const snapBtn = toolbar.createEl('button', {
+ cls: 'tool-btn',
+ title: 'Snap to grid (full) [g]'
+ });
+
+ const snapParser = new DOMParser();
+ const snapDoc = snapParser.parseFromString(
+ ` `,
+ 'image/svg+xml'
+ );
+ snapBtn.appendChild(activeDocument.importNode(snapDoc.documentElement, true));
+
+ if (this.context.isSnapToGrid() && !this.context.isHalfGrid()) snapBtn.addClass('active');
+ snapBtn.onclick = () => {
+ 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 = () => {
+ this.context.setSnappingMode('half');
+ };
+
+ // Unsnapped / Free movement
+ const unsnapBtn = toolbar.createEl('button', {
+ cls: 'tool-btn',
+ title: 'Unsnapped (free movement) [u]'
+ });
+
+ const unsnapParser = new DOMParser();
+ const unsnapDoc = unsnapParser.parseFromString(
+ ` `,
+ 'image/svg+xml'
+ );
+ unsnapBtn.appendChild(activeDocument.importNode(unsnapDoc.documentElement, true));
+
+ if (!this.context.isSnapToGrid()) unsnapBtn.addClass('active');
+ unsnapBtn.onclick = () => {
+ this.context.setSnappingMode('none');
+ };
+
+ // 2. Search
+ const searchBox = this.containerEl.createDiv({ cls: 'search-box' });
+
+ const searchIconSpan = searchBox.createEl('span', { cls: 'search-icon' });
+ const searchParser = new DOMParser();
+ const searchDoc = searchParser.parseFromString(
+ ` `,
+ 'image/svg+xml'
+ );
+ searchIconSpan.appendChild(activeDocument.importNode(searchDoc.documentElement, true));
+ 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' });
+
+ const pkgTitleSpan = pkgTitle.createSpan();
+ const pkgParser = new DOMParser();
+ const pkgDoc = pkgParser.parseFromString(
+ ` `,
+ 'image/svg+xml'
+ );
+ pkgTitleSpan.appendChild(activeDocument.importNode(pkgDoc.documentElement, true));
+ pkgTitle.createSpan({ text: ' Packages' });
+
+ const addPkgBtn = pkgHeader.createEl('button', {
+ cls: 'add-pkg-btn',
+ title: 'Manage packages'
+ });
+
+ const addPkgParser = new DOMParser();
+ const addPkgDoc = addPkgParser.parseFromString(
+ ` `,
+ 'image/svg+xml'
+ );
+ addPkgBtn.appendChild(activeDocument.importNode(addPkgDoc.documentElement, true));
+ addPkgBtn.onclick = () => {
+ 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.textContent = 'ร';
+ closePopover.onclick = () => {
+ 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;
+ const spinSpan = btn.createSpan({ cls: 'spin' });
+ const loaderParser = new DOMParser();
+ const loaderDoc = loaderParser.parseFromString(
+ ` `,
+ 'image/svg+xml'
+ );
+ spinSpan.appendChild(activeDocument.importNode(loaderDoc.documentElement, true));
+ } 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();
+ 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'
+ });
+ const pinParser = new DOMParser();
+ const pinDoc = pinParser.parseFromString(
+ ` `,
+ 'image/svg+xml'
+ );
+ pinBtn.appendChild(activeDocument.importNode(pinDoc.documentElement, true));
+ pinBtn.onclick = e => {
+ e.stopPropagation();
+ 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' });
+
+ const compSvgParser = new DOMParser();
+ const compSvgDoc = compSvgParser.parseFromString(comp.svgMarkup, 'image/svg+xml');
+ svgContainer.appendChild(activeDocument.importNode(compSvgDoc.documentElement, true));
+ card.createEl('span', { cls: 'comp-label', text: comp.name });
+
+ card.onclick = () => {
+ 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');
+ }
+}
diff --git a/src/features/tikz-editor/components/right-sidebar.ts b/src/features/tikz-editor/components/right-sidebar.ts
new file mode 100644
index 0000000..d9c9601
--- /dev/null
+++ b/src/features/tikz-editor/components/right-sidebar.ts
@@ -0,0 +1,460 @@
+import { type TikzEditorContext } 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'
+ });
+
+ const editSpan = editTabBtn.createSpan({ cls: 'tab-icon' });
+ const editParser = new DOMParser();
+ const editDoc = editParser.parseFromString(
+ ` `,
+ 'image/svg+xml'
+ );
+ editSpan.appendChild(activeDocument.importNode(editDoc.documentElement, true));
+ editTabBtn.onclick = () => {
+ this.context.switchTab('edit');
+ };
+
+ const codeTabBtn = tabsHeader.createEl('button', {
+ cls: `tab-btn${activeTab === 'code' ? ' active' : ''}`,
+ text: 'Code'
+ });
+
+ const codeSpan = codeTabBtn.createSpan({ cls: 'tab-icon' });
+ const codeParser = new DOMParser();
+ const codeDoc = codeParser.parseFromString(
+ ` `,
+ 'image/svg+xml'
+ );
+ codeSpan.appendChild(activeDocument.importNode(codeDoc.documentElement, true));
+ codeTabBtn.onclick = () => {
+ this.context.switchTab('code');
+ };
+
+ const tabContent = this.containerEl.createDiv({ cls: 'tab-content' });
+
+ if (activeTab === 'edit') {
+ const selectedVertices = this.context.getSelectedVertices();
+ const uniqueIds = Array.from(new Set(selectedVertices.map(v => v.elementId)));
+ const selectedElements = this.context.getElements().filter(el => uniqueIds.includes(el.id));
+
+ if (selectedElements.length === 1) {
+ const selectedElement = selectedElements[0];
+ const editPanel = tabContent.createDiv({ cls: 'edit-panel' });
+
+ const titleDiv = editPanel.createDiv({ cls: 'section-title' });
+ titleDiv.createSpan({ text: 'Edit component: ' });
+ titleDiv.createSpan({ cls: 'comp-name', text: selectedElement.name });
+
+ // 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 = () => {
+ 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;
+ 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 = () => {
+ 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 = () => {
+ 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 = () => {
+ 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 = () => {
+ 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;
+ 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' });
+
+ const deleteParser = new DOMParser();
+ const deleteDoc = deleteParser.parseFromString(
+ ` `,
+ 'image/svg+xml'
+ );
+ deleteBtn.appendChild(activeDocument.importNode(deleteDoc.documentElement, true));
+ deleteBtn.createSpan({ text: ' Delete Element' });
+ deleteBtn.onclick = () => {
+ this.context.handleDeleteElement(selectedElement.id);
+ };
+ } else if (selectedElements.length > 1) {
+ const editPanel = tabContent.createDiv({ cls: 'edit-panel' });
+
+ const titleDiv = editPanel.createDiv({ cls: 'section-title' });
+ titleDiv.createSpan({ text: 'Edit multiple components ' });
+ titleDiv.createSpan({ cls: 'comp-name', text: `(${selectedElements.length})` });
+
+ const firstElement = selectedElements[0];
+
+ // Shared Style Properties (Color, Thickness, Styles)
+ const fontColorGroup = editPanel.createDiv({ cls: 'control-group' });
+ fontColorGroup.createEl('label', { text: 'Shared styles' });
+ 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 (firstElement.style.fontSize === pt) opt.selected = true;
+ });
+ fontSizeSelect.onchange = () => {
+ const size = parseInt(fontSizeSelect.value) || 12;
+ const updated = selectedElements.map(el => ({
+ ...el,
+ style: { ...el.style, fontSize: size }
+ }));
+ this.context.handleUpdateElements(updated);
+ };
+
+ const colorPickerWrap = fontColorRow.createDiv({ cls: 'color-picker-wrap' });
+ const colorInput = colorPickerWrap.createEl('input', {
+ type: 'color',
+ value: /^#[0-9a-f]{6}$/i.test(firstElement.style.color)
+ ? firstElement.style.color
+ : '#f8e7ad'
+ });
+ colorInput.oninput = () => {
+ const updated = selectedElements.map(el => ({
+ ...el,
+ style: { ...el.style, color: colorInput.value }
+ }));
+ this.context.handleUpdateElements(updated);
+ };
+
+ const thicknessGroup = editPanel.createDiv({ cls: 'control-group' });
+ const currentThickness = firstElement.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)`;
+ const updated = selectedElements.map(el => ({
+ ...el,
+ style: { ...el.style, thickness: val }
+ }));
+ this.context.handleUpdateElements(updated);
+ };
+
+ 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: firstElement.style.bold ? 'active' : '',
+ text: 'B',
+ title: 'Bold'
+ });
+ boldBtn.onclick = () => {
+ const val = !firstElement.style.bold;
+ const updated = selectedElements.map(el => ({
+ ...el,
+ style: { ...el.style, bold: val }
+ }));
+ this.context.handleUpdateElements(updated);
+ };
+
+ const italicBtn = styleBtnsRow.createEl('button', {
+ cls: firstElement.style.italic ? 'active' : '',
+ text: 'I',
+ title: 'Italic'
+ });
+ italicBtn.onclick = () => {
+ const val = !firstElement.style.italic;
+ const updated = selectedElements.map(el => ({
+ ...el,
+ style: { ...el.style, italic: val }
+ }));
+ this.context.handleUpdateElements(updated);
+ };
+
+ const mathBtn = styleBtnsRow.createEl('button', {
+ cls: firstElement.style.math ? 'active' : '',
+ text: '$',
+ title: 'Math formula ($...$)'
+ });
+ mathBtn.onclick = () => {
+ const val = !firstElement.style.math;
+ const updated = selectedElements.map(el => ({
+ ...el,
+ style: { ...el.style, math: val }
+ }));
+ this.context.handleUpdateElements(updated);
+ };
+
+ // Delete elements
+ const deleteWrap = editPanel.createDiv({ cls: 'delete-btn-wrap' });
+ const deleteBtn = deleteWrap.createEl('button', { cls: 'delete-btn' });
+
+ const deleteParser = new DOMParser();
+ const deleteDoc = deleteParser.parseFromString(
+ ` `,
+ 'image/svg+xml'
+ );
+ deleteBtn.appendChild(activeDocument.importNode(deleteDoc.documentElement, true));
+ deleteBtn.createSpan({ text: ' Delete All Selected' });
+ deleteBtn.onclick = () => {
+ selectedElements.forEach(el => this.context.handleDeleteElement(el.id));
+ };
+ } else {
+ const emptyState = tabContent.createDiv({ cls: 'empty-state' });
+ emptyState.createEl('p', {
+ text: 'Select a component on the canvas to configure its styles and properties.'
+ });
+ }
+ } 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 = () => {
+ 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' });
+
+ const copyParser = new DOMParser();
+ const copyDoc = copyParser.parseFromString(
+ ` `,
+ 'image/svg+xml'
+ );
+ copyBtn.appendChild(activeDocument.importNode(copyDoc.documentElement, true));
+ copyBtn.createSpan({ text: ' Copy Code' });
+ copyBtn.onclick = () => {
+ if (this.context.isCodeDirty()) {
+ void navigator.clipboard.writeText(this.context.getEditableCode());
+ } else {
+ this.context.handleCopyCode();
+ }
+ };
+
+ // Update button is now in the header next to the close button
+ }
+ }
+}
diff --git a/src/features/tikz-editor/tikz-editor-modal.ts b/src/features/tikz-editor/tikz-editor-modal.ts
new file mode 100644
index 0000000..3ec5a53
--- /dev/null
+++ b/src/features/tikz-editor/tikz-editor-modal.ts
@@ -0,0 +1,817 @@
+import { App, Modal } from 'obsidian';
+import LatexReferencer from 'main';
+import { AssetsManager } from './assets-manager';
+import {
+ type EditorElement,
+ type ComponentTemplate,
+ type TikzEditorContext,
+ type TikzPackage,
+ type SelectedVertex
+} from './types';
+import { showNotice, setCssProps } 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 selectedVertices: SelectedVertex[] = [];
+ 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;
+ }
+ getSelectedVertices() {
+ return this.selectedVertices;
+ }
+ setSelectedVertices(vertices: SelectedVertex[]) {
+ this.selectedVertices = vertices;
+ }
+ 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.selectedVertices = [];
+ this.renderCanvas();
+ this.renderRightSidebar();
+ this.updateHistoryButtons();
+ }
+ }
+
+ handleRedo() {
+ const next = this.historyManager.redo();
+ if (next !== null) {
+ this.elements = next;
+ this.selectedVertices = [];
+ 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();
+ }
+
+ handleSelectVertices(vertices: SelectedVertex[]) {
+ this.selectedVertices = vertices;
+ if (vertices.length > 0) {
+ this.activeTool = 'select';
+ this.activeTemplate = null;
+ this.leftSidebar.updateToolbarClasses();
+ }
+ this.renderCanvas();
+ this.renderRightSidebar();
+ }
+
+ handleAddElement(elem: Omit) {
+ const newId = this.createId();
+ const newElem: EditorElement = {
+ ...elem,
+ id: newId
+ };
+ this.elements = [...this.elements, newElem];
+ this.selectedVertices =
+ newElem.type === 'wire'
+ ? [
+ { elementId: newId, vertex: 'start' },
+ { elementId: newId, vertex: 'end' }
+ ]
+ : [{ elementId: newId, vertex: 'center' }];
+
+ 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();
+ }
+
+ handleUpdateElements(updatedElements: EditorElement[], saveHistory = true) {
+ const updateMap = new Map(updatedElements.map(el => [el.id, el]));
+ this.elements = this.elements.map(el => {
+ const updated = updateMap.get(el.id);
+ return updated ? updated : el;
+ });
+ if (saveHistory) {
+ this.saveHistoryState();
+ }
+ this.renderCanvas();
+ }
+
+ handleDeleteElement(id: string) {
+ this.elements = this.elements.filter(el => el.id !== id);
+ this.selectedVertices = this.selectedVertices.filter(v => v.elementId !== id);
+ this.saveHistoryState();
+ this.renderCanvas();
+ this.renderRightSidebar();
+ }
+
+ handleSelectTool(tool: 'select' | 'wire' | 'text' | 'erase') {
+ this.activeTool = tool;
+ this.activeTemplate = null;
+ this.selectedVertices = [];
+ this.leftSidebar.updateToolbarClasses();
+ this.renderLeftSidebar();
+ this.renderCanvas();
+ this.renderRightSidebar();
+ }
+
+ handleSelectTemplate(template: ComponentTemplate) {
+ this.activeTemplate = template;
+ this.selectedVertices = [];
+ 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() {
+ void 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) {
+ this.installingPackage = pkgName;
+ this.renderLeftSidebar();
+ try {
+ const success = await AssetsManager.installPackage(pkgName);
+ 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) {
+ try {
+ const success = await AssetsManager.uninstallPackage(pkgName);
+ 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') {
+ 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();
+
+ // Set custom CSS classes and sizes
+ this.modalEl.addClass('tikz-editor-modal');
+
+ setCssProps(containerEl, {
+ '--dialog-width': '95vw',
+ '--dialog-height': '90vh'
+ });
+
+ // Title of the modal
+ this.titleEl.setText('TikZ graphical editor');
+ this.titleEl.setCssStyles({
+ borderBottom: '1px solid var(--border-color)',
+ paddingBottom: '10px',
+ marginBottom: '0'
+ });
+
+ if (this.onSaveCallback) {
+ const headerBtn = this.modalEl.createEl('button', {
+ cls: 'tikz-header-save-btn',
+ text: 'Update block'
+ });
+ headerBtn.onclick = () => {
+ 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');
+ if (closeBtn) {
+ closeBtn.addEventListener('click', e => {
+ 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]) as {
+ elements?: EditorElement[];
+ pictureOptions?: string;
+ };
+ if (Array.isArray(parsed.elements)) {
+ this.elements = parsed.elements;
+ this.pictureOptions = parsed.pictureOptions ?? '';
+ }
+ } catch (e) {
+ console.warn('Failed to parse visual state, using fallback parser:', e);
+ }
+ }
+
+ if (this.elements.length === 0) {
+ const parsedResult = this.codec.parse(this.initialSource);
+ this.elements = parsedResult.elements;
+ this.pictureOptions = parsedResult.pictureOptions;
+ }
+ }
+
+ // 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 + -]' });
+
+ const zoomOutParser = new DOMParser();
+ const zoomOutDoc = zoomOutParser.parseFromString(
+ ` `,
+ 'image/svg+xml'
+ );
+ zoomOutBtn.appendChild(activeDocument.importNode(zoomOutDoc.documentElement, true));
+ 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 + +]' });
+
+ const zoomInParser = new DOMParser();
+ const zoomInDoc = zoomInParser.parseFromString(
+ ` `,
+ 'image/svg+xml'
+ );
+ zoomInBtn.appendChild(activeDocument.importNode(zoomInDoc.documentElement, true));
+ 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;
+
+ const undoParser = new DOMParser();
+ const undoDoc = undoParser.parseFromString(
+ ` `,
+ 'image/svg+xml'
+ );
+ undoBtn.appendChild(activeDocument.importNode(undoDoc.documentElement, true));
+ undoBtn.onclick = () => this.handleUndo();
+
+ const redoBtn = controls.createEl('button', { title: 'Redo [Ctrl + Y]' });
+ redoBtn.disabled = true;
+
+ const redoParser = new DOMParser();
+ const redoDoc = redoParser.parseFromString(
+ ` `,
+ 'image/svg+xml'
+ );
+ redoBtn.appendChild(activeDocument.importNode(redoDoc.documentElement, true));
+ redoBtn.onclick = () => this.handleRedo();
+
+ // Workspace
+ this.canvasWorkspaceEl = this.canvasContainerEl.createDiv({ cls: 'canvas-workspace' });
+ this.canvasWorkspaceEl.setCssStyles({ transformOrigin: '0 0' });
+ this.canvasWorkspaceEl.style.transform = `scale(${this.zoom})`;
+
+ // Grid lines
+ this.canvasWorkspaceEl.createDiv({ cls: 'grid-background' });
+
+ // 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');
+ 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 }
+ );
+ }
+}
diff --git a/src/features/tikz-editor/types.ts b/src/features/tikz-editor/types.ts
new file mode 100644
index 0000000..1d40912
--- /dev/null
+++ b/src/features/tikz-editor/types.ts
@@ -0,0 +1,126 @@
+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 SelectedVertex {
+ elementId: string;
+ vertex: 'center' | 'start' | 'end';
+}
+
+export interface TikzEditorContext {
+ // State getters
+ getElements(): EditorElement[];
+ setElements(elements: EditorElement[]): void;
+ getActiveTool(): 'select' | 'wire' | 'text' | 'erase';
+ getActiveTemplate(): ComponentTemplate | null;
+ getSelectedVertices(): SelectedVertex[];
+ setSelectedVertices(vertices: SelectedVertex[]): void;
+ 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;
+ handleSelectVertices(vertices: SelectedVertex[]): void;
+ handleAddElement(elem: Omit): void;
+ handleUpdateElementPosition(
+ id: string,
+ x: number,
+ y: number,
+ x2?: number,
+ y2?: number,
+ saveHistory?: boolean
+ ): void;
+ handleUpdateElement(updated: EditorElement, saveHistory?: boolean): void;
+ handleUpdateElements(updatedElements: 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;
+ handleUninstallPackage(pkgName: string): Promise;
+
+ // 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;
+}
diff --git a/src/features/tikz-editor/utils/history-manager.ts b/src/features/tikz-editor/utils/history-manager.ts
new file mode 100644
index 0000000..8b979ea
--- /dev/null
+++ b/src/features/tikz-editor/utils/history-manager.ts
@@ -0,0 +1,59 @@
+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) as EditorElement[]);
+ if (this.historyStack.length > this.maxDepth) {
+ this.historyStack.shift();
+ }
+ this.redoStack = [];
+ }
+
+ undo(): EditorElement[] | null {
+ if (this.historyStack.length > 1) {
+ const popped = this.historyStack.pop();
+ if (popped !== undefined) {
+ this.redoStack.push(popped);
+ }
+ return JSON.parse(
+ JSON.stringify(this.historyStack[this.historyStack.length - 1])
+ ) as EditorElement[];
+ }
+ return null;
+ }
+
+ redo(): EditorElement[] | null {
+ if (this.redoStack.length > 0) {
+ const next = this.redoStack.pop();
+ if (next !== undefined) {
+ this.historyStack.push(next);
+ return JSON.parse(JSON.stringify(next)) as EditorElement[];
+ }
+ }
+ return null;
+ }
+
+ canUndo(): boolean {
+ return this.historyStack.length > 1;
+ }
+
+ canRedo(): boolean {
+ return this.redoStack.length > 0;
+ }
+
+ clear() {
+ this.historyStack = [];
+ this.redoStack = [];
+ }
+}
diff --git a/src/features/tikz-editor/utils/tikz-codec.ts b/src/features/tikz-editor/utils/tikz-codec.ts
new file mode 100644
index 0000000..14ecb15
--- /dev/null
+++ b/src/features/tikz-editor/utils/tikz-codec.ts
@@ -0,0 +1,370 @@
+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');
+ if (!wire) {
+ throw new Error("Core component 'Wire' not found");
+ }
+ 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);
+ if (!template) {
+ throw new Error(`Core component '${name}' not found`);
+ }
+ 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');
+ if (!template) {
+ throw new Error("Core component 'Text' not found");
+ }
+ 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';
+ 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 wireTemplate = templates.find(t => t.name === 'Wire');
+ if (!wireTemplate) {
+ throw new Error("Core component 'Wire' not found");
+ }
+ const template =
+ templates.find(t => t.tikzCommand.includes(`to[${circuitMatch[2]}`)) ?? wireTemplate;
+ 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');
+ if (!template) {
+ throw new Error("Core component 'Rectangle' not found");
+ }
+ 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');
+ if (!template) {
+ throw new Error("Core component 'Triangle' not found");
+ }
+ parsedElements.push({
+ id: this.createId(),
+ type: 'component',
+ name: 'Triangle',
+ x: this.toCanvasX(point.x),
+ y: this.toCanvasY(point.y),
+ label: '',
+ rotation: 0,
+ style: { ...this.defaultStyle },
+ svgMarkup: template.svgMarkup,
+ tikzCommand: template.tikzCommand
+ });
+ }
+ continue;
+ }
+
+ 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);
+ if (!template) {
+ throw new Error(`Core component '${wireName}' not found`);
+ }
+
+ parsedElements.push({
+ id: this.createId(),
+ type: 'wire',
+ name: wireName,
+ x: this.toCanvasX(startX),
+ y: this.toCanvasY(startY),
+ x2: this.toCanvasX(endX),
+ y2: this.toCanvasY(endY),
+ label: '',
+ rotation: 0,
+ style: { ...this.defaultStyle },
+ svgMarkup: template.svgMarkup,
+ tikzCommand: template.tikzCommand
+ });
+ }
+ }
+ }
+
+ 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;
+ }
+}
diff --git a/src/features/tikz/live-preview-overlay.ts b/src/features/tikz/live-preview-overlay.ts
index eba0bf9..9a79141 100644
--- a/src/features/tikz/live-preview-overlay.ts
+++ b/src/features/tikz/live-preview-overlay.ts
@@ -2,6 +2,7 @@ import { Extension, Prec, EditorState } from '@codemirror/state';
import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view';
import { showNotice } from 'utils/obsidian';
import LatexReferencer from '../../main';
+import { TikzEditorModal } from '../tikz-editor/tikz-editor-modal';
interface CleanableDiv extends HTMLDivElement {
_cleanup?: () => void;
@@ -95,6 +96,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 +126,63 @@ class TikzLivePreviewOverlay {
this.exportSvg();
};
- handleEl.appendChild(exportBtn);
+ const pencilBtn = doc.createElement('button');
+ const pencilParser = new DOMParser();
+ const pencilDoc = pencilParser.parseFromString(
+ ` `,
+ 'image/svg+xml'
+ );
+ pencilBtn.appendChild(activeDocument.importNode(pencilDoc.documentElement, true));
+ 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();
+
+ 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 +329,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 +412,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;
}
diff --git a/src/features/tikz/renderer.ts b/src/features/tikz/renderer.ts
index d881f37..9ab7e18 100644
--- a/src/features/tikz/renderer.ts
+++ b/src/features/tikz/renderer.ts
@@ -7,10 +7,16 @@ export class TikzRenderer {
private tikzjaxCss: string = '';
private isLoaded: boolean = false;
+ private renderCache = new Map();
+
constructor(public plugin: LatexReferencer) {
this.loader = new TikzJaxLoader(this.plugin);
}
+ public clearCache() {
+ this.renderCache.clear();
+ }
+
async onLoad() {
if (!this.plugin.settings.enableTikzjax) {
return;
@@ -61,12 +67,18 @@ export class TikzRenderer {
this.removeCssAllWindows();
}
- /**
- * Renders the TikZ source string directly to an SVG element
- */
public async render(source: string): Promise {
const code = this.tidyTikzSource(source);
+ // Check cache first
+ const cachedHtml = this.renderCache.get(code);
+ if (cachedHtml !== undefined) {
+ const domParser = new DOMParser();
+ const doc = domParser.parseFromString(cachedHtml, 'text/html');
+ const svg = doc.querySelector('svg') as unknown as SVGElement;
+ return svg;
+ }
+
// Lazy-load tikzjax.css if not loaded yet
if (!this.tikzjaxCss) {
const adapter = this.plugin.app.vault.adapter;
@@ -90,6 +102,10 @@ export class TikzRenderer {
const svg = await this.loader.render(code);
this.postProcessSvg(svg);
+
+ // Save outerHTML of post-processed SVG to cache
+ this.renderCache.set(code, svg.outerHTML);
+
return svg;
}
@@ -113,8 +129,8 @@ export class TikzRenderer {
display: flex;
justify-content: center;
align-items: center;
- padding: 1rem 0;
- margin: 1.5em 0;
+ padding: 0;
+ margin: 0.3em 0;
overflow-x: auto;
background-color: transparent;
}
diff --git a/src/features/tikz/row-layout.ts b/src/features/tikz/row-layout.ts
index fa30443..9d98215 100644
--- a/src/features/tikz/row-layout.ts
+++ b/src/features/tikz/row-layout.ts
@@ -169,7 +169,21 @@ function preprocessContainerRows(container: HTMLElement) {
}
}
-function tightenColumn(colEl: HTMLElement) {
+function tightenColumn(colEl: HTMLElement, colIdx: number, numColumns: number) {
+ let align = 'center';
+ if (numColumns > 1) {
+ if (colIdx === 0) {
+ align = 'right';
+ } else if (colIdx === numColumns - 1) {
+ align = 'left';
+ }
+ }
+
+ const colAlignItems = align === 'right' ? 'flex-end' : align === 'left' ? 'flex-start' : 'center';
+ const childJustifyContent =
+ align === 'right' ? 'flex-end' : align === 'left' ? 'flex-start' : 'center';
+ const childTextAlign = align;
+
setCssProps(
colEl,
{
@@ -180,7 +194,7 @@ function tightenColumn(colEl: HTMLElement) {
display: 'flex',
'flex-direction': 'column',
'justify-content': 'center',
- 'align-items': 'center'
+ 'align-items': colAlignItems
},
'important'
);
@@ -193,7 +207,9 @@ function tightenColumn(colEl: HTMLElement) {
el,
{
'margin-top': '0',
+ 'margin-right': '0',
'margin-bottom': '0',
+ 'margin-left': '0',
'padding-top': '0',
'padding-bottom': '0',
'line-height': 'normal'
@@ -221,7 +237,8 @@ function tightenColumn(colEl: HTMLElement) {
{
display: displayType,
'align-items': 'center',
- 'justify-content': 'center',
+ 'justify-content': childJustifyContent,
+ 'text-align': childTextAlign,
'vertical-align': 'middle'
},
'important'
@@ -238,6 +255,47 @@ function tightenColumn(colEl: HTMLElement) {
});
}
+function updateRowLayout(rowEl: HTMLElement, columns: HTMLElement[], customWidths: string[]) {
+ const numColumns = columns.length;
+ const gridTracks: string[] = [];
+
+ if (customWidths.length > 0) {
+ // Use user-defined widths; row stretches to fill the container.
+ for (let colIdx = 0; colIdx < numColumns; colIdx++) {
+ gridTracks.push(colIdx < customWidths.length ? customWidths[colIdx] : '1fr');
+ }
+ setCssProps(
+ rowEl,
+ {
+ width: '100%',
+ 'max-width': 'none',
+ 'margin-left': '0',
+ 'margin-right': '0',
+ 'grid-template-columns': gridTracks.join(' ')
+ },
+ 'important'
+ );
+ } else {
+ // Size each column to its natural (max-content) width so block-level wrappers
+ // like mjx-container don't inflate the column to the full container width.
+ // The row itself shrinks to fit its content and is centered horizontally.
+ for (let colIdx = 0; colIdx < numColumns; colIdx++) {
+ gridTracks.push('max-content');
+ }
+ setCssProps(
+ rowEl,
+ {
+ width: 'fit-content',
+ 'max-width': '100%',
+ 'margin-left': 'auto',
+ 'margin-right': 'auto',
+ 'grid-template-columns': gridTracks.join(' ')
+ },
+ 'important'
+ );
+ }
+}
+
// ==========================================
// 1. Reading View & PDF Export Post-Processor
// ==========================================
@@ -359,27 +417,19 @@ export const createRowLayoutProcessor = (plugin: LatexReferencer): MarkdownPostP
}
const numColumns = columnsElements.length;
- const gridTracks: string[] = [];
- for (let colIdx = 0; colIdx < numColumns; colIdx++) {
- if (colIdx < widths.length) {
- gridTracks.push(widths[colIdx]);
- } else {
- gridTracks.push('1fr');
- }
- }
-
const rowEl = activeDocument.createElement('div');
rowEl.classList.add('latex-referencer-row');
setCssProps(rowEl, {
display: 'grid',
- 'grid-template-columns': gridTracks.join(' '),
gap: '1.5rem',
width: '100%',
'align-items': 'center',
margin: '-0.5em 0'
});
- columnsElements.forEach(colEls => {
+ const columns: HTMLElement[] = [];
+
+ columnsElements.forEach((colEls, colIdx) => {
const colEl = rowEl.createEl('div', { cls: 'latex-referencer-column' });
setCssProps(colEl, {
display: 'flex',
@@ -390,13 +440,19 @@ export const createRowLayoutProcessor = (plugin: LatexReferencer): MarkdownPostP
// Move existing elements into the column
colEls.forEach(item => colEl.appendChild(item));
-
- tightenColumn(colEl);
- window.setTimeout(() => tightenColumn(colEl), 50);
- window.setTimeout(() => tightenColumn(colEl), 150);
- window.setTimeout(() => tightenColumn(colEl), 500);
+ columns.push(colEl);
});
+ const refresh = () => {
+ columns.forEach((colEl, colIdx) => tightenColumn(colEl, colIdx, numColumns));
+ updateRowLayout(rowEl, columns, widths);
+ };
+
+ refresh();
+ window.setTimeout(refresh, 50);
+ window.setTimeout(refresh, 150);
+ window.setTimeout(refresh, 500);
+
// Replace start element and remove all old intermediate DOM nodes
if (topBlock.parentElement) {
topBlock.parentElement.replaceChild(rowEl, topBlock);
@@ -437,27 +493,17 @@ class RowLayoutWidget extends WidgetType {
const rowEl = activeDocument.createElement('div');
rowEl.classList.add('latex-referencer-row');
setCssProps(rowEl, {
- display: 'grid'
- });
-
- const numColumns = this.columnsMarkdown.length;
- const gridTracks: string[] = [];
- for (let colIdx = 0; colIdx < numColumns; colIdx++) {
- if (colIdx < this.widths.length) {
- gridTracks.push(this.widths[colIdx]);
- } else {
- gridTracks.push('1fr');
- }
- }
- setCssProps(rowEl, {
- 'grid-template-columns': gridTracks.join(' '),
+ display: 'grid',
gap: '1.5rem',
width: '100%',
'align-items': 'center',
margin: '-0.5em 0'
});
- this.columnsMarkdown.forEach(colMarkdown => {
+ const numColumns = this.columnsMarkdown.length;
+ const columns: HTMLElement[] = [];
+
+ this.columnsMarkdown.forEach((colMarkdown, colIdx) => {
const colEl = rowEl.createEl('div', { cls: 'latex-referencer-column' });
setCssProps(colEl, {
display: 'flex',
@@ -465,6 +511,7 @@ class RowLayoutWidget extends WidgetType {
'justify-content': 'center',
'min-width': '0'
});
+ columns.push(colEl);
// Render Markdown asynchronously inside the editor column
const comp = new MarkdownRenderChild(colEl);
@@ -472,16 +519,23 @@ class RowLayoutWidget extends WidgetType {
this.components.push(comp);
MarkdownRenderer.render(this.plugin.app, colMarkdown, colEl, this.sourcePath, comp)
.then(() => {
- tightenColumn(colEl);
- window.setTimeout(() => tightenColumn(colEl), 50);
- window.setTimeout(() => tightenColumn(colEl), 150);
- window.setTimeout(() => tightenColumn(colEl), 500);
+ refresh();
})
.catch(err =>
console.error('Latex Referencer: Failed to render Live Preview column markdown', err)
);
});
+ const refresh = () => {
+ columns.forEach((colEl, colIdx) => tightenColumn(colEl, colIdx, numColumns));
+ updateRowLayout(rowEl, columns, this.widths);
+ };
+
+ refresh();
+ window.setTimeout(refresh, 50);
+ window.setTimeout(refresh, 150);
+ window.setTimeout(refresh, 500);
+
// Resolve editing flow: move selection inside the block when clicked
rowEl.onclick = (evt: MouseEvent) => {
try {
@@ -510,106 +564,115 @@ class RowLayoutWidget extends WidgetType {
}
}
-export const createLivePreviewRowLayoutPlugin = (plugin: LatexReferencer): Extension => {
- const layoutField = StateField.define({
- 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 | null = null;
+let activePlugin: LatexReferencer | null = null;
- const { state } = tr;
-
- const livePreview = state.field(editorLivePreviewField, false);
- if (!livePreview) {
+function getLayoutField(plugin: LatexReferencer): StateField {
+ activePlugin = plugin;
+ if (!layoutField) {
+ layoutField = StateField.define({
+ 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();
- 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();
+ 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 || plugin,
+ 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));
};
diff --git a/src/features/tikz/tikzjax/loader.ts b/src/features/tikz/tikzjax/loader.ts
index dfa9601..40fefc5 100644
--- a/src/features/tikz/tikzjax/loader.ts
+++ b/src/features/tikz/tikzjax/loader.ts
@@ -1,5 +1,111 @@
-import { dvi2html } from 'dvi2html';
+import HTMLMachine from 'dvi2html/lib/html';
+import { dviParser, mergeText, specials } from 'dvi2html';
import { Writable } from 'stream';
+
+interface FontMetricCharacter {
+ width: number;
+ height: number;
+ depth: number;
+}
+
+interface DviRule {
+ a: number;
+ b: number;
+}
+
+interface DviParsedCommand {
+ opcode?: number;
+ width?: number;
+ height?: number;
+ execute(machine: HTMLMachine): void;
+}
+
+class CustomHTMLMachine extends HTMLMachine {
+ override putSVG(svgStr: string): void {
+ const left = this.position.h * this.pointsPerDviUnit;
+ const top = this.position.v * this.pointsPerDviUnit;
+
+ this.svgDepth = this.svgDepth || 0;
+ this.svgDepth += (svgStr.match(//g) || []).length;
+
+ let replacedSvg = svgStr.replace(
+ '',
+ ``
+ );
+ replacedSvg = replacedSvg.replace(
+ '',
+ ``
+ );
+
+ replacedSvg = replacedSvg.replace(/{\?x}/g, left.toString());
+ replacedSvg = replacedSvg.replace(/{\?y}/g, top.toString());
+ this.output.write(replacedSvg);
+ }
+
+ override putRule(rule: DviRule): void {
+ const a = rule.a * this.pointsPerDviUnit;
+ const b = rule.b * this.pointsPerDviUnit;
+ const left = this.position.h * this.pointsPerDviUnit;
+ const bottom = this.position.v * this.pointsPerDviUnit;
+ const top = bottom - a;
+
+ if (this.svgDepth === 0) {
+ this.output.write(
+ ` \n`
+ );
+ } else {
+ this.output.write(
+ ` \n`
+ );
+ }
+ }
+
+ override putText(text: number[] | Buffer): number {
+ let textWidth = 0;
+ let textHeight = 0;
+ let textDepth = 0;
+ let htmlText = '';
+ const chars = this.font.metrics.characters as Record;
+
+ for (let i = 0; i < text.length; i++) {
+ const c = text[i];
+ const metrics = chars[c];
+ if (metrics === undefined) {
+ throw new Error(`Could not find font metric for ${c}`);
+ }
+ textWidth += metrics.width;
+ textHeight = Math.max(textHeight, metrics.height);
+ textDepth = Math.max(textDepth, metrics.depth);
+ if (c < 32) {
+ const shifted = Math.floor(c / 10) * 12 + (c % 10) + 161;
+ htmlText += `${shifted};`;
+ } else {
+ htmlText += String.fromCharCode(c);
+ }
+ }
+
+ const dviUnitsPerFontUnit = (this.font.metrics.designSize / 1048576.0) * (65536 / 1048576);
+ const left = this.position.h * this.pointsPerDviUnit;
+ const height = textHeight * this.pointsPerDviUnit * dviUnitsPerFontUnit;
+ const top = this.position.v * this.pointsPerDviUnit;
+ const fontsize =
+ ((this.font.metrics.designSize / 1048576.0) * this.font.scaleFactor) / this.font.designSize;
+
+ if (this.svgDepth === 0) {
+ this.output.write(
+ `${htmlText} \n`
+ );
+ } else {
+ const bottom = this.position.v * this.pointsPerDviUnit;
+ this.output.write(
+ `${htmlText} \n`
+ );
+ }
+ return (textWidth * dviUnitsPerFontUnit * this.font.scaleFactor) / this.font.designSize;
+ }
+}
+
import zlib from 'zlib';
import { requestUrl, type Notice } from 'obsidian';
import { showNotice } from 'utils/obsidian';
@@ -61,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 {
@@ -149,7 +255,9 @@ export class TikzJaxLoader {
}
// Decompress gzip synchronously using Node zlib
- const decompressed = zlib.gunzipSync(Buffer.from(compressedData));
+ const decompressed = (zlib as unknown as { gunzipSync(buf: Buffer): Buffer }).gunzipSync(
+ Buffer.from(compressedData)
+ );
return new Uint8Array(decompressed);
} catch (err) {
console.error(`Latex Referencer: Failed to load/decompress asset ${filename}`, err);
@@ -255,6 +363,26 @@ export class TikzJaxLoader {
name => name.startsWith('tex_files/simplekv') || name.startsWith('tex_files/t-chemfig')
)
.forEach(name => assetsToLoad.add(name));
+ } else if (pkg === 'tikz-cd') {
+ availableAssets
+ .filter(
+ name =>
+ name.startsWith('tex_files/tikzlibrarycd') || name.startsWith('tex_files/tikz-cd')
+ )
+ .forEach(name => assetsToLoad.add(name));
+ } else if (pkg === 'tikz-feynhand' || pkg === 'tikzfeynhand') {
+ availableAssets
+ .filter(
+ name =>
+ name.startsWith('tex_files/tikzlibraryfeynhand') ||
+ name.startsWith('tex_files/tikzfeynhand') ||
+ name.startsWith('tex_files/tikz-feynhand')
+ )
+ .forEach(name => assetsToLoad.add(name));
+ } else if (pkg === 'pgfcalendar') {
+ availableAssets
+ .filter(name => name.startsWith('tex_files/pgfcalendar'))
+ .forEach(name => assetsToLoad.add(name));
}
}
@@ -270,8 +398,9 @@ export class TikzJaxLoader {
for (const asset of assetsToLoad) {
const data = await this.loadAssetFile(asset);
if (data) {
- // Map the virtual path inside the worker (without .gz)
- const workerVirtualPath = asset.replace(/\.gz$/, '');
+ // Strip the 'tex_files/' prefix and '.gz' suffix so TeX can find
+ // files by their bare name (e.g. 'tikz.sty', not 'tex_files/tikz.sty.gz')
+ const workerVirtualPath = asset.replace(/^tex_files\//, '').replace(/\.gz$/, '');
filesToLoad[workerVirtualPath] = data;
}
}
@@ -322,6 +451,10 @@ export class TikzJaxLoader {
* Converts DVI format to SVG
*/
private async dviToSvg(dvi: Uint8Array): Promise {
+ if (dvi.length === 0) {
+ throw new Error('TikZJax Error: The generated DVI file is completely empty.');
+ }
+
let html = '';
const page = new Writable({
write(chunk: unknown, _, callback) {
@@ -335,22 +468,317 @@ export class TikzJaxLoader {
return;
}
- const machine = await dvi2html(streamBuffer(), page);
+ // Reconstruct the parser pipeline using dvi2html primitives
+ const parser = specials.papersize(
+ specials.svg(specials.color(mergeText(dviParser(streamBuffer()))))
+ );
- // Parse SVG safely using DOMParser
- const parser = new DOMParser();
- const doc = parser.parseFromString(html.trim(), 'image/svg+xml');
- const svg = doc.querySelector('svg');
+ const machine = new CustomHTMLMachine(page);
- if (!svg) {
- throw new Error('DVI conversion succeeded but no SVG element was generated.');
+ let currentPageIndex = -1;
+ const pageSizes: { width: number; height: number }[] = [];
+
+ for await (const rawCommand of parser) {
+ const command = rawCommand as DviParsedCommand;
+ if (command.opcode === 139) {
+ // Bop
+ if (currentPageIndex >= 0) {
+ page.write('');
+ }
+ currentPageIndex++;
+ pageSizes[currentPageIndex] = { width: 0, height: 0 };
+ page.write(``);
+ } else if (command.width !== undefined && command.height !== undefined) {
+ if (currentPageIndex >= 0) {
+ pageSizes[currentPageIndex] = {
+ width: command.width,
+ height: command.height
+ };
+ }
+ }
+ command.execute(machine);
}
- // Apply viewport attributes
- svg.setAttribute('width', `${machine.paperwidth}pt`);
- svg.setAttribute('height', `${machine.paperheight}pt`);
- svg.setAttribute('viewBox', `-72 -72 ${machine.paperwidth} ${machine.paperheight}`);
+ if (currentPageIndex >= 0) {
+ page.write('
');
+ }
+
+ const domParser = new DOMParser();
+ const doc = domParser.parseFromString(html.trim(), 'text/html');
+
+ // LaTeX generated multiple pages. Let's grab all SVGs from the HTML.
+ const svgs = Array.from(doc.querySelectorAll('svg'));
+
+ if (svgs.length === 0) {
+ console.error('[TeXcore] Raw HTML output:', html);
+ throw new Error('TikZJax Error: DVI conversion succeeded but no SVGs were found.');
+ }
+
+ // If there are multiple pages, one might be a blank cropping artifact.
+ // Grab the first SVG that actually contains drawing elements, or default to the first.
+ const svg =
+ svgs.find(s =>
+ s.querySelector('path, use, rect, circle, ellipse, line, polyline, polygon, text')
+ ) || svgs[0];
+
+ // Calculate the bounding box of the actual elements to crop margins perfectly
+ const bbox = getSvgBoundingBox(svg);
+ if (bbox) {
+ const padding = 4; // 4pt padding on all sides for layout safety
+ const width = bbox.maxX - bbox.minX + padding * 2;
+ const height = bbox.maxY - bbox.minY + padding * 2;
+ svg.setAttribute('width', `${width}pt`);
+ svg.setAttribute('height', `${height}pt`);
+ svg.setAttribute(
+ 'viewBox',
+ `${bbox.minX - padding} ${bbox.minY - padding} ${width} ${height}`
+ );
+ svg.classList.add('tikz-cropped');
+ svg.setAttribute('data-bbox-minx', bbox.minX.toString());
+ svg.setAttribute('data-bbox-miny', bbox.minY.toString());
+ svg.setAttribute('data-bbox-maxx', bbox.maxX.toString());
+ svg.setAttribute('data-bbox-maxy', bbox.maxY.toString());
+ svg.setAttribute('data-bbox-width', width.toString());
+ svg.setAttribute('data-bbox-height', height.toString());
+ } else {
+ // Find the corresponding page container to retrieve the paper size dimensions
+ const pageContainer = svg.closest('.texcore-page');
+ const pageIndexStr = pageContainer?.getAttribute('data-page');
+ const pageIndex = pageIndexStr ? parseInt(pageIndexStr, 10) : 0;
+ const pageSize = pageSizes[pageIndex];
+
+ // Apply bounding boxes starting at -72 -72 for standalone cropped outputs
+ if (pageSize && pageSize.width && pageSize.height) {
+ svg.setAttribute('width', `${pageSize.width}pt`);
+ svg.setAttribute('height', `${pageSize.height}pt`);
+ svg.setAttribute('viewBox', `-72 -72 ${pageSize.width} ${pageSize.height}`);
+ } else {
+ // Fallback: If paper dimensions are missing, check if width/height attributes are on the SVG
+ const w = svg.getAttribute('width')?.replace('pt', '');
+ const h = svg.getAttribute('height')?.replace('pt', '');
+ if (w && h && w !== '10' && h !== '10') {
+ svg.setAttribute('viewBox', `-72 -72 ${w} ${h}`);
+ }
+ }
+ }
return svg;
}
}
+
+interface Matrix2D {
+ a: number;
+ b: number;
+ c: number;
+ d: number;
+ e: number;
+ f: number;
+}
+
+function getSvgBoundingBox(
+ svg: SVGElement
+): { minX: number; minY: number; maxX: number; maxY: number } | null {
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+
+ function updateBounds(x: number, y: number) {
+ if (x < minX) minX = x;
+ if (x > maxX) maxX = x;
+ if (y < minY) minY = y;
+ if (y > maxY) maxY = y;
+ }
+
+ function parseTransform(transformStr: string | null): Matrix2D {
+ if (!transformStr) return { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
+ let matrix: Matrix2D = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
+ const regex = /(\w+)\(([^)]+)\)/g;
+ let match;
+ while ((match = regex.exec(transformStr)) !== null) {
+ const type = match[1];
+ const args = match[2]
+ .trim()
+ .split(/[\s,]+/)
+ .map(Number)
+ .filter(n => !isNaN(n));
+ if (type === 'translate') {
+ const tx = args[0] || 0;
+ const ty = args[1] || 0;
+ matrix = multiply(matrix, { a: 1, b: 0, c: 0, d: 1, e: tx, f: ty });
+ } else if (type === 'scale') {
+ const sx = args[0] || 1;
+ const sy = args[1] !== undefined ? args[1] : sx;
+ matrix = multiply(matrix, { a: sx, b: 0, c: 0, d: sy, e: 0, f: 0 });
+ } else if (type === 'matrix') {
+ matrix = multiply(matrix, {
+ a: args[0] !== undefined ? args[0] : 1,
+ b: args[1] || 0,
+ c: args[2] || 0,
+ d: args[3] !== undefined ? args[3] : 1,
+ e: args[4] || 0,
+ f: args[5] || 0
+ });
+ }
+ }
+ return matrix;
+ }
+
+ function multiply(m1: Matrix2D, m2: Matrix2D): Matrix2D {
+ return {
+ a: m1.a * m2.a + m1.c * m2.b,
+ b: m1.b * m2.a + m1.d * m2.b,
+ c: m1.a * m2.c + m1.c * m2.d,
+ d: m1.b * m2.c + m1.d * m2.d,
+ e: m1.a * m2.e + m1.c * m2.f + m1.e,
+ f: m1.b * m2.e + m1.d * m2.f + m1.f
+ };
+ }
+
+ function getAbsoluteMatrix(element: Element): Matrix2D {
+ let m: Matrix2D = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
+ let current: Element | null = element;
+ while (current && current !== svg) {
+ const transform = current.getAttribute('transform');
+ if (transform) {
+ m = multiply(parseTransform(transform), m);
+ }
+ current = current.parentElement;
+ }
+ return m;
+ }
+
+ function applyTransform(matrix: Matrix2D, x: number, y: number) {
+ return {
+ x: matrix.a * x + matrix.c * y + matrix.e,
+ y: matrix.b * x + matrix.d * y + matrix.f
+ };
+ }
+
+ function parsePathBounds(d: string, matrix: Matrix2D, offsetX = 0, offsetY = 0) {
+ const matches = d.match(/[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?/g);
+ if (matches) {
+ for (let i = 0; i < matches.length; i += 2) {
+ const x = parseFloat(matches[i]);
+ const y = parseFloat(matches[i + 1]);
+ if (!isNaN(x) && !isNaN(y)) {
+ const pt = applyTransform(matrix, x + offsetX, y + offsetY);
+ updateBounds(pt.x, pt.y);
+ }
+ }
+ }
+ }
+
+ // 1. Scan paths
+ const paths = svg.querySelectorAll('path');
+ paths.forEach(path => {
+ if (path.closest('defs')) return;
+ const d = path.getAttribute('d') || '';
+ const matrix = getAbsoluteMatrix(path);
+ parsePathBounds(d, matrix);
+ });
+
+ // 2. Scan use elements (referenced symbols)
+ const uses = svg.querySelectorAll('use');
+ uses.forEach(use => {
+ const href = use.getAttribute('href') || use.getAttribute('xlink:href') || '';
+ const x = parseFloat(use.getAttribute('x') || '0');
+ const y = parseFloat(use.getAttribute('y') || '0');
+ const matrix = getAbsoluteMatrix(use);
+
+ const pt = applyTransform(matrix, x, y);
+ updateBounds(pt.x, pt.y);
+
+ if (href.startsWith('#')) {
+ const id = href.substring(1);
+ const defElement = svg.querySelector(`[id="${id}"]`);
+ if (defElement) {
+ if (defElement.tagName === 'path') {
+ const d = defElement.getAttribute('d') || '';
+ parsePathBounds(d, matrix, x, y);
+ }
+ }
+ }
+ });
+
+ // 3. Scan rects
+ const rects = svg.querySelectorAll('rect');
+ rects.forEach(rect => {
+ if (rect.closest('defs')) return;
+ const x = parseFloat(rect.getAttribute('x') || '0');
+ const y = parseFloat(rect.getAttribute('y') || '0');
+ const w = parseFloat(rect.getAttribute('width') || '0');
+ const h = parseFloat(rect.getAttribute('height') || '0');
+ const matrix = getAbsoluteMatrix(rect);
+ const p1 = applyTransform(matrix, x, y);
+ const p2 = applyTransform(matrix, x + w, y);
+ const p3 = applyTransform(matrix, x, y + h);
+ const p4 = applyTransform(matrix, x + w, y + h);
+ updateBounds(p1.x, p1.y);
+ updateBounds(p2.x, p2.y);
+ updateBounds(p3.x, p3.y);
+ updateBounds(p4.x, p4.y);
+ });
+
+ // 4. Scan circles
+ const circles = svg.querySelectorAll('circle');
+ circles.forEach(circle => {
+ if (circle.closest('defs')) return;
+ const cx = parseFloat(circle.getAttribute('cx') || '0');
+ const cy = parseFloat(circle.getAttribute('cy') || '0');
+ const r = parseFloat(circle.getAttribute('r') || '0');
+ const matrix = getAbsoluteMatrix(circle);
+ const p1 = applyTransform(matrix, cx - r, cy - r);
+ const p2 = applyTransform(matrix, cx + r, cy + r);
+ updateBounds(p1.x, p1.y);
+ updateBounds(p2.x, p2.y);
+ });
+
+ // 5. Scan lines
+ const lines = svg.querySelectorAll('line');
+ lines.forEach(line => {
+ if (line.closest('defs')) return;
+ const x1 = parseFloat(line.getAttribute('x1') || '0');
+ const y1 = parseFloat(line.getAttribute('y1') || '0');
+ const x2 = parseFloat(line.getAttribute('x2') || '0');
+ const y2 = parseFloat(line.getAttribute('y2') || '0');
+ const matrix = getAbsoluteMatrix(line);
+ const p1 = applyTransform(matrix, x1, y1);
+ const p2 = applyTransform(matrix, x2, y2);
+ updateBounds(p1.x, p1.y);
+ updateBounds(p2.x, p2.y);
+ });
+
+ // 6. Scan texts
+ const texts = svg.querySelectorAll('text');
+ texts.forEach(text => {
+ if (text.closest('defs')) return;
+ const x = parseFloat(text.getAttribute('x') || '0');
+ const y = parseFloat(text.getAttribute('y') || '0');
+ const matrix = getAbsoluteMatrix(text);
+ const pt = applyTransform(matrix, x, y);
+
+ // Extract font size from style
+ const style = text.getAttribute('style') || '';
+ const fontSizeMatch = style.match(/font-size:\s*([\d.]+)/);
+ const fontSize = fontSizeMatch ? parseFloat(fontSizeMatch[1]) : 10;
+
+ // Extract text content and estimate width
+ const content = text.textContent || '';
+ const estimatedWidth = content.length * fontSize * 0.6;
+
+ // Add text box bounds
+ updateBounds(pt.x, pt.y);
+ updateBounds(pt.x + estimatedWidth, pt.y);
+ updateBounds(pt.x, pt.y - fontSize);
+ updateBounds(pt.x + estimatedWidth, pt.y - fontSize);
+ updateBounds(pt.x, pt.y + fontSize * 0.2); // descenders
+ updateBounds(pt.x + estimatedWidth, pt.y + fontSize * 0.2);
+ });
+
+ if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) {
+ return null;
+ }
+
+ return { minX, minY, maxX, maxY };
+}
diff --git a/src/features/tikz/tikzjax/tikzjax.worker.ts b/src/features/tikz/tikzjax/tikzjax.worker.ts
index b2e581e..6f15075 100644
--- a/src/features/tikz/tikzjax/tikzjax.worker.ts
+++ b/src/features/tikz/tikzjax/tikzjax.worker.ts
@@ -1,240 +1,255 @@
-/* eslint-disable */
-import { tfmData } from 'dvi2html';
+// Raw TFM font metric data (base64-encoded) bundled with dvi2html
+// This is exactly what the TeX engine needs when it opens a .tfm file
+import fontsJson from 'dvi2html/lib/tfm/fonts.json';
+
+function lookupTfmData(fontName: string): Uint8Array | null {
+ const entry = (fontsJson as Record)[fontName];
+ if (!entry) return null;
+ // Decode base64 โ raw TFM bytes
+ const binary = atob(entry);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+ return bytes;
+}
+
+// โโโ Virtual Filesystem โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
interface VirtualFile {
filename: string;
position: number;
erstat: number;
- buffer: Uint8Array;
- descriptor?: number;
+ content: Uint8Array; // NOTE: named 'content', not 'buffer' โ matches original
stdin?: boolean;
stdout?: boolean;
eof?: boolean;
eoln?: boolean;
}
-// Simulated virtual filesystem
+// Open file list (mirrors original's `uq`)
let files: VirtualFile[] = [];
-let virtualFilesystem: Record = {};
+// Preloaded file dict (mirrors original's `cq`) โ filename โ content
+let preloadedFiles: Record = {};
+
+// โโโ WASM State โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+interface TeXWasmExports {
+ main(): void;
+ asyncify_stop_unwind?(): void;
+}
+// WASM exports reference (needed to call main() after asyncify rewind)
+let wasmExports: TeXWasmExports | null = null;
+// Linear memory buffer
+let memory: ArrayBuffer | undefined = undefined;
+// Page count for the WASM memory allocation
+const PAGES = 1100;
+
+// โโโ TeX Engine State โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+// Input string fed to TeX (mirrors original's `xq`)
+let inputBuffer = '';
+// Promise resolved by tex_final_end (mirrors original's `Oq`)
+let texDoneResolve: (() => void) | undefined = undefined;
+
+// โโโ Console Output โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+let consoleBuffer = '';
+const texLogs: string[] = [];
+function writeToConsole(x: string) {
+ consoleBuffer += x;
+ if (consoleBuffer.indexOf('\n') >= 0) {
+ const lines = consoleBuffer.split('\n');
+ consoleBuffer = lines.pop() || '';
+ for (const line of lines) {
+ if (line.length > 0) {
+ texLogs.push(line);
+ }
+ }
+ }
+}
+
+// โโโ Filesystem Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
export function deleteEverything() {
files = [];
- virtualFilesystem = {};
+ preloadedFiles = {};
+ memory = undefined;
+ wasmExports = null;
+ consoleBuffer = '';
+ inputBuffer = '';
+ texDoneResolve = undefined;
}
-export function writeFileSync(filename: string, buffer: Uint8Array) {
- virtualFilesystem[filename] = buffer;
+export function writeFileSync(filename: string, content: Uint8Array) {
+ preloadedFiles[filename] = content;
}
export function readFileSync(filename: string): Uint8Array {
for (const f of files) {
if (f.filename === filename) {
- return f.buffer.slice(0, f.position);
+ return f.content.slice(0, f.position);
}
}
- throw new Error(`Could not find file ${filename}`);
+ const available = files.map(f => f.filename).join(', ');
+ throw new Error(`Could not find file ${filename}. Available: [${available}]`);
}
-function openSync(filename: string, mode: string): number {
- let buffer: any = new Uint8Array();
-
- if (virtualFilesystem[filename]) {
- buffer = virtualFilesystem[filename];
- }
-
- if (filename.endsWith('.tfm')) {
- const fontName = filename.replace(/\.tfm$/, '');
- const data = tfmData(fontName);
- if (data) {
- buffer = Uint8Array.from(data as any);
- }
- }
-
- files.push({
- filename: filename,
- position: 0,
- erstat: 0,
- buffer: buffer,
- descriptor: files.length
- });
-
- return files.length - 1;
-}
-
-function closeSync(fd: number) {
- // Ignore close sync
-}
-
-function writeSync(file: any, buffer: Uint8Array, pointer?: number, length?: number) {
- const p = pointer === undefined ? 0 : pointer;
- let len = length === undefined ? buffer.length - p : length;
-
- while (len > file.buffer.length - file.position) {
- const b = new Uint8Array(1 + file.buffer.length * 2);
- b.set(file.buffer);
- file.buffer = b;
- }
-
- file.buffer.subarray(file.position).set(buffer.subarray(p, p + len));
- file.position += len;
-}
-
-function readSync(
- file: any,
- buffer: Uint8Array,
- pointer?: number,
- length?: number,
- seek?: number
-): number {
- const p = pointer === undefined ? 0 : pointer;
- let len = length === undefined ? buffer.length - p : length;
- const s = seek === undefined ? 0 : seek;
-
- if (len > file.buffer.length - s) {
- len = file.buffer.length - s;
- }
-
- buffer.subarray(p).set(file.buffer.subarray(s, s + len));
- return len;
-}
-
-// Console output collection
-let consoleBuffer = '';
-function writeToConsole(x: string) {
- consoleBuffer = consoleBuffer + x;
- if (consoleBuffer.indexOf('\n') >= 0) {
- const lines = consoleBuffer.split('\n');
- consoleBuffer = lines.pop() || '';
- for (const line of lines) {
- console.log(line);
- }
- }
-}
-
-const customProcess = {
- stdout: {
- write: writeToConsole
- }
-};
-
-// WebAssembly instance memory variables
-let memory: ArrayBuffer | undefined = undefined;
-let inputBuffer = '';
-let callback: (() => void) | undefined = undefined;
-
export function setMemory(m: ArrayBuffer) {
memory = m;
}
-export function setInput(input: string, cb?: () => void) {
+export function setInput(input: string) {
inputBuffer = input;
- if (cb) callback = cb;
}
-// Time functions requested by TeX
+// โโโ Asyncify-aware openSync โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+//
+// All available TeX files are pre-sent to the worker by the main thread.
+// If a file isn't in preloadedFiles, it simply doesn't exist โ mark erstat=1.
+// .tfm font metrics are served from the dvi2html bundle.
+//
+function openSync(filename: string, mode: string): number {
+ let content = new Uint8Array();
+
+ if (preloadedFiles[filename]) {
+ content = preloadedFiles[filename] as unknown as Uint8Array;
+ } else if (filename.endsWith('.tfm')) {
+ const fontName = filename.replace(/\.tfm$/, '');
+ const data = lookupTfmData(fontName);
+ if (data) content = data as unknown as Uint8Array;
+ } else {
+ // Check if this file was generated during the current run (like input.aux)
+ // Search backwards to get the most recent version of the file
+ const generatedFile = files
+ .slice()
+ .reverse()
+ .find(f => f.filename === filename);
+
+ if (generatedFile) {
+ // Copy only the actual written bytes (the underlying buffer is over-allocated)
+ content = generatedFile.content.slice(0, generatedFile.position);
+ } else if (mode === 'r') {
+ // File not available โ mark as missing so TeX can handle the error gracefully
+ files.push({ filename, erstat: 1, content: new Uint8Array(), position: 0 });
+ return files.length - 1;
+ }
+ }
+
+ files.push({
+ filename,
+ position: 0,
+ erstat: 0,
+ content,
+ eof: false,
+ eoln: false
+ });
+ return files.length - 1;
+}
+
+// โโโ Time Functions โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
export function getCurrentMinutes(): number {
const d = new Date();
return 60 * d.getHours() + d.getMinutes();
}
-
export function getCurrentDay(): number {
return new Date().getDate();
}
-
export function getCurrentMonth(): number {
return new Date().getMonth() + 1;
}
-
export function getCurrentYear(): number {
return new Date().getFullYear();
}
-// TeX print output helper methods
+// โโโ Text Encoding โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
const textDecoder = new TextDecoder();
const textEncoder = new TextEncoder();
+// โโโ Print Functions โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
export function printString(descriptor: number, x: number) {
if (!memory) return;
- const file = descriptor < 0 ? ({ stdout: true } as any) : files[descriptor];
+ const file = descriptor < 0 ? null : files[descriptor];
const length = new Uint8Array(memory, x, 1)[0];
- const buffer = new Uint8Array(memory, x + 1, length);
- const string = textDecoder.decode(buffer);
+ const buf = new Uint8Array(memory, x + 1, length);
+ const string = textDecoder.decode(buf);
- if (file.stdout) {
- customProcess.stdout.write(string);
+ if (!file || file.stdout) {
+ writeToConsole(string);
return;
}
-
- writeSync(file, textEncoder.encode(string));
+ writeContent(file, textEncoder.encode(string));
}
export function printBoolean(descriptor: number, x: boolean) {
- const file = descriptor < 0 ? ({ stdout: true } as any) : files[descriptor];
+ const file = descriptor < 0 ? null : files[descriptor];
const result = x ? 'TRUE' : 'FALSE';
-
- if (file.stdout) {
- customProcess.stdout.write(result);
+ if (!file || file.stdout) {
+ writeToConsole(result);
return;
}
-
- writeSync(file, textEncoder.encode(result));
+ writeContent(file, textEncoder.encode(result));
}
export function printChar(descriptor: number, x: number) {
- const file = descriptor < 0 ? ({ stdout: true } as any) : files[descriptor];
- if (file.stdout) {
- customProcess.stdout.write(String.fromCharCode(x));
+ const file = descriptor < 0 ? null : files[descriptor];
+ if (!file || file.stdout) {
+ writeToConsole(String.fromCharCode(x));
return;
}
-
- const b = new Uint8Array([x]);
- writeSync(file, b);
+ writeContent(file, new Uint8Array([x]));
}
export function printInteger(descriptor: number, x: number) {
- const file = descriptor < 0 ? ({ stdout: true } as any) : files[descriptor];
+ const file = descriptor < 0 ? null : files[descriptor];
const str = x.toString();
- if (file.stdout) {
- customProcess.stdout.write(str);
+ if (!file || file.stdout) {
+ writeToConsole(str);
return;
}
-
- writeSync(file, textEncoder.encode(str));
+ writeContent(file, textEncoder.encode(str));
}
export function printFloat(descriptor: number, x: number) {
- const file = descriptor < 0 ? ({ stdout: true } as any) : files[descriptor];
+ const file = descriptor < 0 ? null : files[descriptor];
const str = x.toString();
- if (file.stdout) {
- customProcess.stdout.write(str);
+ if (!file || file.stdout) {
+ writeToConsole(str);
return;
}
-
- writeSync(file, textEncoder.encode(str));
+ writeContent(file, textEncoder.encode(str));
}
export function printNewline(descriptor: number) {
- const file = descriptor < 0 ? ({ stdout: true } as any) : files[descriptor];
- if (file.stdout) {
- customProcess.stdout.write('\n');
+ const file = descriptor < 0 ? null : files[descriptor];
+ if (!file || file.stdout) {
+ writeToConsole('\n');
return;
}
-
- writeSync(file, textEncoder.encode('\n'));
+ writeContent(file, textEncoder.encode('\n'));
}
+function writeContent(file: VirtualFile, buf: Uint8Array, offset = 0, len = buf.length - offset) {
+ while (len > file.content.length - file.position) {
+ const b = new Uint8Array(1 + file.content.length * 2);
+ b.set(file.content);
+ file.content = b;
+ }
+ file.content.subarray(file.position).set(buf.subarray(offset, offset + len));
+ file.position += len;
+}
+
+// โโโ WASM File I/O Imports โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
export function reset(length: number, pointer: number): number {
if (!memory) return -1;
- const buffer = new Uint8Array(memory, pointer, length);
- let filename = textDecoder.decode(buffer);
-
+ let filename = textDecoder.decode(new Uint8Array(memory, pointer, length));
filename = filename.replace(/ +$/g, '');
filename = filename.replace(/^\*/, '');
filename = filename.replace(/^TeXfonts:/, '');
- if (filename === 'TeXformats:TEX.POOL') {
- filename = 'tex.pool';
- }
+ if (filename === 'TeXformats:TEX.POOL') filename = 'tex.pool';
if (filename === 'TTY:') {
files.push({
@@ -242,7 +257,7 @@ export function reset(length: number, pointer: number): number {
stdin: true,
position: 0,
erstat: 0,
- buffer: new Uint8Array()
+ content: new Uint8Array()
});
return files.length - 1;
}
@@ -252,9 +267,7 @@ export function reset(length: number, pointer: number): number {
export function rewrite(length: number, pointer: number): number {
if (!memory) return -1;
- const buffer = new Uint8Array(memory, pointer, length);
- let filename = textDecoder.decode(buffer);
-
+ let filename = textDecoder.decode(new Uint8Array(memory, pointer, length));
filename = filename.replace(/ +$/g, '');
if (filename === 'TTY:') {
@@ -263,7 +276,7 @@ export function rewrite(length: number, pointer: number): number {
stdout: true,
position: 0,
erstat: 0,
- buffer: new Uint8Array()
+ content: new Uint8Array()
});
return files.length - 1;
}
@@ -271,11 +284,85 @@ export function rewrite(length: number, pointer: number): number {
return openSync(filename, 'w');
}
-export function close(descriptor: number) {
+export function inputln(
+ descriptor: number,
+ bypass_eof: number,
+ buffer_pointer: number,
+ first_pointer: number,
+ last_pointer: number,
+ max_length_pointer: number,
+ length: number
+): number {
+ if (!memory) return 0;
const file = files[descriptor];
- if (file && file.descriptor !== undefined) {
- closeSync(file.descriptor);
+
+ // stdin: serve from inputBuffer
+ if (file.stdin) {
+ const r = new Uint8Array(memory, buffer_pointer, length);
+ const X = new Uint32Array(memory, first_pointer, 4);
+ const H = new Uint32Array(memory, last_pointer, 4);
+ H[0] = X[0];
+
+ const encoded = textEncoder.encode(inputBuffer);
+
+ // Advance past the \n from the previous read (mirrors the file-read branch)
+ if (bypass_eof && !file.eof && file.eoln) {
+ file.position = file.position + 1;
+ }
+
+ let t = encoded.indexOf(10, file.position);
+ if (t < 0) t = encoded.length;
+
+ if (file.position >= encoded.length) {
+ file.eof = true;
+ return 0;
+ }
+
+ const sourceSlice = encoded.subarray(file.position, t);
+ r.subarray(X[0]).set(sourceSlice);
+ H[0] = X[0] + t - file.position;
+ while (H[0] > X[0] && r[H[0] - 1] === 32) H[0]--;
+ file.position = t;
+ file.eoln = true;
+ return 1;
}
+
+ const r = new Uint8Array(memory, buffer_pointer, length);
+ const X = new Uint32Array(memory, first_pointer, 4);
+ const H = new Uint32Array(memory, last_pointer, 4);
+ H[0] = X[0];
+
+ if (bypass_eof && !file.eof && file.eoln) {
+ file.position = file.position + 1;
+ }
+
+ let t = file.content.indexOf(10, file.position);
+ if (t < 0) t = file.content.length;
+
+ if (file.position >= file.content.length) {
+ file.eof = true;
+ return 0;
+ }
+
+ const sourceSlice = file.content.subarray(file.position, t);
+ r.subarray(X[0]).set(sourceSlice);
+ H[0] = X[0] + t - file.position;
+ while (H[0] > X[0] && r[H[0] - 1] === 32) H[0]--;
+ file.position = t;
+ file.eoln = true;
+ return 1;
+}
+
+export function tex_final_end() {
+ // TeX engine signals it is done โ resolve the compile promise
+ if (texDoneResolve) {
+ texDoneResolve();
+ texDoneResolve = undefined;
+ }
+}
+
+export function close(descriptor: number) {
+ // No-op: files are kept in memory for reading after close
}
export function eof(descriptor: number): number {
@@ -299,86 +386,134 @@ export function get(descriptor: number, pointer: number, length: number) {
const buffer = new Uint8Array(memory);
if (file.stdin) {
- if (file.position >= inputBuffer.length) {
+ const encoded = textEncoder.encode(inputBuffer);
+ if (file.position >= encoded.length) {
buffer[pointer] = 13;
file.eof = true;
file.eoln = true;
- if (callback) callback();
} else {
- buffer[pointer] = inputBuffer[file.position].charCodeAt(0);
+ buffer[pointer] = encoded[file.position];
}
} else {
- if (file.descriptor !== undefined) {
- if (readSync(file, buffer, pointer, length, file.position) === 0) {
- buffer[pointer] = 0;
- file.eof = true;
- file.eoln = true;
- return;
- }
- } else {
+ if (file.position >= file.content.length) {
+ buffer[pointer] = 0;
file.eof = true;
file.eoln = true;
return;
}
+ buffer[pointer] = file.content[file.position];
}
file.eoln = false;
if (buffer[pointer] === 10 || buffer[pointer] === 13) {
file.eoln = true;
}
-
- file.position = file.position + length;
+ file.position += length;
}
export function put(descriptor: number, pointer: number, length: number) {
if (!memory) return;
const file = files[descriptor];
const buffer = new Uint8Array(memory);
- writeSync(file, buffer, pointer, length);
+ writeContent(file, buffer, pointer, length);
}
-// Main execution function
-async function compile(
- code: string,
- preloadedFiles: Record
-): Promise {
+// โโโ Main Compile Function โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+async function compile(code: string, filesMap: Record): Promise {
deleteEverything();
- // Populate virtual filesystem
- for (const [filepath, content] of Object.entries(preloadedFiles)) {
+ // 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') {
writeFileSync(filepath, content);
}
}
- const wasmBinary = preloadedFiles['tex.wasm'];
- const coreDump = preloadedFiles['core.dump'];
+ const wasmBinary = filesMap['tex.wasm'];
+ const coreDump = filesMap['core.dump'];
if (!wasmBinary) throw new Error('Missing tex.wasm binary');
if (!coreDump) throw new Error('Missing core.dump binary');
- // Input LaTeX code
- let input = code;
- if (!input.includes('\\begin{document}')) {
- input = '\\begin{document}\n' + input;
- }
- if (!input.includes('\\end{document}')) {
- input = input + '\n\\end{document}\n';
+ // Build the TeX source.
+ // The core.dump file is pre-loaded with \documentclass[margin=0pt]{standalone}
+ // 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 (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 = strippedCode.replace(/\\documentclass\s*(\[[^\]]*\])?\s*\{[^}]*\}/g, '');
+ cleanCode = cleanCode.replace(/\\usepackage\s*(\[[^\]]*\])?\s*\{tikz\}/g, '');
+
+ // Split cleanCode into preamble and body. Preamble commands (like \usepackage,
+ // \usetikzlibrary, \newcommand, etc.) must go before \begin{document}.
+ const lines = cleanCode.split('\n');
+ const preambleLines: string[] = [];
+ const bodyLines: string[] = [];
+ let inPreamble = true;
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed) {
+ if (inPreamble) preambleLines.push(line);
+ else bodyLines.push(line);
+ continue;
+ }
+ if (trimmed.startsWith('%')) {
+ if (inPreamble) preambleLines.push(line);
+ else bodyLines.push(line);
+ continue;
+ }
+
+ const isPreambleCommand =
+ trimmed.startsWith('\\usepackage') ||
+ trimmed.startsWith('\\usetikzlibrary') ||
+ trimmed.startsWith('\\newcommand') ||
+ trimmed.startsWith('\\renewcommand') ||
+ trimmed.startsWith('\\def') ||
+ trimmed.startsWith('\\let') ||
+ trimmed.startsWith('\\pgfplotsset') ||
+ trimmed.startsWith('\\tikzset') ||
+ trimmed.startsWith('\\usepgfplotslibrary');
+
+ if (inPreamble && isPreambleCommand) {
+ preambleLines.push(line);
+ } else {
+ inPreamble = false;
+ bodyLines.push(line);
+ }
+ }
+
+ input = `\n${preambleLines.join('\n')}
+\\begin{document}
+${bodyLines.join('\n')}
+\\end{document}
+`;
}
- writeFileSync('sample.tex', textEncoder.encode(input));
-
- const pages = 1000;
- const memoryInstance = new WebAssembly.Memory({ initial: pages, maximum: pages });
- const memoryBuffer = new Uint8Array(memoryInstance.buffer, 0, pages * 65536);
-
- // Load core dump into memory
- memoryBuffer.set(coreDump);
+ // Write input to the virtual fs; TeX engine outputs input.dvi
+ writeFileSync('input.tex', textEncoder.encode(input));
+ // Set up WASM memory and load the core dump (TeX format file)
+ const memoryInstance = new WebAssembly.Memory({ initial: PAGES, maximum: PAGES });
+ new Uint8Array(memoryInstance.buffer, 0, PAGES * 65536).set(coreDump);
setMemory(memoryInstance.buffer);
- setInput(' sample.tex \n\\end\n');
- // Instantiate TeX WebAssembly
+ // Keep the leading space, but remove the newline between the commands
+ setInput(' \\nonstopmode\\input input.tex \n');
+
+ // Create the deferred promise โ tex_final_end will resolve it
+ const texDone = new Promise(resolve => {
+ texDoneResolve = resolve;
+ });
+
const importObject = {
library: {
deleteEverything,
@@ -396,35 +531,62 @@ async function compile(
printNewline,
reset,
rewrite,
+ inputln,
close,
eof,
erstat,
eoln,
get,
- put
+ put,
+ tex_final_end
},
env: {
memory: memoryInstance
}
};
- await WebAssembly.instantiate(wasmBinary, importObject);
+ const wasmResult = await WebAssembly.instantiate(wasmBinary.buffer ?? wasmBinary, importObject);
+ wasmExports = (wasmResult as unknown as WebAssembly.WebAssemblyInstantiatedSource).instance
+ .exports as unknown as TeXWasmExports;
- // Read generated output DVI
- return readFileSync('sample.dvi');
+ // Kick off the TeX engine. It may asyncify-unwind multiple times (to load
+ // missing files on demand); each time it does, openSync schedules a rewind
+ // via setTimeout so the microtask queue drains before we re-enter main().
+ if (wasmExports) {
+ wasmExports.main();
+ wasmExports.asyncify_stop_unwind?.();
+ }
+
+ // Wait for tex_final_end to be called โ signals successful compilation
+ await texDone;
+
+ // Read the DVI output file
+ return readFileSync('input.dvi');
}
-// Worker message handling
-self.addEventListener('message', async (e: MessageEvent) => {
- const { type, code, files } = e.data;
+// โโโ Worker Message Handling โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+self.addEventListener('message', (e: MessageEvent) => {
+ const { type, code, files } = e.data as {
+ type: string;
+ code: string;
+ files: Record;
+ };
if (type === 'compile') {
- try {
- const dvi = await compile(code, files);
- // Transfer the array buffer back for optimal performance
- (self as any).postMessage({ type: 'success', dvi }, [dvi.buffer as any]);
- } catch (err: any) {
- (self as any).postMessage({ type: 'error', error: err?.message || String(err) });
- }
+ texLogs.length = 0;
+ void (async () => {
+ try {
+ const dvi = await compile(code, files);
+ (
+ self as unknown as { postMessage(message: unknown, transfer?: Transferable[]): void }
+ ).postMessage({ type: 'success', dvi }, [dvi.buffer]);
+ } catch (err: unknown) {
+ const errMsg = err instanceof Error ? err.message : String(err);
+ (
+ self as unknown as { postMessage(message: unknown, transfer?: Transferable[]): void }
+ ).postMessage({ type: 'error', error: `${errMsg}\nTeX logs:\n${texLogs.join('\n')}` });
+ }
+ })();
}
});
diff --git a/src/main.ts b/src/main.ts
index 869d46c..8952af3 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -55,6 +55,7 @@ export default class LatexReferencer extends Plugin {
snippetManager!: SnippetManager;
customNoteManager!: CustomNoteManager;
tikzRenderer!: TikzRenderer;
+ isTikzEditorOpen = false;
async onload() {
await this.loadSettings();
@@ -315,7 +316,14 @@ export default class LatexReferencer extends Plugin {
typeof activeView?.getViewData === 'function' ? activeView.getViewData() : null;
if (!activeFile || targetFile.path !== activeFile.path || activeContent === null) {
- return oldFunc.call(this, hoverParent, targetEl, linktext, sourcePath, state);
+ return oldFunc.call(
+ this,
+ hoverParent,
+ targetEl,
+ linktext,
+ sourcePath,
+ state
+ ) as unknown;
}
const equations = getEquations(activeFile, activeContent);
@@ -325,14 +333,21 @@ export default class LatexReferencer extends Plugin {
const line = targetEquation.$position.start;
const newState = { ...state, scroll: line };
// Immediately call the original function with the correct line number
- return oldFunc.call(this, hoverParent, targetEl, linktext, sourcePath, newState);
+ return oldFunc.call(
+ this,
+ hoverParent,
+ targetEl,
+ linktext,
+ sourcePath,
+ newState
+ ) as unknown;
}
}
}
// If it's not our link, or if we couldn't find it in the cache,
// call the original function without modification.
- return oldFunc.call(this, hoverParent, targetEl, linktext, sourcePath, state);
+ return oldFunc.call(this, hoverParent, targetEl, linktext, sourcePath, state) as unknown;
};
}
});
diff --git a/src/styles/main.css b/src/styles/main.css
index 95ae12f..2d1f12f 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -355,6 +355,64 @@
/* 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 .modal-close-button,
+div.tikz-editor-modal .modal-close-button {
+ z-index: 10000;
+ pointer-events: auto;
+}
+
+.tikz-editor-modal button.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;
+ 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 +648,930 @@
position: sticky;
bottom: 0;
z-index: 1;
-}
\ No newline at end of file
+}
+
+/* ====== 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;
+ z-index: 5;
+ flex-shrink: 0;
+}
+@supports (user-select: none) {
+ .tikz-editor-ui .left-sidebar {
+ user-select: none;
+ }
+}
+
+.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 button.tool-btn {
+ width: 32px;
+ height: 32px;
+ 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 div.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;
+ min-height: 72px;
+}
+
+.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 button.pin-btn {
+ position: absolute;
+ top: 6px;
+ right: 6px;
+ width: 20px;
+ height: 20px;
+ 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;
+ overflow-wrap: 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 button.add-pkg-btn {
+ width: 24px;
+ height: 24px;
+ 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 button.close-btn {
+ font-size: 1.2em;
+ background: transparent;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ height: auto;
+}
+
+.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 button.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;
+}
+
+.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 div.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;
+}
+
+.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;
+}
+@supports (user-select: none) {
+ .tikz-editor-ui .tikz-canvas-controls .zoom-label {
+ 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 div.canvas-grid-container.is-grab,
+.tikz-editor-ui div.canvas-grid-container.is-grab * {
+ cursor: grab;
+}
+
+.tikz-editor-ui div.canvas-grid-container.is-grabbing,
+.tikz-editor-ui div.canvas-grid-container.is-grabbing * {
+ cursor: grabbing;
+}
+
+.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;
+ opacity: 0.12;
+}
+@supports (user-select: none) {
+ .tikz-editor-ui .axis-overlay text {
+ user-select: none;
+ }
+}
+
+.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;
+ padding: 4px 8px;
+ background-color: var(--background-primary);
+ border: 1px dashed var(--background-modifier-border);
+ border-radius: var(--radius-s);
+}
+@supports (user-select: none) {
+ .tikz-editor-ui .placed-element.is-text .text-element-content {
+ user-select: none;
+ }
+}
+
+.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 button.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;
+}
+
+.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 div.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;
+}
+
+.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%;
+}
+@supports (accent-color: red) {
+ .tikz-editor-ui input[type="range"] {
+ accent-color: var(--interactive-accent);
+ }
+}
+
+.tikz-editor-ui .presets-row {
+ display: flex;
+ gap: 4px;
+ margin-top: 6px;
+}
+
+.tikz-editor-ui button.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;
+}
+
+.tikz-editor-ui .preset-btn:hover {
+ background-color: var(--background-modifier-hover);
+}
+
+.tikz-editor-ui .delete-btn-wrap {
+ margin-top: 32px;
+}
+
+.tikz-editor-ui button.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;
+}
+
+.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);
+}
+@supports (resize: none) {
+ .tikz-editor-ui .code-panel textarea {
+ 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 button.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;
+}
+
+.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);
+}
diff --git a/src/ui/export-pdf/modal.ts b/src/ui/export-pdf/modal.ts
index 90b2dba..3dcea00 100644
--- a/src/ui/export-pdf/modal.ts
+++ b/src/ui/export-pdf/modal.ts
@@ -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();
}
}