diff --git a/src/features/tikz/live-preview-overlay.ts b/src/features/tikz/live-preview-overlay.ts new file mode 100644 index 0000000..cefe4f7 --- /dev/null +++ b/src/features/tikz/live-preview-overlay.ts @@ -0,0 +1,259 @@ +import { Extension, Prec } from "@codemirror/state"; +import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view"; +import { MarkdownRenderChild } from "obsidian"; +import LatexReferencer from "../../main"; + +class TikzLivePreviewOverlay { + private overlayEl: HTMLDivElement | null = null; + private containerEl: HTMLDivElement | null = null; + private currentSource: string = ""; + private debounceTimeout: any = null; + private isDragging = false; + private startX = 0; + private startY = 0; + private startLeft = 0; + private startTop = 0; + + constructor(private view: EditorView, private plugin: LatexReferencer) {} + + public updateSource(source: string) { + this.ensureOverlayCreated(); + + if (this.currentSource === source) { + return; + } + this.currentSource = source; + + // Debounce compilation to prevent excessive UI lag/compiles + if (this.debounceTimeout) { + clearTimeout(this.debounceTimeout); + } + this.debounceTimeout = setTimeout(() => { + this.renderTikz(); + }, 300); + } + + private ensureOverlayCreated() { + if (this.overlayEl) return; + + const doc = this.view.dom.ownerDocument; + const body = doc.body; + + this.overlayEl = doc.createElement("div"); + this.overlayEl.classList.add("tikz-live-preview-overlay"); + + // CSS Styles for floating overlay + this.overlayEl.style.position = "fixed"; + this.overlayEl.style.zIndex = "1000"; + this.overlayEl.style.top = "100px"; + this.overlayEl.style.right = "50px"; + this.overlayEl.style.width = "320px"; + this.overlayEl.style.height = "320px"; + this.overlayEl.style.backgroundColor = "var(--background-primary-alt)"; + this.overlayEl.style.border = "1px solid var(--border-color)"; + this.overlayEl.style.borderRadius = "8px"; + this.overlayEl.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.15)"; + this.overlayEl.style.display = "flex"; + this.overlayEl.style.flexDirection = "column"; + this.overlayEl.style.overflow = "hidden"; + + // Drag Handle + const handleEl = doc.createElement("div"); + handleEl.classList.add("tikz-live-preview-handle"); + handleEl.style.cursor = "move"; + handleEl.style.padding = "6px"; + handleEl.style.backgroundColor = "var(--background-secondary-alt)"; + handleEl.style.borderBottom = "1px solid var(--border-color)"; + handleEl.style.fontSize = "0.85em"; + handleEl.style.fontWeight = "bold"; + handleEl.style.textAlign = "center"; + handleEl.style.color = "var(--text-muted)"; + handleEl.style.userSelect = "none"; + handleEl.textContent = "TikZ Live Preview"; + this.overlayEl.appendChild(handleEl); + + // Container for TikZ render + this.containerEl = doc.createElement("div"); + this.containerEl.classList.add("tikz-live-preview-container"); + this.containerEl.classList.add("block-language-tikz"); + this.containerEl.style.flex = "1"; + this.containerEl.style.overflow = "auto"; + this.containerEl.style.display = "flex"; + this.containerEl.style.justifyContent = "center"; + this.containerEl.style.alignItems = "center"; + this.containerEl.style.padding = "10px"; + this.containerEl.style.backgroundColor = "transparent"; + this.overlayEl.appendChild(this.containerEl); + + // Drag functionality + handleEl.onmousedown = (e: MouseEvent) => { + this.isDragging = true; + this.startX = e.clientX; + this.startY = e.clientY; + this.startLeft = this.overlayEl!.offsetLeft; + this.startTop = this.overlayEl!.offsetTop; + e.preventDefault(); + }; + + const mouseMoveHandler = (e: MouseEvent) => { + if (!this.isDragging || !this.overlayEl) return; + const dx = e.clientX - this.startX; + const dy = e.clientY - this.startY; + + let newLeft = this.startLeft + dx; + let newTop = this.startTop + dy; + + const maxLeft = doc.defaultView!.innerWidth - this.overlayEl.offsetWidth; + const maxTop = doc.defaultView!.innerHeight - this.overlayEl.offsetHeight; + + newLeft = Math.max(0, Math.min(newLeft, maxLeft)); + newTop = Math.max(0, Math.min(newTop, maxTop)); + + this.overlayEl.style.left = `${newLeft}px`; + this.overlayEl.style.top = `${newTop}px`; + this.overlayEl.style.right = "auto"; + }; + + const mouseUpHandler = () => { + this.isDragging = false; + }; + + doc.addEventListener("mousemove", mouseMoveHandler); + doc.addEventListener("mouseup", mouseUpHandler); + + // Save reference to clean up event listeners later + (this.overlayEl as any)._cleanup = () => { + doc.removeEventListener("mousemove", mouseMoveHandler); + doc.removeEventListener("mouseup", mouseUpHandler); + }; + + body.appendChild(this.overlayEl); + this.renderTikz(); + } + + private renderTikz() { + if (!this.containerEl) return; + this.containerEl.empty(); + + if (!this.currentSource.trim()) { + return; + } + + let code = this.currentSource.trim(); + code = code.replaceAll(" ", ""); + code = code.split("\n").map(l => l.trim()).filter(l => l).join("\n"); + + if (!code.includes("\\begin{document}")) { + code = "\\begin{document}\n" + code + "\n\\end{document}"; + } + + const script = this.containerEl.createEl("script"); + script.setAttribute("type", "text/tikz"); + script.setAttribute("data-show-console", "true"); + script.textContent = code; + } + + public destroy() { + if (this.debounceTimeout) { + clearTimeout(this.debounceTimeout); + } + if (this.overlayEl) { + if (typeof (this.overlayEl as any)._cleanup === "function") { + (this.overlayEl as any)._cleanup(); + } + this.overlayEl.remove(); + this.overlayEl = null; + } + this.containerEl = null; + } +} + +export const createTikzLivePreviewPlugin = (plugin: LatexReferencer): Extension => { + return Prec.highest( + ViewPlugin.fromClass( + class { + private previewOverlay: TikzLivePreviewOverlay | null = null; + + constructor(private view: EditorView) {} + + update(update: ViewUpdate) { + if (!plugin.settings.enableTikzjax) { + this.cleanup(); + return; + } + + const state = update.state; + const pos = state.selection.main.head; + + const tikzBlock = this.getTikzBlockAtPos(state, pos); + if (tikzBlock) { + if (!this.previewOverlay) { + this.previewOverlay = new TikzLivePreviewOverlay(this.view, plugin); + } + this.previewOverlay.updateSource(tikzBlock.source); + } else { + this.cleanup(); + } + } + + private getTikzBlockAtPos(state: any, pos: number): { source: string } | 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); + } + return { source: lines.join("\n") }; + } + } catch (e) { + // Fail silently on line errors + } + return null; + } + + private cleanup() { + if (this.previewOverlay) { + this.previewOverlay.destroy(); + this.previewOverlay = null; + } + } + + destroy() { + this.cleanup(); + } + } + ) + ); +}; diff --git a/src/main.ts b/src/main.ts index 2c152e3..9c3a9b9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -31,6 +31,7 @@ import { processZoteroCleanup } from 'features/zotero-cleanup'; import { CustomNoteManager } from 'features/custom-notes/manager'; import { TikzRenderer } from "./features/tikz/renderer"; import { createRowLayoutProcessor, createLivePreviewRowLayoutPlugin } from "./features/tikz/row-layout"; +import { createTikzLivePreviewPlugin } from "./features/tikz/live-preview-overlay"; const isDev = process.env.NODE_ENV === "development"; @@ -218,6 +219,7 @@ export default class LatexReferencer extends Plugin { this.editorExtensions.push(createEquationNumberPlugin(this)); this.editorExtensions.push(createLivePreviewLinkRendererPlugin(this)); this.editorExtensions.push(createLivePreviewRowLayoutPlugin(this)); + this.editorExtensions.push(createTikzLivePreviewPlugin(this)); this.app.workspace.updateOptions(); }