feat(tikz): major rendering, layout, and editor enhancements (#1)

Fixes
- WebAssembly & initialization
  - Fix TikZJax WebAssembly initialization issues.

- Fonts & symbol rendering
  - Override DVI parser `machine.putText` to use split character code offsets from `tikzjax.js`.
  - Map codes 0–9 starting at 161 to fix incorrect Γ rendering (was showing as Θ).
  - Map codes 10–19 starting at 173 to resolve Ω and ⊗ collisions with soft hyphen.

- Layout & rendering
  - Fix SVG clipping by switching to `position: relative` with `overflow: visible`.
  - Adjust inline SVG behavior to prevent top-of-page clipping.

- Type compatibility
  - Cast types to `unknown` in `TikzJaxLoader` and worker functions for compatibility.

- Error handling & cleanup
  - Improve error handling across TikZ components.
  - Remove unused dependencies and unnecessary console logs.

Features
- Package loading system
  - Add robust dependency resolution in `loader.ts` for:
    - `tikz-cd`
    - `tikz-feynhand`
    - `pgfcalendar`
  - Ensure required libraries and keys load dynamically on demand.

- Rendering & layout improvements
  - Enhance SVG bounding box calculation.
  - Implement dynamic column width calculation.
  - Improve row and column layout alignment and responsiveness.

- TikZ editor enhancements
  - Add keyboard shortcut tooltips to sidebar buttons.
  - Introduce thickness control slider for elements.
  - Improve snapping system with modes:
    - grid
    - half
    - none
  - Add support for new shapes:
    - circles
    - rectangles
    - triangles
  - Improve TikZ code generation error handling.

Improvements
- Selection & editing UX
  - Add lasso selection for multiple vertices in `CanvasGrid`.
  - Support multi-element editing in `RightSidebar`.
  - Refactor `TikzEditorModal` to handle multiple selected vertices.
  - Enable batch updates for selected elements.

- Layout & styling
  - Compact `.block-language-tikz` layout:
    - margin: `1.5em 0` → `0.3em 0`
    - padding: `1rem 0` → `0`
  - Improve canvas controls and interaction styles.

- Component refactoring
  - Refactor `CanvasGrid` to use template literals.
  - Replace inline styles with `setCssStyles`.
  - Improve `TikzCodec` node naming clarity.
  - Improve `history manager` type safety with `EditorElement`.
  - Use `window.setTimeout` in asset manager for better compatibility.

- Accessibility & UI polish
  - Improve sidebar button titles and tooltips.
  - Standardize live preview overlay button titles.
  - Clean up modal styling and text consistency.

Documentation
- Revamp TeXcore plugin documentation:
  - Clarify PDF export features and settings.
  - Improve quick preview architecture explanation.
  - Enhance equation search and autocomplete docs.
  - Streamline snippets usage and transformations.
  - Expand TikZ diagrams section with graphical editor guide.
  - Improve getting started guide with clearer steps.
  - Add navigation and configuration updates.

- Update plugin manifest descriptions for clarity.
This commit is contained in:
Jovi Koikkara 2026-06-06 12:08:11 +02:00 committed by GitHub
parent bf83c2a9d7
commit eea4d67e3e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 5959 additions and 1479 deletions

View file

@ -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.
<img src="docs/assets/branding/v0.0.2.gif" alt="Prisma Calendar Preview" width="100%">
📚 **[Full Documentation & Guides](https://youfoundjk.github.io/TeXcore/)**

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 MiB

View file

@ -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:
<div class="grid cards" markdown>
- :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.
</div>

View file

@ -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 `<h1>` 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 `<h1>` 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 (h1h6) 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
<div style="width: 100vw;font-size:10px;text-align:center;">
<span class="title"></span>
</div>
```
- **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
<div style="width: 100vw;font-size:10px;text-align:center;">
<span class="pageNumber"></span> / <span class="totalPages"></span>
</div>
```
- **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. |

View file

@ -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 <!-- This breaks out! -->
$$
```
**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).

View file

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

41
docs/features/index.md Normal file
View file

@ -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:
<div class="grid cards" markdown>
- :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.
</div>

View file

@ -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
<div style="width: 100vw; font-size: 10px; text-align: center;">
<span class="title"></span>
</div>
```
**Footer (page numbers):**
```html
<div style="width: 100vw; font-size: 10px; text-align: center;">
<span class="pageNumber"></span> / <span class="totalPages"></span>
</div>
```
## Additional Options
### Include File Name as Title
Adds an `<h1>` 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 (0100%) 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:
- `<span class="title"></span>`: Resolves to note file title.
- `<span class="pageNumber"></span>`: Resolves to current page index.
- `<span class="totalPages"></span>`: Resolves to total compiled pages.
- `<span class="date"></span>`: Inserts local print timestamp.
- `<span class="url"></span>`: 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
<div style="width: 100vw; font-size: 10px; text-align: center;">
<span class="title"></span>
</div>
```
## Table of Contents
=== "Footer Template"
```html
<div style="width: 100vw; font-size: 10px; text-align: center;">
<span class="pageNumber"></span> / <span class="totalPages"></span>
</div>
```
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.

View file

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

View file

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

View file

@ -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 (`<mark>...</mark>` 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.

View file

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

View file

@ -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: `<vault>/.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: <name>`) 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.

View file

@ -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:
<div class="grid cards" markdown>
- :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.
</div>
## 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)
!!! 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).

View file

@ -4,7 +4,7 @@ import { builtinModules } from 'node:module';
import fs from 'fs';
import path from 'path';
import inlineWorkerPlugin from 'esbuild-plugin-inline-worker';
import esbuildSvelte from 'esbuild-svelte';
const builtins = [
...new Set([...builtinModules, ...builtinModules.map(moduleName => `node:${moduleName}`)])
@ -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) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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(
`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-loader spin-icon" style="animation: spin 1s linear infinite;">
<path d="M12 2v4"/>
<path d="m16.2 6.2 2.9-2.9"/>
<path d="M18 12h4"/>
<path d="m16.2 17.8 2.9 2.9"/>
<path d="M12 18v4"/>
<path d="m4.9 19.1 2.9-2.9"/>
<path d="M2 12h4"/>
<path d="m4.9 4.9 2.9 2.9"/>
</svg>`,
'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(
`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-check-big" style="color: var(--text-success, #4caf50);">
<path d="m9 11 3 3L22 4"/>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
</svg>`,
'image/svg+xml'
);
const svg = doc.documentElement;
iconSpan.appendChild(activeDocument.importNode(svg, true));
}
}
}
}
destroy() {
if (this.container) {
this.container.remove();
}
}
}

View file

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

View file

@ -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: `<svg viewBox="0 0 40 40"><line x1="0" y1="20" x2="40" y2="20" stroke="currentColor" stroke-width="2"/></svg>`,
tikzCommand: '\\draw[line width=0.8pt] ({x}, {y}) -- ({x2}, {y2});'
});
}
}
}
this.wireStart = null;
this.wireCurrent = null;
this.renderWires();
activeDocument.removeEventListener('mousemove', onMouseMove);
activeDocument.removeEventListener('mouseup', onMouseUp);
};
activeDocument.addEventListener('mousemove', onMouseMove);
activeDocument.addEventListener('mouseup', onMouseUp);
} else if (activeTool === 'text') {
const template = activeTemplate ?? {
name: 'Text',
type: 'text',
category: 'Basic',
svgMarkup: `<svg viewBox="0 0 40 40" width="30" height="30" style="color: var(--text-normal);"><text x="50%" y="60%" dominant-baseline="middle" text-anchor="middle" font-size="22" font-family="serif" font-weight="bold" fill="currentColor">Aa</text></svg>`,
tikzCommand: '\\node[font={fontSize}] at ({x}, {y}) {{label}};'
};
this.context.handleAddElement({
type: 'text',
name: 'Text',
x: coords.x,
y: coords.y,
label: template.name === 'Text' ? 'Label Text' : template.name,
rotation: 0,
style: {
bold: false,
italic: false,
math: true,
color: '#f8e7ad',
fontSize: 12,
thickness: 1.0
},
svgMarkup: template.svgMarkup,
tikzCommand: template.tikzCommand
});
} else if (activeTool === 'select') {
this.context.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);
}
}

View file

@ -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]',
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m3 3 7.07 16.97 2.51-7.39 7.39-2.51L3 3z"/><path d="m13 13 6 6"/></svg>`
);
// Wire button
const wireBtn = toolbar.createEl('button', { cls: 'tool-btn', title: 'Wire [w]' });
wireBtn.createDiv({ cls: 'wire-icon' });
if (this.context.getActiveTool() === 'wire') wireBtn.addClass('active');
wireBtn.onclick = () => {
this.context.handleSelectTool('wire');
};
addToolBtn(
'text',
'Text [T]',
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg>`
);
addToolBtn(
'erase',
'Eraser [E]',
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.4 5.4c1 1 1 2.5 0 3.4L13 21Z"/><path d="M22 21H7"/><path d="m5 11 9 9"/></svg>`
);
toolbar.createDiv({ cls: 'divider' });
// Snap to grid (Full)
const snapBtn = toolbar.createEl('button', {
cls: 'tool-btn',
title: 'Snap to grid (full) [g]'
});
const snapParser = new DOMParser();
const snapDoc = snapParser.parseFromString(
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/><path d="M9 3v18"/><path d="M15 3v18"/></svg>`,
'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(
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="2" y1="2" x2="22" y2="22"/><path d="M7 21v-8M7 9V3M17 21v-2M17 13V3M3 7h4M13 7h8M3 17h18"/></svg>`,
'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(
`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>`,
'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(
`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>`,
'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(
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`,
'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(
`<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/></svg>`,
'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<string, ComponentTemplate[]>();
if (!searchQuery.trim()) {
categoriesMap.set('Basic', basic);
} else {
filtered.forEach(c => {
if (!categoriesMap.has(c.category)) {
categoriesMap.set(c.category, []);
}
categoriesMap.get(c.category)?.push(c);
});
}
if (categoriesMap.size === 0) {
libraryContainer.createDiv({
cls: 'empty-library',
text: 'No components found. Try installing packages!'
});
return;
}
categoriesMap.forEach((comps, category) => {
const section = libraryContainer.createDiv({ cls: 'category-section' });
section.createDiv({ cls: 'category-header', text: category });
const grid = section.createDiv({ cls: 'grid' });
comps.forEach(comp => {
const isActive = this.context.getActiveTemplate()?.name === comp.name;
const card = grid.createDiv({
cls: `comp-card${isActive ? ' active-template' : ''}`,
attr: { role: 'button', tabindex: '0', title: `Click to select ${comp.name}` }
});
// Pin button for non-core packages if search active
if (searchQuery.trim() && !core.some(coreComp => coreComp.name === comp.name)) {
const isPinned = pinnedComponents.includes(comp.name);
const pinBtn = card.createEl('button', {
cls: `pin-btn${isPinned ? ' pinned' : ''}`,
title: isPinned ? 'Pinned in Basic' : 'Pin to Basic'
});
const pinParser = new DOMParser();
const pinDoc = pinParser.parseFromString(
`<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-.44-1.24l-2.78-3.47A2 2 0 0 1 15 9.3V5a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v4.3a2 2 0 0 1-.78 1.23l-2.78 3.5a2 2 0 0 0-.44 1.24z"/></svg>`,
'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');
}
}

View file

@ -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(
`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="10" x2="20" y2="3"/><line x1="2" y1="14" x2="6" y2="14"/><line x1="10" y1="8" x2="14" y2="8"/><line x1="18" y1="16" x2="22" y2="16"/></svg> `,
'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(
`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg> `,
'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(
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>`,
'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(
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>`,
'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(
`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`,
'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
}
}
}

View file

@ -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<EditorElement, 'id'>) {
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<HTMLElement>('.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(
`<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>`,
'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(
`<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>`,
'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(
`<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/></svg>`,
'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(
`<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 2.7"/></svg>`,
'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 }
);
}
}

View file

@ -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<EditorElement, 'id'>): 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<void>;
handleUninstallPackage(pkgName: string): Promise<void>;
// View triggers
renderLeftSidebar(): void;
renderCanvas(): void;
renderRightSidebar(): void;
// State mutations
toggleSnapToGrid(): void;
toggleHalfGrid(): void;
togglePackageManager(): void;
switchTab(tab: 'edit' | 'code'): void;
setSnappingMode(mode: 'grid' | 'half' | 'none'): void;
}

View file

@ -0,0 +1,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 = [];
}
}

View file

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

View file

@ -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(
`<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-pencil"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>`,
'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;
}

View file

@ -7,10 +7,16 @@ export class TikzRenderer {
private tikzjaxCss: string = '';
private isLoaded: boolean = false;
private renderCache = new Map<string, string>();
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<SVGElement> {
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;
}

View file

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

View file

@ -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(/<svg/g) || []).length;
this.svgDepth -= (svgStr.match(/<\/svg>/g) || []).length;
let replacedSvg = svgStr.replace(
'<svg beginpicture>',
`<svg beginpicture width="10pt" height="10pt" viewBox="0 0 10 10" style="overflow: visible; position: relative;">`
);
replacedSvg = replacedSvg.replace(
'<svg>',
`<svg width="10pt" height="10pt" viewBox="0 0 10 10" style="overflow: visible; position: relative;">`
);
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(
`<span style="background: ${this.color}; position: absolute; top: ${top}pt; left: ${left}pt; width:${b}pt; height: ${a}pt;"></span>\n`
);
} else {
this.output.write(
`<rect x="${left}" y="${top}" width="${b}" height="${a}" fill="${this.color}" />\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<number, FontMetricCharacter | undefined>;
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(
`<span style="color: ${this.color}; font-family: ${this.font.name}; font-size: ${fontsize}pt; position: absolute; top: ${top - height}pt; left: ${left}pt; overflow: visible;"><span style="margin-top: -${fontsize}pt; line-height: 0pt; height: ${fontsize}pt; display: inline-block; vertical-align: baseline; ">${htmlText}</span><span style="display: inline-block; vertical-align: ${height}pt; height: 0pt; line-height: 0;"></span></span>\n`
);
} else {
const bottom = this.position.v * this.pointsPerDviUnit;
this.output.write(
`<text alignment-baseline="baseline" y="${bottom}" x="${left}" style="font-family: ${this.font.name}; font-size: ${fontsize};">${htmlText}</text>\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<SVGElement> {
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('</div>');
}
currentPageIndex++;
pageSizes[currentPageIndex] = { width: 0, height: 0 };
page.write(`<div class="texcore-page" data-page="${currentPageIndex}">`);
} 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('</div>');
}
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 };
}

View file

@ -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<string, string>)[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<string, Uint8Array> = {};
// Preloaded file dict (mirrors original's `cq`) — filename → content
let preloadedFiles: Record<string, Uint8Array> = {};
// ─── 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<ArrayBuffer>;
} else if (filename.endsWith('.tfm')) {
const fontName = filename.replace(/\.tfm$/, '');
const data = lookupTfmData(fontName);
if (data) content = data as unknown as Uint8Array<ArrayBuffer>;
} 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<string, Uint8Array>
): Promise<Uint8Array> {
// ─── Main Compile Function ────────────────────────────────────────────────────
async function compile(code: string, filesMap: Record<string, Uint8Array>): Promise<Uint8Array> {
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<void>(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<string, Uint8Array>;
};
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')}` });
}
})();
}
});

View file

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

View file

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

View file

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