diff --git a/web/powerpoint.html b/web/powerpoint.html index a1d06e8..7fd72b3 100644 --- a/web/powerpoint.html +++ b/web/powerpoint.html @@ -76,6 +76,17 @@ +
+ + Global preamble + + +
+
diff --git a/web/src/constants.ts b/web/src/constants.ts index dc44d01..2f2e237 100644 --- a/web/src/constants.ts +++ b/web/src/constants.ts @@ -7,11 +7,23 @@ */ export const SHAPE_CONFIG = { NAME: "Typst Shape", + ALT_TEXT_TITLE: "PPTypst shape", + ALT_TEXT_DESCRIPTION: "Generated by PPTypst. Edit this shape with the PPTypst add-in.", TAGS: { + KIND: "TypstKind", FONT_SIZE: "TypstFontSize", FILL_COLOR: "TypstFillColor", MATH_MODE: "TypstMathMode", }, + TAG_VALUES: { + KIND: "typst", + }, + CUSTOM_XML: { + NAMESPACE: "https://splines.github.io/pptypst/shape/v1", + ROOT: "pptypst:content", + PREAMBLE_NODE: "pptypst:preamble", + BODY_NODE: "pptypst:body", + }, } as const; /** @@ -29,6 +41,9 @@ export const DOM_IDS = { FILL_COLOR: "fillColor", PREVIEW_FILL_ENABLED: "previewFillEnabled", MATH_MODE_ENABLED: "mathModeEnabled", + PREAMBLE_DETAILS: "preambleDetails", + PREAMBLE_SUMMARY: "preambleSummary", + PREAMBLE_INPUT: "preambleInput", INPUT_WRAPPER: "inputWrapper", TYPST_INPUT: "typstInput", INSERT_BTN: "insertBtn", @@ -56,6 +71,8 @@ export const STORAGE_KEYS = { FILL_COLOR: "typstFillColor", PREVIEW_FILL: "typstPreviewFill", MATH_MODE: "typstMathMode", + PREAMBLE: "typstPreamble", + PREAMBLE_OPEN: "typstPreambleOpen", THEME: "typstTheme", } as const; @@ -101,4 +118,17 @@ export const BUTTON_TEXT = { export const DEFAULTS = { FONT_SIZE: "28", FILL_COLOR: "#000000", + PREAMBLE: "", +} as const; + +/** + * Preamble editor copy. + */ +export const PREAMBLE_UI = { + GLOBAL_LABEL: "Global preamble", + SHAPE_LABEL: "Shape preamble", + MULTI_LABEL: "Preamble", + GLOBAL_TITLE: "Used for new Typst shapes. Existing shapes keep their own preamble until you update them.", + SHAPE_TITLE: "This preamble belongs only to the selected shape and is saved when you update it.", + MULTI_TITLE: "Select a single Typst shape to edit its preamble, or clear the selection to edit the global default.", } as const; diff --git a/web/src/insertion.ts b/web/src/insertion.ts index 1483233..938ce5a 100644 --- a/web/src/insertion.ts +++ b/web/src/insertion.ts @@ -1,16 +1,30 @@ import { debug } from "./utils/logger.js"; import { applyFillColor, normalizeAlphaHexColors, parseAndApplySize } from "./svg.js"; import { typst } from "./typst.js"; -import { setStatus, getFontSize, getFillColor, getMathModeEnabled, getTypstCode } from "./ui.js"; -import { isTypstPayload, createTypstPayload, extractTypstCode } from "./payload.js"; +import { + setStatus, + getFontSize, + getFillColor, + getMathModeEnabled, + getTypstCode, + getPreambleCode, + getEditorMode, +} from "./ui.js"; +import { TypstSource } from "./payload.js"; import { storeValue } from "./utils/storage.js"; -import { lastTypstShapeId, TypstShapeInfo, writeShapeProperties, readShapeTag } from "./shape.js"; +import { + lastTypstShapeId, + TypstShapeInfo, + writeShapeProperties, + readShapeTag, + readTypstSource, + isLoadedTypstShape, +} from "./shape.js"; import { STORAGE_KEYS, SHAPE_CONFIG, FILL_COLOR_DISABLED } from "./constants.js"; type PreparedSvgResult = { svg: string; size: { width: number; height: number }; - payload: string; }; type SlideSize = { @@ -22,12 +36,12 @@ type SlideSize = { * Compiles Typst code to SVG and prepares it for insertion. */ async function prepareTypstSvg( - typstCode: string, + source: TypstSource, fontSize: string, fillColor: string | null, mathMode: boolean, ): Promise { - const result = await typst(typstCode, fontSize, mathMode); + const result = await typst(source, fontSize, mathMode); if (!result.svg) { // diagnostics are only shown for preview, not insertion return null; @@ -41,9 +55,8 @@ async function prepareTypstSvg( const serializer = new XMLSerializer(); const svg = serializer.serializeToString(svgElement); - const payload = createTypstPayload(typstCode); - return { svg, size, payload }; + return { svg, size }; } /** @@ -85,14 +98,28 @@ async function insertSvgAndTag( * Inserts or updates a Typst formula in PowerPoint. */ export async function insertOrUpdateFormula() { - const rawCode = getTypstCode(); + if (getEditorMode() === "multi-select") { + setStatus( + "Select a single Typst shape to update it, or clear the selection to insert a new one.", true); + return; + } + + const source: TypstSource = { + body: getTypstCode(), + preamble: getPreambleCode(), + }; const fontSize = getFontSize(); const fillColor = getFillColor(); const mathMode = getMathModeEnabled(); storeValue(STORAGE_KEYS.FONT_SIZE, fontSize); storeValue(STORAGE_KEYS.FILL_COLOR, fillColor); + if (getEditorMode() === "insert") { + // overwrite global preamble only when inserting a new shape, not when + // editing an existing shape + storeValue(STORAGE_KEYS.PREAMBLE, source.preamble); + } - const prepared = await prepareTypstSvg(rawCode, fontSize, fillColor, mathMode); + const prepared = await prepareTypstSvg(source, fontSize, fillColor, mathMode); if (!prepared) { setStatus("Typst compile failed.", true); return; @@ -100,12 +127,10 @@ export async function insertOrUpdateFormula() { try { await PowerPoint.run(async (context) => { - const selection = context.presentation.getSelectedShapes(); const selectedSlides = context.presentation.getSelectedSlides(); const allSlides = context.presentation.slides; const pageSetup = context.presentation.pageSetup; - selection.load("items"); selectedSlides.load("items"); allSlides.load("items"); pageSetup.load(["slideWidth", "slideHeight"]); @@ -131,7 +156,7 @@ export async function insertOrUpdateFormula() { let rotation: number | undefined; let isReplacing = false; - const typstShape = await findTypstShape(selection.items, allSlides.items, context); + const typstShape = await findTypstShape(allSlides.items, context); if (typstShape) { position = calculateCenteredPosition(typstShape, fittedSize); position = clampPositionWithinSlide(position, fittedSize, slideSize); @@ -145,7 +170,7 @@ export async function insertOrUpdateFormula() { const existingShapeIds = new Set(targetSlide.shapes.items.map(shape => shape.id)); const insertedShape = await insertSvgAndTag(prepared.svg, { - payload: prepared.payload, + source, fontSize, fillColor: fillColor || null, mathMode, @@ -170,13 +195,8 @@ export async function insertOrUpdateFormula() { /** * Finds a Typst shape in the current selection or uses cached selection. */ -async function findTypstShape(selectedShapes: PowerPoint.Shape[], allSlides: PowerPoint.Slide[], +async function findTypstShape(allSlides: PowerPoint.Slide[], context: PowerPoint.RequestContext): Promise { - const typstShape = selectedShapes.find( - shape => isTypstPayload(shape.altTextDescription), - ); - if (typstShape) return typstShape; - if (!lastTypstShapeId) return undefined; const id = lastTypstShapeId; @@ -188,7 +208,15 @@ async function findTypstShape(selectedShapes: PowerPoint.Shape[], allSlides: Pow await context.sync(); if (targetSlide.shapes.items.length === 0) return undefined; - return targetSlide.shapes.items.find(shape => shape.id === id.shapeId); + const cachedShape = targetSlide.shapes.items.find(shape => shape.id === id.shapeId); + if (!cachedShape) { + return undefined; + } + + cachedShape.load(["id", "altTextDescription", "left", "top", "width", "height", "rotation"]); + cachedShape.tags.load("items/key,items/value"); + await context.sync(); + return cachedShape; } catch (error) { debug("Fallback to last selection failed:", error); return undefined; @@ -242,9 +270,15 @@ export async function bulkUpdateFontSize() { selection.load("items"); await context.sync(); - const typstShapes = selection.items.filter(shape => - isTypstPayload(shape.altTextDescription), - ); + if (selection.items.length > 0) { + selection.items.forEach((shape) => { + shape.load(["id", "altTextDescription", "left", "top", "width", "height", "rotation"]); + shape.tags.load("items/key,items/value"); + }); + await context.sync(); + } + + const typstShapes = selection.items.filter(isLoadedTypstShape); if (typstShapes.length === 0) { setStatus("No Typst shapes selected.", true); @@ -263,7 +297,12 @@ export async function bulkUpdateFontSize() { for (const shape of typstShapes) { try { - const typstCode = extractTypstCode(shape.altTextDescription); + const source = await readTypstSource(shape, context); + if (!source) { + debug(`No Typst source stored for shape ${shape.id}`); + continue; + } + const storedFillColor = await readShapeTag(shape, SHAPE_CONFIG.TAGS.FILL_COLOR, context); const fillColor = !storedFillColor || storedFillColor === FILL_COLOR_DISABLED @@ -271,7 +310,7 @@ export async function bulkUpdateFontSize() { : storedFillColor; const mathMode = getMathModeEnabled(); - const prepared = await prepareTypstSvg(typstCode, newFontSize, fillColor, mathMode); + const prepared = await prepareTypstSvg(source, newFontSize, fillColor, mathMode); if (!prepared) { debug(`Typst compile failed for shape ${shape.id}`); continue; @@ -298,7 +337,7 @@ export async function bulkUpdateFontSize() { await context.sync(); const insertedShape = await insertSvgAndTag(prepared.svg, { - payload: prepared.payload, + source, fontSize: newFontSize, fillColor, mathMode, diff --git a/web/src/payload.ts b/web/src/payload.ts index 623b428..c1a5300 100644 --- a/web/src/payload.ts +++ b/web/src/payload.ts @@ -1,25 +1,90 @@ -import { decodeBase64, encodeBase64 } from "./utils/base64"; +import { decodeBase64 } from "./utils/base64"; +import { SHAPE_CONFIG } from "./constants.js"; -const TYPST_PREFIX = "TYPST:"; +const LEGACY_TYPST_PREFIX = "TYPST:"; + +export type TypstSource = { + preamble: string; + body: string; +}; /** - * Creates a Typst payload from source code. + * Checks if an alt text description contains a legacy Typst payload. */ -export function createTypstPayload(code: string): string { - return `${TYPST_PREFIX}${encodeBase64(code)}`; +export function isLegacyTypstPayload(altTextDescription: string | undefined): boolean { + return !!(altTextDescription && altTextDescription.startsWith(LEGACY_TYPST_PREFIX)); } /** - * Checks if an alt text description is a Typst payload. + * Extracts Typst code from a legacy payload. */ -export function isTypstPayload(altTextDescription: string | undefined): boolean { - return !!(altTextDescription && altTextDescription.startsWith(TYPST_PREFIX)); +export function extractLegacyTypstCode(payload: string): string { + const base64Payload = payload.split(LEGACY_TYPST_PREFIX)[1]; + return decodeBase64(base64Payload); } /** - * Extracts Typst code from a payload. + * Converts legacy payload data to the current Typst source structure. */ -export function extractTypstCode(payload: string): string { - const base64Payload = payload.split(TYPST_PREFIX)[1]; - return decodeBase64(base64Payload); +export function getLegacyTypstSource(payload: string): TypstSource { + return { + preamble: "", + body: extractLegacyTypstCode(payload), + }; +} + +/** + * Serializes Typst source for storage in a shape-scoped custom XML part. + */ +export function serializeTypstSource(source: TypstSource): string { + const documentNode = document.implementation.createDocument( + SHAPE_CONFIG.CUSTOM_XML.NAMESPACE, + SHAPE_CONFIG.CUSTOM_XML.ROOT, + null, + ); + const root = documentNode.documentElement; + + const preambleNode = documentNode.createElementNS( + SHAPE_CONFIG.CUSTOM_XML.NAMESPACE, + SHAPE_CONFIG.CUSTOM_XML.PREAMBLE_NODE, + ); + preambleNode.textContent = source.preamble; + + const bodyNode = documentNode.createElementNS( + SHAPE_CONFIG.CUSTOM_XML.NAMESPACE, + SHAPE_CONFIG.CUSTOM_XML.BODY_NODE, + ); + bodyNode.textContent = source.body; + + root.appendChild(preambleNode); + root.appendChild(bodyNode); + + return new XMLSerializer().serializeToString(documentNode); +} + +/** + * Parses Typst source from a shape-scoped custom XML part. + */ +export function parseTypstSource(xml: string): TypstSource | null { + const documentNode = new DOMParser().parseFromString(xml, "application/xml"); + if (documentNode.querySelector("parsererror")) { + return null; + } + + const preambleNode = documentNode + .getElementsByTagNameNS(SHAPE_CONFIG.CUSTOM_XML.NAMESPACE, "preamble") + .item(0); + + const bodyNode = documentNode + .getElementsByTagNameNS(SHAPE_CONFIG.CUSTOM_XML.NAMESPACE, "body") + .item(0); + + if (preambleNode === null || bodyNode === null) { + return null; + } + + return { + preamble: preambleNode.textContent, + body: bodyNode.textContent, + }; } diff --git a/web/src/preview.ts b/web/src/preview.ts index 6f45c90..98ac8a7 100644 --- a/web/src/preview.ts +++ b/web/src/preview.ts @@ -5,19 +5,21 @@ import { getAreaElement, getHTMLElement, getInputElement } from "./utils/dom"; import { getFillColor, getFontSize, + getPreambleCode, + getEditorMode, getMathModeEnabled, getTypstCode, setButtonEnabled, setMathModeEnabled, } from "./ui"; import { storeValue, getStoredValue } from "./utils/storage.js"; -import { lastTypstShapeId } from "./shape.js"; /** * Sets up event listeners for preview updates. */ export function setupPreviewListeners() { const typstInput = getAreaElement(DOM_IDS.TYPST_INPUT); + const preambleInput = getAreaElement(DOM_IDS.PREAMBLE_INPUT); const fontSizeInput = getInputElement(DOM_IDS.FONT_SIZE); const fillColorInput = getInputElement(DOM_IDS.FILL_COLOR); const fillColorEnabled = getInputElement(DOM_IDS.FILL_COLOR_ENABLED); @@ -29,6 +31,13 @@ export function setupPreviewListeners() { void updatePreview(); }); + preambleInput.addEventListener("input", () => { + if (getEditorMode() === "insert") { + storeValue(STORAGE_KEYS.PREAMBLE, getPreambleCode()); + } + void updatePreview(); + }); + fontSizeInput.addEventListener("input", () => { const fontSize = getFontSize(); storeValue(STORAGE_KEYS.FONT_SIZE, fontSize); @@ -56,7 +65,7 @@ export function setupPreviewListeners() { mathModeEnabled.addEventListener("change", () => { const mathMode = getMathModeEnabled(); - if (!lastTypstShapeId) { + if (getEditorMode() === "insert") { // Only save to storage when in insert mode (no shape selected) storeValue(STORAGE_KEYS.MATH_MODE, mathMode.toString()); } @@ -126,6 +135,7 @@ export function updateMathModeVisuals() { */ export async function updatePreview() { const rawCode = getTypstCode().trim(); + const preamble = getPreambleCode(); const fontSize = getFontSize(); const mathMode = getMathModeEnabled(); const previewElement = getHTMLElement(DOM_IDS.PREVIEW_CONTENT); @@ -138,11 +148,11 @@ export async function updatePreview() { return; } - const result = await typst(rawCode, fontSize, mathMode); + const result = await typst({ body: rawCode, preamble }, fontSize, mathMode); if (result.diagnostics && result.diagnostics.length > 0) { diagnosticsContainer.style.display = "block"; - displayDiagnostics(result.diagnostics, diagnosticsContent, mathMode); + displayDiagnostics(result.diagnostics, diagnosticsContent); } else { diagnosticsContainer.style.display = "none"; } @@ -174,7 +184,10 @@ export async function updatePreview() { /** * Displays diagnostics in the UI. */ -function displayDiagnostics(diagnostics: (string | DiagnosticMessage)[], content: HTMLElement, mathMode: boolean) { +function displayDiagnostics( + diagnostics: (string | DiagnosticMessage)[], + content: HTMLElement, +) { content.innerHTML = ""; diagnostics.forEach((diag, index) => { @@ -202,13 +215,7 @@ function displayDiagnostics(diagnostics: (string | DiagnosticMessage)[], content severitySpan.className = "diagnostic-severity"; severitySpan.textContent = diag.severity; - const rangeSpan = document.createElement("span"); - rangeSpan.className = "diagnostic-range"; - const rangeString = correctDiagnosticRange(diag.range, mathMode); - rangeSpan.textContent = rangeString; - headerDiv.appendChild(severitySpan); - headerDiv.appendChild(rangeSpan); const messageSpan = document.createElement("span"); messageSpan.className = "diagnostic-message"; @@ -225,30 +232,11 @@ function displayDiagnostics(diagnostics: (string | DiagnosticMessage)[], content * Updates the insert button enabled state based on whether there's input. */ export function updateButtonState() { + if (getEditorMode() === "multi-select") { + setButtonEnabled(false); + return; + } + const rawCode = getTypstCode().trim(); setButtonEnabled(rawCode.length > 0); } - -/** - * Corrects the diagnostic range to account for added lines in the Typst code. - * - * See `buildRawTypstString` for details. - * - * @param range The range string from the diagnostic - * @param mathMode Whether math mode is enabled (adds extra line offset) - */ -function correctDiagnosticRange(range: string, mathMode: boolean): string { - const rangeRegex = /(\d+):(\d+)-(\d+):(\d+)/; - const match = range.match(rangeRegex); - if (match) { - // buildRawTypstString adds 2 lines before user code - // If mathMode is enabled, it adds one more line for the opening $ - const offset = mathMode ? 3 : 2; - const startLine = parseInt(match[1], 10) - offset; - const startCol = parseInt(match[2], 10); - const endLine = parseInt(match[3], 10) - offset; - const endCol = parseInt(match[4], 10); - return `${startLine.toString()}:${startCol.toString()}-${endLine.toString()}:${endCol.toString()}`; - } - return range; -} diff --git a/web/src/selection.ts b/web/src/selection.ts index 75743c7..a8b3e0c 100644 --- a/web/src/selection.ts +++ b/web/src/selection.ts @@ -1,8 +1,19 @@ import { FILL_COLOR_DISABLED, SHAPE_CONFIG, DEFAULTS } from "./constants.js"; -import { extractTypstCode, isTypstPayload } from "./payload.js"; import { updatePreview, updateButtonState, restoreMathModeFromStorage, updateMathModeVisuals, syncPreviewFillToggleFromFillCheckbox } from "./preview.js"; -import { readShapeTag, setLastTypstId } from "./shape.js"; -import { setButtonText, setFillColor, setFontSize, setMathModeEnabled, setStatus, setTypstCode, setBulkUpdateButtonVisible, setFileButtonText } from "./ui.js"; +import { readShapeTag, readTypstSource, setLastTypstId, isLoadedTypstShape } from "./shape.js"; +import { + setButtonText, + setFillColor, + setFontSize, + setMathModeEnabled, + setStatus, + setTypstCode, + setPreambleCode, + restorePreambleFromStorage, + setBulkUpdateButtonVisible, + setFileButtonText, + setEditorMode, +} from "./ui.js"; import { debug } from "./utils/logger.js"; /** @@ -17,15 +28,18 @@ export async function handleSelectionChange() { await context.sync(); if (shapes.items.length > 0) { - shapes.items.forEach(shape => + shapes.items.forEach((shape) => { shape.load(["id", "altTextDescription", "left", "top", - "width", "height", "rotation", "tags"]), - ); + "width", "height", "rotation"]); + shape.tags.load("items/key,items/value"); + }); await context.sync(); } if (shapes.items.length === 0) { setLastTypstId(null); + setEditorMode("insert"); + restorePreambleFromStorage(); setButtonText(false); setBulkUpdateButtonVisible(false); setFileButtonText(false); @@ -33,12 +47,12 @@ export async function handleSelectionChange() { return; } - const typstShapes = shapes.items.filter(shape => - isTypstPayload(shape.altTextDescription), - ); + const typstShapes = shapes.items.filter(isLoadedTypstShape); if (typstShapes.length > 1) { // Multiple Typst shapes selected - show bulk update button + setEditorMode("multi-select"); + restorePreambleFromStorage(); setBulkUpdateButtonVisible(true); setButtonText(true); setFileButtonText(true); @@ -55,6 +69,8 @@ export async function handleSelectionChange() { } else { // No Typst shapes selected setLastTypstId(null); + setEditorMode("insert"); + restorePreambleFromStorage(); setButtonText(false); setFileButtonText(false); setBulkUpdateButtonVisible(false); @@ -69,7 +85,12 @@ export async function handleSelectionChange() { async function loadTypstShape(typstShape: PowerPoint.Shape, slideId: string | null, context: PowerPoint.RequestContext) { try { - const typstCode = extractTypstCode(typstShape.altTextDescription); + const typstSource = await readTypstSource(typstShape, context); + if (!typstSource) { + setStatus("Failed to read Typst source from selection.", true); + return; + } + const storedFontSize = await readShapeTag(typstShape, SHAPE_CONFIG.TAGS.FONT_SIZE, context); const storedFillColor = await readShapeTag(typstShape, SHAPE_CONFIG.TAGS.FILL_COLOR, context); const storedMathMode = await readShapeTag(typstShape, SHAPE_CONFIG.TAGS.MATH_MODE, context); @@ -87,7 +108,9 @@ async function loadTypstShape(typstShape: PowerPoint.Shape, slideId: string | nu setFillColor(fillColorToSet); syncPreviewFillToggleFromFillCheckbox(); - setTypstCode(typstCode); + setTypstCode(typstSource.body); + setPreambleCode(typstSource.preamble); + setEditorMode("edit"); setMathModeEnabled(storedMathMode === "true"); updateMathModeVisuals(); setLastTypstId({ slideId, shapeId: typstShape.id }); @@ -95,8 +118,8 @@ async function loadTypstShape(typstShape: PowerPoint.Shape, slideId: string | nu updateButtonState(); void updatePreview(); } catch (error) { - console.error("Decode error:", error); - setStatus("Failed to decode Typst payload from selection.", true); + console.error("Selection load error:", error); + setStatus("Failed to load Typst data from selection.", true); } } diff --git a/web/src/shape.ts b/web/src/shape.ts index c4f855e..72b3b7f 100644 --- a/web/src/shape.ts +++ b/web/src/shape.ts @@ -1,4 +1,11 @@ import { SHAPE_CONFIG, FILL_COLOR_DISABLED } from "./constants.js"; +import { + getLegacyTypstSource, + isLegacyTypstPayload, + parseTypstSource, + serializeTypstSource, + TypstSource, +} from "./payload.js"; import { debug } from "./utils/logger.js"; export type TypstShapeId = { @@ -16,7 +23,7 @@ export function setLastTypstId(info: TypstShapeId | null) { } export type TypstShapeInfo = { - payload: string; + source: TypstSource; fontSize: string; fillColor: string | null; mathMode: boolean; @@ -28,15 +35,25 @@ export type TypstShapeInfo = { /** * Writes shape properties and Typst metadata to a given shape. */ -export async function writeShapeProperties(shape: PowerPoint.Shape, info: TypstShapeInfo, - context: PowerPoint.RequestContext) { - shape.altTextDescription = info.payload; +export async function writeShapeProperties( + shape: PowerPoint.Shape, + info: TypstShapeInfo, + context: PowerPoint.RequestContext, +) { + shape.altTextTitle = SHAPE_CONFIG.ALT_TEXT_TITLE; + shape.altTextDescription = SHAPE_CONFIG.ALT_TEXT_DESCRIPTION; shape.name = SHAPE_CONFIG.NAME; + shape.tags.add(SHAPE_CONFIG.TAGS.KIND, SHAPE_CONFIG.TAG_VALUES.KIND); shape.tags.add(SHAPE_CONFIG.TAGS.FONT_SIZE, info.fontSize); shape.tags.add(SHAPE_CONFIG.TAGS.FILL_COLOR, info.fillColor === null ? FILL_COLOR_DISABLED : info.fillColor); shape.tags.add(SHAPE_CONFIG.TAGS.MATH_MODE, info.mathMode.toString()); + // There can't be leftover XML parts here, as we always create + // a new shape when updating + const serializedSource = serializeTypstSource(info.source); + shape.customXmlParts.add(serializedSource); + if (info.size.height > 0 && info.size.width > 0) { shape.height = info.size.height; shape.width = info.size.width; @@ -72,3 +89,44 @@ export async function readShapeTag( return null; } } + +/** + * Checks whether a loaded shape belongs to PPTypst. + */ +export function isLoadedTypstShape(shape: PowerPoint.Shape): boolean { + const hasMarkerTag = shape.tags.items.some(tag => + tag.key.toLowerCase() === SHAPE_CONFIG.TAGS.KIND.toLowerCase() + && tag.value === SHAPE_CONFIG.TAG_VALUES.KIND, + ); + + return hasMarkerTag || isLegacyTypstPayload(shape.altTextDescription); +} + +/** + * Reads stored Typst source from a shape. + */ +export async function readTypstSource( + shape: PowerPoint.Shape, + context: PowerPoint.RequestContext, +): Promise { + try { + const xmlParts = shape.customXmlParts.getByNamespace(SHAPE_CONFIG.CUSTOM_XML.NAMESPACE); + xmlParts.load("items/id"); + await context.sync(); + + if (xmlParts.items.length > 0) { + const latestPart = xmlParts.items[xmlParts.items.length - 1]; + const xmlResult = latestPart.getXml(); + await context.sync(); + return parseTypstSource(xmlResult.value); + } + } catch (error) { + debug("Error reading Typst custom XML part:", error); + } + + if (isLegacyTypstPayload(shape.altTextDescription)) { + return getLegacyTypstSource(shape.altTextDescription); + } + + return null; +} diff --git a/web/src/typst.ts b/web/src/typst.ts index 039db81..e06ae29 100644 --- a/web/src/typst.ts +++ b/web/src/typst.ts @@ -25,6 +25,7 @@ import typstCompilerWasm from "@myriaddreamin/typst-ts-web-compiler/pkg/typst_ts // @ts-expect-error WASM module import import typstRendererWasm from "@myriaddreamin/typst-ts-renderer/pkg/typst_ts_renderer_bg.wasm?url"; import { registryRequest } from "./registry/registry"; +import { TypstSource } from "./payload.js"; let compiler: typstWeb.TypstCompiler; let renderer: typstWeb.TypstRenderer; @@ -80,21 +81,21 @@ async function initRenderer() { /** * Builds the complete Typst code with page setup and font size. * - * Note: If you change the number of lines added here, make sure to update - * the diagnostic range offset in preview.ts accordingly. - * - * @param rawCode The user's Typst code + * @param source The user's Typst source * @param fontSize Font size in points * @param mathMode Whether to wrap the code in display math delimiters * @returns Complete Typst code ready for compilation */ -function buildRawTypstString(rawCode: string, fontSize: string, mathMode: boolean): string { - let code = rawCode; +function buildRawTypstString(source: TypstSource, fontSize: string, mathMode: boolean): string { + let body = source.body; if (mathMode) { - code = `$\n${rawCode}\n$`; + body = `$\n${source.body}\n$`; } + + const separator = source.preamble && body && !source.preamble.endsWith("\n") ? "\n" : ""; + const compiledUserSource = `${source.preamble}${separator}${body}`; return "#set page(margin: 3pt, background: none, width: auto, fill: none, height: auto)" - + `\n#set text(size: ${fontSize}pt)\n${code}`; + + `\n#set text(size: ${fontSize}pt)\n${compiledUserSource}`; } export interface CompilationResult { @@ -122,10 +123,12 @@ export type Diagnostics = (string | DiagnosticMessage)[] | undefined; /** * Compiles the given Typst source to SVG. */ -export async function typst(source: string, fontSize: string, mathMode: boolean): Promise { +export async function typst( + source: TypstSource, fontSize: string, mathMode: boolean, +): Promise { const mainFilePath = "/main.typ"; - const typstCode = buildRawTypstString(source, fontSize, mathMode); - compiler.addSource(mainFilePath, typstCode); + const builtSource = buildRawTypstString(source, fontSize, mathMode); + compiler.addSource(mainFilePath, builtSource); const response = await compiler.compile({ mainFilePath }); const diagnostics: Diagnostics = response.diagnostics; diff --git a/web/src/ui.ts b/web/src/ui.ts index 918ac71..46e971a 100644 --- a/web/src/ui.ts +++ b/web/src/ui.ts @@ -1,9 +1,25 @@ -import { DOM_IDS, DEFAULTS, BUTTON_TEXT, STORAGE_KEYS, FILL_COLOR_DISABLED } from "./constants.js"; -import { getInputElement, getHTMLElement, getAreaElement, getButtonElement } from "./utils/dom.js"; +import { + DOM_IDS, + DEFAULTS, + BUTTON_TEXT, + STORAGE_KEYS, + FILL_COLOR_DISABLED, + PREAMBLE_UI, +} from "./constants.js"; +import { + getInputElement, + getHTMLElement, + getAreaElement, + getButtonElement, + getDetailsElement, +} from "./utils/dom.js"; import { insertOrUpdateFormula, bulkUpdateFontSize } from "./insertion.js"; -import { getStoredValue } from "./utils/storage.js"; +import { getStoredValue, storeValue } from "./utils/storage.js"; import { handleGenerateFromFile } from "./file/file.js"; +export type EditorMode = "insert" | "edit" | "multi-select"; +let currentEditorMode: EditorMode = "insert"; + /** * Initializes the UI state. */ @@ -23,10 +39,18 @@ export function initializeUIState() { setMathModeEnabled(savedMathMode === "true"); } + const savedPreamble = getStoredValue(STORAGE_KEYS.PREAMBLE); + setPreambleCode(savedPreamble || DEFAULTS.PREAMBLE); + + const preambleDetails = getDetailsElement(DOM_IDS.PREAMBLE_DETAILS); + preambleDetails.open = getStoredValue(STORAGE_KEYS.PREAMBLE_OPEN) === "true"; + const savedPreviewFill = getStoredValue(STORAGE_KEYS.PREVIEW_FILL); if (savedPreviewFill !== null) { setPreviewFillEnabled(savedPreviewFill === "true"); } + + updatePreamblePresentation(); } /** @@ -49,6 +73,23 @@ export function setupEventListeners() { const typstInput = getAreaElement(DOM_IDS.TYPST_INPUT); typstInput.addEventListener("keydown", handleCtrlEnter); + const preambleInput = getAreaElement(DOM_IDS.PREAMBLE_INPUT); + preambleInput.addEventListener("keydown", handleCtrlEnter); + preambleInput.addEventListener("input", updatePreamblePresentation); + + const preambleDetails = getDetailsElement(DOM_IDS.PREAMBLE_DETAILS); + preambleDetails.addEventListener("toggle", () => { + storeValue(STORAGE_KEYS.PREAMBLE_OPEN, preambleDetails.open.toString()); + + if (preambleDetails.open && !preambleInput.disabled) { + requestAnimationFrame(() => { + preambleInput.focus(); + const cursor = preambleInput.value.length; + preambleInput.setSelectionRange(cursor, cursor); + }); + } + }); + const fontSizeInput = getInputElement(DOM_IDS.FONT_SIZE); fontSizeInput.addEventListener("keydown", handleCtrlEnter); @@ -140,6 +181,44 @@ export function setTypstCode(typstCode: string) { getAreaElement(DOM_IDS.TYPST_INPUT).value = typstCode; } +/** + * @returns Typst preamble from the UI input + */ +export function getPreambleCode(): string { + return getAreaElement(DOM_IDS.PREAMBLE_INPUT).value; +} + +/** + * Sets the Typst preamble in the UI input. + */ +export function setPreambleCode(preamble: string) { + getAreaElement(DOM_IDS.PREAMBLE_INPUT).value = preamble; + updatePreamblePresentation(); +} + +/** + * Restores the global preamble from local storage. + */ +export function restorePreambleFromStorage() { + const savedPreamble = getStoredValue(STORAGE_KEYS.PREAMBLE); + setPreambleCode(savedPreamble || DEFAULTS.PREAMBLE); +} + +/** + * @returns the current editor mode. + */ +export function getEditorMode(): EditorMode { + return currentEditorMode; +} + +/** + * Tracks whether the task pane is inserting, editing, or locked by multi-select. + */ +export function setEditorMode(mode: EditorMode) { + currentEditorMode = mode; + updatePreamblePresentation(); +} + /** * Updates the button text based on whether a Typst shape is selected. */ @@ -195,3 +274,29 @@ export function setFileButtonText(isEditingExistingFormula: boolean) { const button = getButtonElement(DOM_IDS.GENERATE_FROM_FILE_BTN); button.textContent = isEditingExistingFormula ? BUTTON_TEXT.UPDATE_FROM_FILE : BUTTON_TEXT.GENERATE_FROM_FILE; } + +/** + * Updates the preamble panel label and locked state. + */ +function updatePreamblePresentation() { + const preambleInput = getAreaElement(DOM_IDS.PREAMBLE_INPUT); + const preambleDetails = getDetailsElement(DOM_IDS.PREAMBLE_DETAILS); + const summaryElement = getHTMLElement(DOM_IDS.PREAMBLE_SUMMARY); + + const isLocked = currentEditorMode === "multi-select"; + + let labelText: string = PREAMBLE_UI.GLOBAL_LABEL; + let summaryTitle: string = PREAMBLE_UI.GLOBAL_TITLE; + if (currentEditorMode === "edit") { + labelText = PREAMBLE_UI.SHAPE_LABEL; + summaryTitle = PREAMBLE_UI.SHAPE_TITLE; + } else if (currentEditorMode === "multi-select") { + labelText = PREAMBLE_UI.MULTI_LABEL; + summaryTitle = PREAMBLE_UI.MULTI_TITLE; + } + + preambleInput.disabled = isLocked; + preambleDetails.classList.toggle("is-readonly", isLocked); + summaryElement.textContent = labelText; + summaryElement.title = summaryTitle; +} diff --git a/web/src/utils/dom.ts b/web/src/utils/dom.ts index bafad41..699ec34 100644 --- a/web/src/utils/dom.ts +++ b/web/src/utils/dom.ts @@ -43,6 +43,13 @@ export function getButtonElement(id: string): HTMLButtonElement { return getElement(id, HTMLButtonElement); } +/** + * Gets an HTMLDetailsElement by ID. + */ +export function getDetailsElement(id: string): HTMLDetailsElement { + return getElement(id, HTMLDetailsElement); +} + /** * Gets a generic HTMLElement by ID. */ diff --git a/web/styles/main.css b/web/styles/main.css index 8d68446..8def53c 100644 --- a/web/styles/main.css +++ b/web/styles/main.css @@ -27,11 +27,12 @@ body { margin: 0; padding: 15px; - padding-bottom: 60px; /* Space for sticky status bar */ + padding-bottom: 100px; /* Space for sticky status bar and final controls */ font-family: sans-serif; background: var(--bg-primary); color: var(--text-primary); zoom: 114%; + scroll-padding-bottom: 100px; transition: background 0.2s, color 0.2s; } @@ -117,6 +118,51 @@ hr { user-select: none; } +.preamble-panel { + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-secondary); + overflow: hidden; + + &.is-readonly { + opacity: 0.8; + } +} + +.preamble-summary { + padding: 8px 10px; + cursor: pointer; + list-style: none; + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; + user-select: none; + + &::-webkit-details-marker { + display: none; + } +} + +.preamble-summary:hover { + background: color-mix(in srgb, var(--bg-secondary) 88%, var(--text-primary) 12%); +} + +#preambleInput { + width: calc(100% - 20px); + border: 1px solid var(--border-color); + border-radius: 4px; + margin: 0 10px 10px; + height: 84px; + min-height: 64px; + font-size: 0.8rem; + font-weight: 600; +} + +#preambleInput:disabled { + opacity: 0.7; + cursor: not-allowed; +} + #mathDelimiterTop { border-bottom: none; border-radius: 4px 4px 0 0;