From 710302497d3f299ae942b050e541540932c8d771 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 31 Dec 2025 10:45:06 +0000 Subject: [PATCH 1/8] Initial plan From 17ec9c339684abf5f36cae49c24913b465c78e09 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 31 Dec 2025 10:54:57 +0000 Subject: [PATCH 2/8] Add typst directive implementation with preview and edit modes Co-authored-by: mikebarkmin <2592379+mikebarkmin@users.noreply.github.com> --- .../markdown/assets/directive-typst/client.js | 155 ++++++++++++++ .../markdown/assets/directive-typst/style.css | 134 ++++++++++++ packages/markdown/assets/store.js | 1 + packages/markdown/locales/de.json | 7 +- packages/markdown/locales/en.json | 7 +- packages/markdown/src/process.ts | 2 + packages/markdown/src/remarkDirectiveTypst.ts | 201 ++++++++++++++++++ .../remarkDirectiveTypst.test.ts.snap | 51 +++++ .../tests/remarkDirectiveTypst.test.ts | 135 ++++++++++++ .../vscode/snipptes/hyperbook.code-snippets | 12 ++ platforms/vscode/syntaxes/hyperbook.json | 52 +++++ website/en/book/elements/typst.md | 137 ++++++++++++ 12 files changed, 892 insertions(+), 2 deletions(-) create mode 100644 packages/markdown/assets/directive-typst/client.js create mode 100644 packages/markdown/assets/directive-typst/style.css create mode 100644 packages/markdown/src/remarkDirectiveTypst.ts create mode 100644 packages/markdown/tests/__snapshots__/remarkDirectiveTypst.test.ts.snap create mode 100644 packages/markdown/tests/remarkDirectiveTypst.test.ts create mode 100644 website/en/book/elements/typst.md diff --git a/packages/markdown/assets/directive-typst/client.js b/packages/markdown/assets/directive-typst/client.js new file mode 100644 index 00000000..cb62d119 --- /dev/null +++ b/packages/markdown/assets/directive-typst/client.js @@ -0,0 +1,155 @@ +hyperbook.typst = (function () { + // Register code-input template for typst syntax highlighting + window.codeInput?.registerTemplate( + "typst-highlighted", + codeInput.templates.prism(window.Prism, [ + new codeInput.plugins.AutoCloseBrackets(), + new codeInput.plugins.Indent(true, 2), + ]), + ); + + const elems = document.getElementsByClassName("directive-typst"); + + // Typst WASM module URLs + const TYPST_COMPILER_URL = "https://cdn.jsdelivr.net/npm/@myriaddreamin/typst-ts-web-compiler/pkg/typst_ts_web_compiler_bg.wasm"; + const TYPST_RENDERER_URL = "https://cdn.jsdelivr.net/npm/@myriaddreamin/typst-ts-renderer/pkg/typst_ts_renderer_bg.wasm"; + + // Load typst all-in-one bundle + let typstLoaded = false; + let typstLoadPromise = null; + + const loadTypst = () => { + if (typstLoaded) { + return Promise.resolve(); + } + if (typstLoadPromise) { + return typstLoadPromise; + } + + typstLoadPromise = new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = "https://cdn.jsdelivr.net/npm/@myriaddreamin/typst.ts/dist/esm/contrib/all-in-one-lite.bundle.js"; + script.type = "module"; + script.id = "typst-loader"; + script.onload = () => { + // Wait a bit for the module to initialize + const checkTypst = () => { + if (typeof $typst !== "undefined") { + // Initialize the Typst compiler and renderer + $typst.setCompilerInitOptions({ + getModule: () => TYPST_COMPILER_URL, + }); + $typst.setRendererInitOptions({ + getModule: () => TYPST_RENDERER_URL, + }); + typstLoaded = true; + resolve(); + } else { + setTimeout(checkTypst, 50); + } + }; + checkTypst(); + }; + script.onerror = reject; + document.head.appendChild(script); + }); + + return typstLoadPromise; + }; + + // Render typst code to SVG + const renderTypst = async (code, container) => { + await loadTypst(); + + try { + const svg = await $typst.svg({ mainContent: code }); + container.innerHTML = svg; + + // Scale SVG to fit container + const svgElem = container.firstElementChild; + if (svgElem) { + const width = Number.parseFloat(svgElem.getAttribute("width")); + const height = Number.parseFloat(svgElem.getAttribute("height")); + const containerWidth = container.clientWidth - 20; + if (width > 0 && containerWidth > 0) { + svgElem.setAttribute("width", containerWidth); + svgElem.setAttribute("height", (height * containerWidth) / width); + } + } + } catch (error) { + container.innerHTML = `
${error.message || "Error rendering Typst"}
`; + console.error("Typst rendering error:", error); + } + }; + + // Export to PDF + const exportPdf = async (code, id) => { + await loadTypst(); + + try { + const pdfData = await $typst.pdf({ mainContent: code }); + const pdfFile = new Blob([pdfData], { type: "application/pdf" }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(pdfFile); + link.download = `typst-${id}.pdf`; + link.click(); + URL.revokeObjectURL(link.href); + } catch (error) { + console.error("PDF export error:", error); + alert(i18n.get("typst-pdf-error") || "Error exporting PDF"); + } + }; + + for (let elem of elems) { + const id = elem.getAttribute("data-id"); + const preview = elem.querySelector(".typst-preview"); + const editor = elem.querySelector(".editor.typst"); + const downloadBtn = elem.querySelector(".download-pdf"); + const resetBtn = elem.querySelector(".reset"); + const sourceTextarea = elem.querySelector(".typst-source"); + + // Get initial code + let initialCode = ""; + if (editor) { + // Edit mode - code is in the editor + // Wait for code-input to load + editor.addEventListener("code-input_load", async () => { + // Check for stored code + const result = await store.typst?.get(id); + if (result) { + editor.value = result.code; + } + initialCode = editor.value; + renderTypst(initialCode, preview); + + // Listen for input changes + editor.addEventListener("input", () => { + store.typst?.put({ id, code: editor.value }); + renderTypst(editor.value, preview); + }); + }); + } else if (sourceTextarea) { + // Preview mode - code is in hidden textarea + initialCode = sourceTextarea.value; + loadTypst().then(() => { + renderTypst(initialCode, preview); + }); + } + + // Download PDF button + downloadBtn?.addEventListener("click", async () => { + const code = editor ? editor.value : initialCode; + await exportPdf(code, id); + }); + + // Reset button (edit mode only) + resetBtn?.addEventListener("click", async () => { + if (window.confirm(i18n.get("typst-reset-prompt") || "Are you sure you want to reset the code?")) { + store.typst?.delete(id); + window.location.reload(); + } + }); + } + + return {}; +})(); diff --git a/packages/markdown/assets/directive-typst/style.css b/packages/markdown/assets/directive-typst/style.css new file mode 100644 index 00000000..5c00af29 --- /dev/null +++ b/packages/markdown/assets/directive-typst/style.css @@ -0,0 +1,134 @@ +.directive-typst { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + margin-bottom: 16px; + overflow: hidden; + gap: 8px; +} + +code-input { + margin: 0; +} + +.directive-typst .preview-container { + width: 100%; + border: 1px solid var(--color-spacer); + border-radius: 8px; + overflow: auto; + background-color: white; +} + +.directive-typst .typst-preview { + padding: 16px; + min-height: 100px; + display: flex; + justify-content: center; + align-items: flex-start; +} + +.directive-typst .typst-preview svg { + max-width: 100%; + height: auto; +} + +.directive-typst .typst-error { + color: #dc2626; + padding: 16px; + background-color: #fef2f2; + border-radius: 4px; + font-family: monospace; + white-space: pre-wrap; + word-break: break-word; +} + +.directive-typst .editor-container { + width: 100%; + display: flex; + flex-direction: column; + height: 400px; +} + +.directive-typst .editor { + width: 100%; + border: 1px solid var(--color-spacer); + flex: 1; +} + +.directive-typst .editor:not(.active) { + display: none; +} + +.directive-typst .buttons { + display: flex; + border: 1px solid var(--color-spacer); + border-radius: 8px; + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.directive-typst .buttons.bottom { + border: 1px solid var(--color-spacer); + border-radius: 8px; + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.directive-typst .buttons-container { + display: flex; + justify-content: center; + width: 100%; +} + +.directive-typst button { + flex: 1; + padding: 8px 16px; + border: none; + border-right: 1px solid var(--color-spacer); + background-color: var(--color--background); + color: var(--color-text); + cursor: pointer; +} + +.directive-typst button:not(.active) { + opacity: 0.6; +} + +.directive-typst .buttons:last-child { + border-right: none; +} + +.directive-typst button:hover { + background-color: var(--color-spacer); +} + +.directive-typst .buttons-container button { + flex: none; + border: 1px solid var(--color-spacer); + border-radius: 8px; + padding: 8px 24px; +} + +.directive-typst .hidden { + display: none !important; +} + +@media screen and (min-width: 1024px) { + .directive-typst:not(.preview-only) { + flex-direction: row; + height: calc(100dvh - 128px); + .preview-container { + flex: 1; + height: 100% !important; + } + + .editor-container { + flex: 1; + height: 100%; + overflow: hidden; + } + } +} diff --git a/packages/markdown/assets/store.js b/packages/markdown/assets/store.js index 68250a90..467befdc 100644 --- a/packages/markdown/assets/store.js +++ b/packages/markdown/assets/store.js @@ -28,6 +28,7 @@ store.version(1).stores({ geogebra: `id,state`, learningmap: `id,nodes,x,y,zoom`, textinput: `id,text`, + typst: `id,code`, custom: `id,payload`, multievent: `id,state`, }); diff --git a/packages/markdown/locales/de.json b/packages/markdown/locales/de.json index e32e85d1..09f3a1bf 100644 --- a/packages/markdown/locales/de.json +++ b/packages/markdown/locales/de.json @@ -53,5 +53,10 @@ "webide-reset": "Zurücksetzen", "webide-reset-prompt": "Sind Sie sicher, dass Sie den Code zurücksetzen möchten?", "webide-copy": "Kopieren", - "webide-download": "Herunterladen" + "webide-download": "Herunterladen", + "typst-code": "Typst", + "typst-reset": "Zurücksetzen", + "typst-reset-prompt": "Sind Sie sicher, dass Sie den Code zurücksetzen möchten?", + "typst-download-pdf": "PDF herunterladen", + "typst-pdf-error": "Fehler beim PDF-Export" } diff --git a/packages/markdown/locales/en.json b/packages/markdown/locales/en.json index c9361eb3..394f27f5 100644 --- a/packages/markdown/locales/en.json +++ b/packages/markdown/locales/en.json @@ -53,5 +53,10 @@ "webide-reset": "Reset", "webide-reset-prompt": "Are you sure you want to reset the code?", "webide-copy": "Copy", - "webide-download": "Download" + "webide-download": "Download", + "typst-code": "Typst", + "typst-reset": "Reset", + "typst-reset-prompt": "Are you sure you want to reset the code?", + "typst-download-pdf": "Download PDF", + "typst-pdf-error": "Error exporting PDF" } diff --git a/packages/markdown/src/process.ts b/packages/markdown/src/process.ts index 2c0a012a..f4a38722 100644 --- a/packages/markdown/src/process.ts +++ b/packages/markdown/src/process.ts @@ -62,6 +62,7 @@ import remarkSubSup from "./remarkSubSup"; import remarkImageAttrs from "./remarkImageAttrs"; import remarkDirectiveLearningmap from "./remarkDirectiveLearningmap"; import remarkDirectiveTextinput from "./remarkDirectiveTextinput"; +import remarkDirectiveTypst from "./remarkDirectiveTypst"; export const remark = (ctx: HyperbookContext) => { i18n.init(ctx.config.language || "en"); @@ -106,6 +107,7 @@ export const remark = (ctx: HyperbookContext) => { remarkDirectiveMultievent(ctx), remarkDirectiveLearningmap(ctx), remarkDirectiveTextinput(ctx), + remarkDirectiveTypst(ctx), remarkCode(ctx), remarkMath, /* needs to be last directive */ diff --git a/packages/markdown/src/remarkDirectiveTypst.ts b/packages/markdown/src/remarkDirectiveTypst.ts new file mode 100644 index 00000000..fd8e3a41 --- /dev/null +++ b/packages/markdown/src/remarkDirectiveTypst.ts @@ -0,0 +1,201 @@ +// Register directive nodes in mdast: +/// +// +import { HyperbookContext } from "@hyperbook/types"; +import { Code, Root } from "mdast"; +import { visit } from "unist-util-visit"; +import { VFile } from "vfile"; +import { + expectContainerDirective, + isDirective, + registerDirective, + requestCSS, + requestJS, +} from "./remarkHelper"; +import hash from "./objectHash"; +import { i18n } from "./i18n"; +import { Element, ElementContent } from "hast"; + +function htmlEntities(str: string) { + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +export default (ctx: HyperbookContext) => () => { + const name = "typst"; + + return (tree: Root, file: VFile) => { + visit(tree, function (node) { + if (isDirective(node) && node.name === name) { + const { height = 400, id = hash(node), mode = "preview" } = node.attributes || {}; + const data = node.data || (node.data = {}); + + expectContainerDirective(node, file, name); + registerDirective(file, name, ["client.js"], ["style.css"], []); + requestJS(file, ["code-input", "code-input.min.js"]); + requestCSS(file, ["code-input", "code-input.min.css"]); + requestJS(file, ["code-input", "auto-close-brackets.min.js"]); + requestJS(file, ["code-input", "indent.min.js"]); + + let typstCode = ""; + + // Find typst code block inside the directive + const typstNode = node.children.find( + (n) => n.type === "code" && (n.lang === "typ" || n.lang === "typst"), + ) as Code; + + if (typstNode) { + typstCode = typstNode.value; + } + + const isEditMode = mode === "edit"; + const isPreviewMode = mode === "preview" || !isEditMode; + + data.hName = "div"; + data.hProperties = { + class: ["directive-typst", isPreviewMode ? "preview-only" : ""].join(" ").trim(), + "data-id": id, + }; + + const previewContainer: Element = { + type: "element", + tagName: "div", + properties: { + class: "preview-container", + style: `height: ${height}px;`, + }, + children: [ + { + type: "element", + tagName: "div", + properties: { + class: "typst-preview", + }, + children: [], + }, + ], + }; + + const downloadButton: Element = { + type: "element", + tagName: "button", + properties: { + class: "download-pdf", + }, + children: [ + { + type: "text", + value: i18n.get("typst-download-pdf"), + }, + ], + }; + + if (isEditMode) { + // Edit mode: show editor and preview side by side + data.hChildren = [ + previewContainer, + { + type: "element", + tagName: "div", + properties: { + class: "editor-container", + }, + children: [ + { + type: "element", + tagName: "div", + properties: { + class: "buttons", + }, + children: [ + { + type: "element", + tagName: "button", + properties: { + class: "typst", + }, + children: [ + { + type: "text", + value: i18n.get("typst-code"), + }, + ], + }, + ], + }, + { + type: "element", + tagName: "code-input", + properties: { + class: "editor typst active line-numbers", + language: "typst", + template: "typst-highlighted", + }, + children: [ + { + type: "raw", + value: htmlEntities(typstCode), + }, + ], + }, + { + type: "element", + tagName: "div", + properties: { + class: "buttons bottom", + }, + children: [ + { + type: "element", + tagName: "button", + properties: { + class: "reset", + }, + children: [ + { + type: "text", + value: i18n.get("typst-reset"), + }, + ], + }, + downloadButton, + ], + }, + ], + }, + ]; + } else { + // Preview mode: show only preview with download button + data.hChildren = [ + previewContainer, + { + type: "element", + tagName: "div", + properties: { + class: "buttons-container", + }, + children: [downloadButton], + }, + { + type: "element", + tagName: "textarea", + properties: { + class: "typst-source hidden", + style: "display: none;", + }, + children: [ + { + type: "text", + value: typstCode, + }, + ], + }, + ]; + } + } + }); + }; +}; diff --git a/packages/markdown/tests/__snapshots__/remarkDirectiveTypst.test.ts.snap b/packages/markdown/tests/__snapshots__/remarkDirectiveTypst.test.ts.snap new file mode 100644 index 00000000..7fabff58 --- /dev/null +++ b/packages/markdown/tests/__snapshots__/remarkDirectiveTypst.test.ts.snap @@ -0,0 +1,51 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`remarkDirectiveTypst > should default to preview mode 1`] = ` +" +
+
+
+
+
+
+" +`; + +exports[`remarkDirectiveTypst > should handle typst language code block 1`] = ` +" +
+
+
+
+
+
+" +`; + +exports[`remarkDirectiveTypst > should transform edit mode 1`] = ` +" +
+
+
+
+
+
+ = Hello World! + +This is editable content. +
+
+
+" +`; + +exports[`remarkDirectiveTypst > should transform preview mode 1`] = ` +" +
+
+
+
+
+
+" +`; diff --git a/packages/markdown/tests/remarkDirectiveTypst.test.ts b/packages/markdown/tests/remarkDirectiveTypst.test.ts new file mode 100644 index 00000000..32c6212b --- /dev/null +++ b/packages/markdown/tests/remarkDirectiveTypst.test.ts @@ -0,0 +1,135 @@ +import { HyperbookContext } from "@hyperbook/types/dist"; +import { describe, expect, it } from "vitest"; +import rehypeStringify from "rehype-stringify"; +import remarkToRehype from "remark-rehype"; +import rehypeFormat from "rehype-format"; +import { unified, PluggableList } from "unified"; +import remarkDirective from "remark-directive"; +import remarkDirectiveRehype from "remark-directive-rehype"; +import { ctx } from "./mock"; +import remarkDirectiveTypst from "../src/remarkDirectiveTypst"; +import remarkParse from "../src/remarkParse"; + +export const toHtml = (md: string, ctx: HyperbookContext) => { + const remarkPlugins: PluggableList = [ + remarkDirective, + remarkDirectiveRehype, + remarkDirectiveTypst(ctx), + ]; + + return unified() + .use(remarkParse) + .use(remarkPlugins) + .use(remarkToRehype) + .use(rehypeFormat) + .use(rehypeStringify, { + allowDangerousCharacters: true, + allowDangerousHtml: true, + }) + .processSync(md); +}; + +describe("remarkDirectiveTypst", () => { + it("should transform preview mode", async () => { + expect( + toHtml( + ` +:::typst{mode="preview"} + +\`\`\`typ += Hello World! +\`\`\` + +::: +`, + ctx, + ).value, + ).toMatchSnapshot(); + }); + + it("should transform edit mode", async () => { + expect( + toHtml( + ` +:::typst{mode="edit"} + +\`\`\`typ += Hello World! + +This is editable content. +\`\`\` + +::: +`, + ctx, + ).value, + ).toMatchSnapshot(); + }); + + it("should default to preview mode", async () => { + expect( + toHtml( + ` +:::typst + +\`\`\`typ += Default Mode +\`\`\` + +::: +`, + ctx, + ).value, + ).toMatchSnapshot(); + }); + + it("should register directives", async () => { + expect( + toHtml( + ` +:::typst{mode="preview"} + +\`\`\`typ += Test +\`\`\` + +::: +`, + ctx, + ).data.directives?.["typst"], + ).toBeDefined(); + }); + + it("should accept custom height", async () => { + const result = toHtml( + ` +:::typst{mode="preview" height=600} + +\`\`\`typ += Custom Height +\`\`\` + +::: +`, + ctx, + ).value; + expect(result).toContain("height: 600px"); + }); + + it("should handle typst language code block", async () => { + expect( + toHtml( + ` +:::typst{mode="preview"} + +\`\`\`typst += Using typst language +\`\`\` + +::: +`, + ctx, + ).value, + ).toMatchSnapshot(); + }); +}); diff --git a/platforms/vscode/snipptes/hyperbook.code-snippets b/platforms/vscode/snipptes/hyperbook.code-snippets index ef3bc63d..25d1270c 100644 --- a/platforms/vscode/snipptes/hyperbook.code-snippets +++ b/platforms/vscode/snipptes/hyperbook.code-snippets @@ -269,5 +269,17 @@ "body": [ ":Snippet{#${1:snippet-name}}" ] + }, + "Element Typst": { + "prefix": [":typst"], + "body": [ + ":::typst{mode=\"${1|preview,edit|}\"}", + "", + "```typ", + "$TM_SELECTED_TEXT$0", + "```", + "", + ":::" + ] } } diff --git a/platforms/vscode/syntaxes/hyperbook.json b/platforms/vscode/syntaxes/hyperbook.json index a6c78704..6991f728 100644 --- a/platforms/vscode/syntaxes/hyperbook.json +++ b/platforms/vscode/syntaxes/hyperbook.json @@ -110,6 +110,9 @@ }, { "include": "#directive-webide" + }, + { + "include": "#directive-typst" } ], "repository": { @@ -1443,6 +1446,55 @@ "include": "text.html.markdown" } ] + }, + "directive-typst": { + "name": "meta.directive.typst.hyperbook", + "begin": "(^|\\G)(:{3,})(typst)({.*})?\\s*$", + "end": "(^|\\G)(\\2|:{3,})\\s*$", + "beginCaptures": { + "2": { + "name": "punctuation.definition.directive.level.hyperbook" + }, + "3": { + "name": "entity.name.function.directive.typst.hyperbook" + }, + "4": { + "patterns": [ + { + "match": "(mode)=\"([^\"]*)\"", + "captures": { + "1": { + "name": "keyword.parameter.directive.typst.hyperbook" + }, + "2": { + "name": "string.quoted.double.untitled" + } + } + }, + { + "match": "(height)=([0-9]+)", + "captures": { + "1": { + "name": "keyword.parameter.directive.typst.hyperbook" + }, + "2": { + "name": "constant.numeric" + } + } + } + ] + } + }, + "endCaptures": { + "2": { + "name": "punctuation.definition.directive.level.hyperbook" + } + }, + "patterns": [ + { + "include": "text.html.markdown" + } + ] } } } diff --git a/website/en/book/elements/typst.md b/website/en/book/elements/typst.md new file mode 100644 index 00000000..3ac0b337 --- /dev/null +++ b/website/en/book/elements/typst.md @@ -0,0 +1,137 @@ +--- +name: Typst +permaid: typst +--- + +# Typst + +The Typst directive allows you to render [Typst](https://typst.app/) documents directly in your hyperbook. Typst is a modern markup-based typesetting system that is easy to learn and produces beautiful documents. + +## Usage + +To use the Typst directive, wrap your Typst code in a `:::typst` block with a code block using the `typ` or `typst` language. + +### Preview Mode + +In preview mode, only the rendered output is shown with a download button for exporting to PDF. + +````md +:::typst{mode="preview"} + +```typ += Hello World! + +This is a simple Typst document. + +- First item +- Second item +- Third item +``` + +::: +```` + +:::typst{mode="preview"} + +```typ += Hello World! + +This is a simple Typst document. + +- First item +- Second item +- Third item +``` + +::: + +### Edit Mode + +In edit mode, an editor is shown alongside the preview, allowing users to modify the Typst code and see live updates. + +````md +:::typst{mode="edit"} + +```typ += Interactive Document + +You can edit this text and see the changes live! + +$ sum_(i=1)^n i = (n(n+1))/2 $ +``` + +::: +```` + +:::typst{mode="edit"} + +```typ += Interactive Document + +You can edit this text and see the changes live! + +$ sum_(i=1)^n i = (n(n+1))/2 $ +``` + +::: + +## Options + +| Option | Description | Default | +|--------|-------------|---------| +| `mode` | Display mode: `preview` (view only) or `edit` (with editor) | `preview` | +| `height` | Height of the preview container in pixels | `400` | + +## Examples + +### Mathematical Formulas + +:::typst{mode="preview" height=300} + +```typ += Mathematical Formulas + +Typst supports beautiful mathematical typesetting: + +$ integral_0^infinity e^(-x^2) dif x = sqrt(pi)/2 $ + +The quadratic formula: + +$ x = (-b plus.minus sqrt(b^2 - 4a c)) / (2a) $ +``` + +::: + +### Tables + +:::typst{mode="preview" height=250} + +```typ += Data Table + +#table( + columns: (auto, auto, auto), + [*Name*], [*Age*], [*City*], + [Alice], [25], [Berlin], + [Bob], [30], [Munich], + [Carol], [28], [Hamburg], +) +``` + +::: + +### Code Blocks + +:::typst{mode="preview" height=200} + +```typ += Code Example + +Here is some inline `code` and a code block: + +#raw(block: true, lang: "python", +"def hello(): + print('Hello, World!')") +``` + +::: From 2f9ce953ddcd5d9d95cd88ca1e2786a7f19a9b28 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 31 Dec 2025 10:59:17 +0000 Subject: [PATCH 3/8] Add German documentation for typst directive Co-authored-by: mikebarkmin <2592379+mikebarkmin@users.noreply.github.com> --- website/de/book/elements/typst.md | 137 ++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 website/de/book/elements/typst.md diff --git a/website/de/book/elements/typst.md b/website/de/book/elements/typst.md new file mode 100644 index 00000000..2d6abd60 --- /dev/null +++ b/website/de/book/elements/typst.md @@ -0,0 +1,137 @@ +--- +name: Typst +permaid: typst +--- + +# Typst + +Die Typst-Direktive ermöglicht es, [Typst](https://typst.app/)-Dokumente direkt in Ihrem Hyperbook zu rendern. Typst ist ein modernes, Markup-basiertes Satzsystem, das leicht zu erlernen ist und schöne Dokumente erstellt. + +## Verwendung + +Um die Typst-Direktive zu verwenden, umschließen Sie Ihren Typst-Code in einem `:::typst`-Block mit einem Code-Block, der die Sprache `typ` oder `typst` verwendet. + +### Vorschau-Modus + +Im Vorschau-Modus wird nur die gerenderte Ausgabe angezeigt, zusammen mit einer Download-Schaltfläche zum Exportieren als PDF. + +````md +:::typst{mode="preview"} + +```typ += Hallo Welt! + +Dies ist ein einfaches Typst-Dokument. + +- Erster Punkt +- Zweiter Punkt +- Dritter Punkt +``` + +::: +```` + +:::typst{mode="preview"} + +```typ += Hallo Welt! + +Dies ist ein einfaches Typst-Dokument. + +- Erster Punkt +- Zweiter Punkt +- Dritter Punkt +``` + +::: + +### Bearbeiten-Modus + +Im Bearbeiten-Modus wird ein Editor neben der Vorschau angezeigt, mit dem Benutzer den Typst-Code ändern und Live-Updates sehen können. + +````md +:::typst{mode="edit"} + +```typ += Interaktives Dokument + +Sie können diesen Text bearbeiten und die Änderungen live sehen! + +$ sum_(i=1)^n i = (n(n+1))/2 $ +``` + +::: +```` + +:::typst{mode="edit"} + +```typ += Interaktives Dokument + +Sie können diesen Text bearbeiten und die Änderungen live sehen! + +$ sum_(i=1)^n i = (n(n+1))/2 $ +``` + +::: + +## Optionen + +| Option | Beschreibung | Standard | +|--------|--------------|----------| +| `mode` | Anzeigemodus: `preview` (nur Ansicht) oder `edit` (mit Editor) | `preview` | +| `height` | Höhe des Vorschau-Containers in Pixel | `400` | + +## Beispiele + +### Mathematische Formeln + +:::typst{mode="preview" height=300} + +```typ += Mathematische Formeln + +Typst unterstützt schöne mathematische Formeln: + +$ integral_0^infinity e^(-x^2) dif x = sqrt(pi)/2 $ + +Die quadratische Formel: + +$ x = (-b plus.minus sqrt(b^2 - 4a c)) / (2a) $ +``` + +::: + +### Tabellen + +:::typst{mode="preview" height=250} + +```typ += Datentabelle + +#table( + columns: (auto, auto, auto), + [*Name*], [*Alter*], [*Stadt*], + [Alice], [25], [Berlin], + [Bob], [30], [München], + [Carol], [28], [Hamburg], +) +``` + +::: + +### Code-Blöcke + +:::typst{mode="preview" height=200} + +```typ += Code-Beispiel + +Hier ist etwas Inline-`Code` und ein Code-Block: + +#raw(block: true, lang: "python", +"def hallo(): + print('Hallo, Welt!')") +``` + +::: From 1fca1264abab20c4dbcdeb8d25147c7abd053b45 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 31 Dec 2025 11:19:22 +0000 Subject: [PATCH 4/8] Add external file support, loading indicator, and copy button to typst directive Co-authored-by: mikebarkmin <2592379+mikebarkmin@users.noreply.github.com> --- .../markdown/assets/directive-typst/client.js | 30 +++++++-- .../markdown/assets/directive-typst/style.css | 25 +++++++ packages/markdown/locales/de.json | 4 +- packages/markdown/locales/en.json | 4 +- packages/markdown/src/remarkDirectiveTypst.ts | 65 ++++++++++++++++--- .../remarkDirectiveTypst.test.ts.snap | 20 ++++-- website/de/book/elements/typst.md | 15 +++++ website/en/book/elements/typst.md | 15 +++++ 8 files changed, 160 insertions(+), 18 deletions(-) diff --git a/packages/markdown/assets/directive-typst/client.js b/packages/markdown/assets/directive-typst/client.js index cb62d119..5907a188 100644 --- a/packages/markdown/assets/directive-typst/client.js +++ b/packages/markdown/assets/directive-typst/client.js @@ -58,7 +58,12 @@ hyperbook.typst = (function () { }; // Render typst code to SVG - const renderTypst = async (code, container) => { + const renderTypst = async (code, container, loadingIndicator) => { + // Show loading indicator + if (loadingIndicator) { + loadingIndicator.style.display = "flex"; + } + await loadTypst(); try { @@ -79,6 +84,11 @@ hyperbook.typst = (function () { } catch (error) { container.innerHTML = `
${error.message || "Error rendering Typst"}
`; console.error("Typst rendering error:", error); + } finally { + // Hide loading indicator + if (loadingIndicator) { + loadingIndicator.style.display = "none"; + } } }; @@ -103,8 +113,10 @@ hyperbook.typst = (function () { for (let elem of elems) { const id = elem.getAttribute("data-id"); const preview = elem.querySelector(".typst-preview"); + const loadingIndicator = elem.querySelector(".typst-loading"); const editor = elem.querySelector(".editor.typst"); const downloadBtn = elem.querySelector(".download-pdf"); + const copyBtn = elem.querySelector(".copy"); const resetBtn = elem.querySelector(".reset"); const sourceTextarea = elem.querySelector(".typst-source"); @@ -120,19 +132,19 @@ hyperbook.typst = (function () { editor.value = result.code; } initialCode = editor.value; - renderTypst(initialCode, preview); + renderTypst(initialCode, preview, loadingIndicator); // Listen for input changes editor.addEventListener("input", () => { store.typst?.put({ id, code: editor.value }); - renderTypst(editor.value, preview); + renderTypst(editor.value, preview, loadingIndicator); }); }); } else if (sourceTextarea) { // Preview mode - code is in hidden textarea initialCode = sourceTextarea.value; loadTypst().then(() => { - renderTypst(initialCode, preview); + renderTypst(initialCode, preview, loadingIndicator); }); } @@ -142,6 +154,16 @@ hyperbook.typst = (function () { await exportPdf(code, id); }); + // Copy button + copyBtn?.addEventListener("click", async () => { + const code = editor ? editor.value : initialCode; + try { + await navigator.clipboard.writeText(code); + } catch (error) { + console.error("Copy error:", error); + } + }); + // Reset button (edit mode only) resetBtn?.addEventListener("click", async () => { if (window.confirm(i18n.get("typst-reset-prompt") || "Are you sure you want to reset the code?")) { diff --git a/packages/markdown/assets/directive-typst/style.css b/packages/markdown/assets/directive-typst/style.css index 5c00af29..a494bbe8 100644 --- a/packages/markdown/assets/directive-typst/style.css +++ b/packages/markdown/assets/directive-typst/style.css @@ -33,6 +33,31 @@ code-input { height: auto; } +.directive-typst .typst-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px; + gap: 12px; + color: var(--color-text); +} + +.directive-typst .typst-spinner { + width: 32px; + height: 32px; + border: 3px solid var(--color-spacer); + border-top-color: var(--color-primary, #3b82f6); + border-radius: 50%; + animation: typst-spin 1s linear infinite; +} + +@keyframes typst-spin { + to { + transform: rotate(360deg); + } +} + .directive-typst .typst-error { color: #dc2626; padding: 16px; diff --git a/packages/markdown/locales/de.json b/packages/markdown/locales/de.json index 09f3a1bf..b550e263 100644 --- a/packages/markdown/locales/de.json +++ b/packages/markdown/locales/de.json @@ -58,5 +58,7 @@ "typst-reset": "Zurücksetzen", "typst-reset-prompt": "Sind Sie sicher, dass Sie den Code zurücksetzen möchten?", "typst-download-pdf": "PDF herunterladen", - "typst-pdf-error": "Fehler beim PDF-Export" + "typst-pdf-error": "Fehler beim PDF-Export", + "typst-copy": "Kopieren", + "typst-loading": "Typst wird geladen..." } diff --git a/packages/markdown/locales/en.json b/packages/markdown/locales/en.json index 394f27f5..d7b57b7a 100644 --- a/packages/markdown/locales/en.json +++ b/packages/markdown/locales/en.json @@ -58,5 +58,7 @@ "typst-reset": "Reset", "typst-reset-prompt": "Are you sure you want to reset the code?", "typst-download-pdf": "Download PDF", - "typst-pdf-error": "Error exporting PDF" + "typst-pdf-error": "Error exporting PDF", + "typst-copy": "Copy", + "typst-loading": "Loading Typst..." } diff --git a/packages/markdown/src/remarkDirectiveTypst.ts b/packages/markdown/src/remarkDirectiveTypst.ts index fd8e3a41..f68a2cf4 100644 --- a/packages/markdown/src/remarkDirectiveTypst.ts +++ b/packages/markdown/src/remarkDirectiveTypst.ts @@ -15,6 +15,7 @@ import { import hash from "./objectHash"; import { i18n } from "./i18n"; import { Element, ElementContent } from "hast"; +import { readFile } from "./helper"; function htmlEntities(str: string) { return String(str) @@ -30,7 +31,7 @@ export default (ctx: HyperbookContext) => () => { return (tree: Root, file: VFile) => { visit(tree, function (node) { if (isDirective(node) && node.name === name) { - const { height = 400, id = hash(node), mode = "preview" } = node.attributes || {}; + const { height = 400, id = hash(node), mode = "preview", src = "" } = node.attributes || {}; const data = node.data || (node.data = {}); expectContainerDirective(node, file, name); @@ -42,13 +43,18 @@ export default (ctx: HyperbookContext) => () => { let typstCode = ""; - // Find typst code block inside the directive - const typstNode = node.children.find( - (n) => n.type === "code" && (n.lang === "typ" || n.lang === "typst"), - ) as Code; + // Load from external file if src is provided + if (src) { + typstCode = readFile(src, ctx) || ""; + } else { + // Find typst code block inside the directive + const typstNode = node.children.find( + (n) => n.type === "code" && (n.lang === "typ" || n.lang === "typst"), + ) as Code; - if (typstNode) { - typstCode = typstNode.value; + if (typstNode) { + typstCode = typstNode.value; + } } const isEditMode = mode === "edit"; @@ -68,6 +74,34 @@ export default (ctx: HyperbookContext) => () => { style: `height: ${height}px;`, }, children: [ + { + type: "element", + tagName: "div", + properties: { + class: "typst-loading", + }, + children: [ + { + type: "element", + tagName: "div", + properties: { + class: "typst-spinner", + }, + children: [], + }, + { + type: "element", + tagName: "span", + properties: {}, + children: [ + { + type: "text", + value: i18n.get("typst-loading"), + }, + ], + }, + ], + }, { type: "element", tagName: "div", @@ -93,6 +127,20 @@ export default (ctx: HyperbookContext) => () => { ], }; + const copyButton: Element = { + type: "element", + tagName: "button", + properties: { + class: "copy", + }, + children: [ + { + type: "text", + value: i18n.get("typst-copy"), + }, + ], + }; + if (isEditMode) { // Edit mode: show editor and preview side by side data.hChildren = [ @@ -161,6 +209,7 @@ export default (ctx: HyperbookContext) => () => { }, ], }, + copyButton, downloadButton, ], }, @@ -177,7 +226,7 @@ export default (ctx: HyperbookContext) => () => { properties: { class: "buttons-container", }, - children: [downloadButton], + children: [copyButton, downloadButton], }, { type: "element", diff --git a/packages/markdown/tests/__snapshots__/remarkDirectiveTypst.test.ts.snap b/packages/markdown/tests/__snapshots__/remarkDirectiveTypst.test.ts.snap index 7fabff58..65211a3d 100644 --- a/packages/markdown/tests/__snapshots__/remarkDirectiveTypst.test.ts.snap +++ b/packages/markdown/tests/__snapshots__/remarkDirectiveTypst.test.ts.snap @@ -4,9 +4,12 @@ exports[`remarkDirectiveTypst > should default to preview mode 1`] = ` "
+
+
typst-loading +
-
+
" `; @@ -15,9 +18,12 @@ exports[`remarkDirectiveTypst > should handle typst language code block 1`] = ` "
+
+
typst-loading +
-
+
" `; @@ -26,6 +32,9 @@ exports[`remarkDirectiveTypst > should transform edit mode 1`] = ` "
+
+
typst-loading +
@@ -33,7 +42,7 @@ exports[`remarkDirectiveTypst > should transform edit mode 1`] = ` = Hello World! This is editable content. -
+
" @@ -43,9 +52,12 @@ exports[`remarkDirectiveTypst > should transform preview mode 1`] = ` "
+
+
typst-loading +
-
+
" `; diff --git a/website/de/book/elements/typst.md b/website/de/book/elements/typst.md index 2d6abd60..6f492751 100644 --- a/website/de/book/elements/typst.md +++ b/website/de/book/elements/typst.md @@ -81,6 +81,21 @@ $ sum_(i=1)^n i = (n(n+1))/2 $ |--------|--------------|----------| | `mode` | Anzeigemodus: `preview` (nur Ansicht) oder `edit` (mit Editor) | `preview` | | `height` | Höhe des Vorschau-Containers in Pixel | `400` | +| `src` | Pfad zu einer externen `.typ`-Datei | - | + +### Laden aus externer Datei + +Sie können Typst-Code aus einer externen Datei laden, indem Sie das `src`-Attribut verwenden: + +````md +:::typst{mode="preview" src="dokument.typ"} +::: +```` + +Die Datei wird in folgenden Verzeichnissen gesucht (in dieser Reihenfolge): +1. `public/`-Verzeichnis +2. `book/`-Verzeichnis +3. Verzeichnis der aktuellen Seite ## Beispiele diff --git a/website/en/book/elements/typst.md b/website/en/book/elements/typst.md index 3ac0b337..32184a13 100644 --- a/website/en/book/elements/typst.md +++ b/website/en/book/elements/typst.md @@ -81,6 +81,21 @@ $ sum_(i=1)^n i = (n(n+1))/2 $ |--------|-------------|---------| | `mode` | Display mode: `preview` (view only) or `edit` (with editor) | `preview` | | `height` | Height of the preview container in pixels | `400` | +| `src` | Path to an external `.typ` file to load | - | + +### Loading from External File + +You can load Typst code from an external file using the `src` attribute: + +````md +:::typst{mode="preview" src="document.typ"} +::: +```` + +The file is searched in the following locations (in order): +1. `public/` directory +2. `book/` directory +3. Current page's directory ## Examples From b0013d2000f5fcd57e51688fd8b6d5a17c1aaa11 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 31 Dec 2025 12:36:36 +0000 Subject: [PATCH 5/8] Add visual separation to typst document and attach buttons to preview bottom Co-authored-by: mikebarkmin <2592379+mikebarkmin@users.noreply.github.com> --- .../markdown/assets/directive-typst/style.css | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/markdown/assets/directive-typst/style.css b/packages/markdown/assets/directive-typst/style.css index a494bbe8..1c6c6982 100644 --- a/packages/markdown/assets/directive-typst/style.css +++ b/packages/markdown/assets/directive-typst/style.css @@ -8,6 +8,10 @@ gap: 8px; } +.directive-typst.preview-only { + gap: 0; +} + code-input { margin: 0; } @@ -16,6 +20,9 @@ code-input { width: 100%; border: 1px solid var(--color-spacer); border-radius: 8px; + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; overflow: auto; background-color: white; } @@ -31,6 +38,8 @@ code-input { .directive-typst .typst-preview svg { max-width: 100%; height: auto; + border-radius: 8px; + box-shadow: rgba(0, 0, 0, 0.16) 0px 3px 6px, rgba(0, 0, 0, 0.16) 0px 3px 6px; } .directive-typst .typst-loading { @@ -104,8 +113,12 @@ code-input { .directive-typst .buttons-container { display: flex; - justify-content: center; width: 100%; + border: 1px solid var(--color-spacer); + border-radius: 8px; + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; } .directive-typst button { @@ -118,6 +131,10 @@ code-input { cursor: pointer; } +.directive-typst button:last-child { + border-right: none; +} + .directive-typst button:not(.active) { opacity: 0.6; } @@ -130,13 +147,6 @@ code-input { background-color: var(--color-spacer); } -.directive-typst .buttons-container button { - flex: none; - border: 1px solid var(--color-spacer); - border-radius: 8px; - padding: 8px 24px; -} - .directive-typst .hidden { display: none !important; } From 4e2156284f629d52a08dfc062a2cdb8d81650e4d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 31 Dec 2025 13:05:17 +0000 Subject: [PATCH 6/8] Add zoom controls: plus/minus buttons, fit width, and full page buttons Co-authored-by: mikebarkmin <2592379+mikebarkmin@users.noreply.github.com> --- .../markdown/assets/directive-typst/client.js | 93 ++++++++++++++++--- .../markdown/assets/directive-typst/style.css | 8 ++ packages/markdown/locales/de.json | 6 +- packages/markdown/locales/en.json | 6 +- packages/markdown/src/remarkDirectiveTypst.ts | 58 +++++++++++- .../remarkDirectiveTypst.test.ts.snap | 6 +- 6 files changed, 159 insertions(+), 18 deletions(-) diff --git a/packages/markdown/assets/directive-typst/client.js b/packages/markdown/assets/directive-typst/client.js index 5907a188..5c3da36c 100644 --- a/packages/markdown/assets/directive-typst/client.js +++ b/packages/markdown/assets/directive-typst/client.js @@ -57,8 +57,11 @@ hyperbook.typst = (function () { return typstLoadPromise; }; + // Store original SVG dimensions + const svgOriginalDimensions = new Map(); + // Render typst code to SVG - const renderTypst = async (code, container, loadingIndicator) => { + const renderTypst = async (code, container, loadingIndicator, scaleState) => { // Show loading indicator if (loadingIndicator) { loadingIndicator.style.display = "flex"; @@ -70,16 +73,17 @@ hyperbook.typst = (function () { const svg = await $typst.svg({ mainContent: code }); container.innerHTML = svg; - // Scale SVG to fit container + // Store original dimensions and apply scale const svgElem = container.firstElementChild; if (svgElem) { - const width = Number.parseFloat(svgElem.getAttribute("width")); - const height = Number.parseFloat(svgElem.getAttribute("height")); - const containerWidth = container.clientWidth - 20; - if (width > 0 && containerWidth > 0) { - svgElem.setAttribute("width", containerWidth); - svgElem.setAttribute("height", (height * containerWidth) / width); - } + const originalWidth = Number.parseFloat(svgElem.getAttribute("width")); + const originalHeight = Number.parseFloat(svgElem.getAttribute("height")); + + // Store original dimensions + svgOriginalDimensions.set(container, { width: originalWidth, height: originalHeight }); + + // Apply current scale + applyScale(container, scaleState); } } catch (error) { container.innerHTML = `
${error.message || "Error rendering Typst"}
`; @@ -92,6 +96,35 @@ hyperbook.typst = (function () { } }; + // Apply scale to SVG + const applyScale = (container, scaleState) => { + const svgElem = container.firstElementChild; + const original = svgOriginalDimensions.get(container); + + if (!svgElem || !original) return; + + const containerWidth = container.clientWidth - 32; // Account for padding + + let newWidth, newHeight; + + if (scaleState.mode === "fit-width") { + // Fit to container width + newWidth = containerWidth; + newHeight = (original.height * containerWidth) / original.width; + } else if (scaleState.mode === "full-page") { + // Show at 100% original size + newWidth = original.width; + newHeight = original.height; + } else { + // Manual scale + newWidth = original.width * scaleState.scale; + newHeight = original.height * scaleState.scale; + } + + svgElem.setAttribute("width", newWidth); + svgElem.setAttribute("height", newHeight); + }; + // Export to PDF const exportPdf = async (code, id) => { await loadTypst(); @@ -118,8 +151,18 @@ hyperbook.typst = (function () { const downloadBtn = elem.querySelector(".download-pdf"); const copyBtn = elem.querySelector(".copy"); const resetBtn = elem.querySelector(".reset"); + const zoomInBtn = elem.querySelector(".zoom-in"); + const zoomOutBtn = elem.querySelector(".zoom-out"); + const fitWidthBtn = elem.querySelector(".fit-width"); + const fullPageBtn = elem.querySelector(".full-page"); const sourceTextarea = elem.querySelector(".typst-source"); + // Scale state for this element + const scaleState = { + scale: 1.0, + mode: "fit-width" // "fit-width", "full-page", or "manual" + }; + // Get initial code let initialCode = ""; if (editor) { @@ -132,22 +175,48 @@ hyperbook.typst = (function () { editor.value = result.code; } initialCode = editor.value; - renderTypst(initialCode, preview, loadingIndicator); + renderTypst(initialCode, preview, loadingIndicator, scaleState); // Listen for input changes editor.addEventListener("input", () => { store.typst?.put({ id, code: editor.value }); - renderTypst(editor.value, preview, loadingIndicator); + renderTypst(editor.value, preview, loadingIndicator, scaleState); }); }); } else if (sourceTextarea) { // Preview mode - code is in hidden textarea initialCode = sourceTextarea.value; loadTypst().then(() => { - renderTypst(initialCode, preview, loadingIndicator); + renderTypst(initialCode, preview, loadingIndicator, scaleState); }); } + // Zoom in button + zoomInBtn?.addEventListener("click", () => { + scaleState.mode = "manual"; + scaleState.scale = Math.min(scaleState.scale + 0.25, 5.0); + applyScale(preview, scaleState); + }); + + // Zoom out button + zoomOutBtn?.addEventListener("click", () => { + scaleState.mode = "manual"; + scaleState.scale = Math.max(scaleState.scale - 0.25, 0.25); + applyScale(preview, scaleState); + }); + + // Fit width button + fitWidthBtn?.addEventListener("click", () => { + scaleState.mode = "fit-width"; + applyScale(preview, scaleState); + }); + + // Full page button + fullPageBtn?.addEventListener("click", () => { + scaleState.mode = "full-page"; + applyScale(preview, scaleState); + }); + // Download PDF button downloadBtn?.addEventListener("click", async () => { const code = editor ? editor.value : initialCode; diff --git a/packages/markdown/assets/directive-typst/style.css b/packages/markdown/assets/directive-typst/style.css index 1c6c6982..b83c2a36 100644 --- a/packages/markdown/assets/directive-typst/style.css +++ b/packages/markdown/assets/directive-typst/style.css @@ -131,6 +131,14 @@ code-input { cursor: pointer; } +.directive-typst button.zoom-in, +.directive-typst button.zoom-out { + flex: 0; + min-width: 40px; + font-weight: bold; + font-size: 1.2em; +} + .directive-typst button:last-child { border-right: none; } diff --git a/packages/markdown/locales/de.json b/packages/markdown/locales/de.json index b550e263..5d43fe66 100644 --- a/packages/markdown/locales/de.json +++ b/packages/markdown/locales/de.json @@ -60,5 +60,9 @@ "typst-download-pdf": "PDF herunterladen", "typst-pdf-error": "Fehler beim PDF-Export", "typst-copy": "Kopieren", - "typst-loading": "Typst wird geladen..." + "typst-loading": "Typst wird geladen...", + "typst-zoom-in": "+", + "typst-zoom-out": "-", + "typst-fit-width": "Breite anpassen", + "typst-full-page": "Volle Seite" } diff --git a/packages/markdown/locales/en.json b/packages/markdown/locales/en.json index d7b57b7a..b72468be 100644 --- a/packages/markdown/locales/en.json +++ b/packages/markdown/locales/en.json @@ -60,5 +60,9 @@ "typst-download-pdf": "Download PDF", "typst-pdf-error": "Error exporting PDF", "typst-copy": "Copy", - "typst-loading": "Loading Typst..." + "typst-loading": "Loading Typst...", + "typst-zoom-in": "+", + "typst-zoom-out": "-", + "typst-fit-width": "Fit Width", + "typst-full-page": "Full Page" } diff --git a/packages/markdown/src/remarkDirectiveTypst.ts b/packages/markdown/src/remarkDirectiveTypst.ts index f68a2cf4..68021764 100644 --- a/packages/markdown/src/remarkDirectiveTypst.ts +++ b/packages/markdown/src/remarkDirectiveTypst.ts @@ -141,6 +141,62 @@ export default (ctx: HyperbookContext) => () => { ], }; + const zoomInButton: Element = { + type: "element", + tagName: "button", + properties: { + class: "zoom-in", + }, + children: [ + { + type: "text", + value: i18n.get("typst-zoom-in"), + }, + ], + }; + + const zoomOutButton: Element = { + type: "element", + tagName: "button", + properties: { + class: "zoom-out", + }, + children: [ + { + type: "text", + value: i18n.get("typst-zoom-out"), + }, + ], + }; + + const fitWidthButton: Element = { + type: "element", + tagName: "button", + properties: { + class: "fit-width", + }, + children: [ + { + type: "text", + value: i18n.get("typst-fit-width"), + }, + ], + }; + + const fullPageButton: Element = { + type: "element", + tagName: "button", + properties: { + class: "full-page", + }, + children: [ + { + type: "text", + value: i18n.get("typst-full-page"), + }, + ], + }; + if (isEditMode) { // Edit mode: show editor and preview side by side data.hChildren = [ @@ -226,7 +282,7 @@ export default (ctx: HyperbookContext) => () => { properties: { class: "buttons-container", }, - children: [copyButton, downloadButton], + children: [zoomOutButton, zoomInButton, fitWidthButton, fullPageButton, copyButton, downloadButton], }, { type: "element", diff --git a/packages/markdown/tests/__snapshots__/remarkDirectiveTypst.test.ts.snap b/packages/markdown/tests/__snapshots__/remarkDirectiveTypst.test.ts.snap index 65211a3d..7b7397d6 100644 --- a/packages/markdown/tests/__snapshots__/remarkDirectiveTypst.test.ts.snap +++ b/packages/markdown/tests/__snapshots__/remarkDirectiveTypst.test.ts.snap @@ -9,7 +9,7 @@ exports[`remarkDirectiveTypst > should default to preview mode 1`] = `
-
+
" `; @@ -23,7 +23,7 @@ exports[`remarkDirectiveTypst > should handle typst language code block 1`] = `
-
+
" `; @@ -57,7 +57,7 @@ exports[`remarkDirectiveTypst > should transform preview mode 1`] = `
-
+
" `; From 772b8ab4695b68e6e542c199051cbce6792a98f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 31 Dec 2025 13:16:27 +0000 Subject: [PATCH 7/8] Update height parameter to support CSS values and fix full-page to fit without cut-offs Co-authored-by: mikebarkmin <2592379+mikebarkmin@users.noreply.github.com> --- packages/markdown/assets/directive-typst/client.js | 10 +++++++--- packages/markdown/assets/directive-typst/style.css | 3 --- packages/markdown/src/remarkDirectiveTypst.ts | 14 ++++++++++++-- .../remarkDirectiveTypst.test.ts.snap | 8 ++++---- website/de/book/elements/typst.md | 2 +- website/en/book/elements/typst.md | 2 +- 6 files changed, 25 insertions(+), 14 deletions(-) diff --git a/packages/markdown/assets/directive-typst/client.js b/packages/markdown/assets/directive-typst/client.js index 5c3da36c..38e25d8e 100644 --- a/packages/markdown/assets/directive-typst/client.js +++ b/packages/markdown/assets/directive-typst/client.js @@ -104,6 +104,7 @@ hyperbook.typst = (function () { if (!svgElem || !original) return; const containerWidth = container.clientWidth - 32; // Account for padding + const containerHeight = container.clientHeight - 32; // Account for padding let newWidth, newHeight; @@ -112,9 +113,12 @@ hyperbook.typst = (function () { newWidth = containerWidth; newHeight = (original.height * containerWidth) / original.width; } else if (scaleState.mode === "full-page") { - // Show at 100% original size - newWidth = original.width; - newHeight = original.height; + // Fit entire page in container without cut-offs + const scaleX = containerWidth / original.width; + const scaleY = containerHeight / original.height; + const scale = Math.min(scaleX, scaleY); + newWidth = original.width * scale; + newHeight = original.height * scale; } else { // Manual scale newWidth = original.width * scaleState.scale; diff --git a/packages/markdown/assets/directive-typst/style.css b/packages/markdown/assets/directive-typst/style.css index b83c2a36..703db173 100644 --- a/packages/markdown/assets/directive-typst/style.css +++ b/packages/markdown/assets/directive-typst/style.css @@ -81,7 +81,6 @@ code-input { width: 100%; display: flex; flex-direction: column; - height: 400px; } .directive-typst .editor { @@ -162,10 +161,8 @@ code-input { @media screen and (min-width: 1024px) { .directive-typst:not(.preview-only) { flex-direction: row; - height: calc(100dvh - 128px); .preview-container { flex: 1; - height: 100% !important; } .editor-container { diff --git a/packages/markdown/src/remarkDirectiveTypst.ts b/packages/markdown/src/remarkDirectiveTypst.ts index 68021764..ab755a5d 100644 --- a/packages/markdown/src/remarkDirectiveTypst.ts +++ b/packages/markdown/src/remarkDirectiveTypst.ts @@ -31,7 +31,7 @@ export default (ctx: HyperbookContext) => () => { return (tree: Root, file: VFile) => { visit(tree, function (node) { if (isDirective(node) && node.name === name) { - const { height = 400, id = hash(node), mode = "preview", src = "" } = node.attributes || {}; + const { height, id = hash(node), mode = "preview", src = "" } = node.attributes || {}; const data = node.data || (node.data = {}); expectContainerDirective(node, file, name); @@ -60,6 +60,16 @@ export default (ctx: HyperbookContext) => () => { const isEditMode = mode === "edit"; const isPreviewMode = mode === "preview" || !isEditMode; + // Determine container height based on mode and custom height + let containerHeight: string; + if (height) { + // Custom height provided - use as-is if string, add px if number + containerHeight = typeof height === "number" || /^\d+$/.test(height) ? `${height}px` : height; + } else { + // Default heights: auto for preview, calc(100dvh - 128px) for edit + containerHeight = isPreviewMode ? "auto" : "calc(100dvh - 128px)"; + } + data.hName = "div"; data.hProperties = { class: ["directive-typst", isPreviewMode ? "preview-only" : ""].join(" ").trim(), @@ -71,7 +81,7 @@ export default (ctx: HyperbookContext) => () => { tagName: "div", properties: { class: "preview-container", - style: `height: ${height}px;`, + style: `height: ${containerHeight};`, }, children: [ { diff --git a/packages/markdown/tests/__snapshots__/remarkDirectiveTypst.test.ts.snap b/packages/markdown/tests/__snapshots__/remarkDirectiveTypst.test.ts.snap index 7b7397d6..61e9cb72 100644 --- a/packages/markdown/tests/__snapshots__/remarkDirectiveTypst.test.ts.snap +++ b/packages/markdown/tests/__snapshots__/remarkDirectiveTypst.test.ts.snap @@ -3,7 +3,7 @@ exports[`remarkDirectiveTypst > should default to preview mode 1`] = ` "
-
+
typst-loading
@@ -17,7 +17,7 @@ exports[`remarkDirectiveTypst > should default to preview mode 1`] = ` exports[`remarkDirectiveTypst > should handle typst language code block 1`] = ` "
-
+
typst-loading
@@ -31,7 +31,7 @@ exports[`remarkDirectiveTypst > should handle typst language code block 1`] = ` exports[`remarkDirectiveTypst > should transform edit mode 1`] = ` "
-
+
typst-loading
@@ -51,7 +51,7 @@ This is editable content. exports[`remarkDirectiveTypst > should transform preview mode 1`] = ` "
-
+
typst-loading
diff --git a/website/de/book/elements/typst.md b/website/de/book/elements/typst.md index 6f492751..973256af 100644 --- a/website/de/book/elements/typst.md +++ b/website/de/book/elements/typst.md @@ -80,7 +80,7 @@ $ sum_(i=1)^n i = (n(n+1))/2 $ | Option | Beschreibung | Standard | |--------|--------------|----------| | `mode` | Anzeigemodus: `preview` (nur Ansicht) oder `edit` (mit Editor) | `preview` | -| `height` | Höhe des Vorschau-Containers in Pixel | `400` | +| `height` | Höhe des Vorschau-Containers. Akzeptiert CSS-Werte wie `100px`, `50vh`, `calc(100dvh - 200px)` | `auto` für preview, `calc(100dvh - 128px)` für edit | | `src` | Pfad zu einer externen `.typ`-Datei | - | ### Laden aus externer Datei diff --git a/website/en/book/elements/typst.md b/website/en/book/elements/typst.md index 32184a13..41e884b1 100644 --- a/website/en/book/elements/typst.md +++ b/website/en/book/elements/typst.md @@ -80,7 +80,7 @@ $ sum_(i=1)^n i = (n(n+1))/2 $ | Option | Description | Default | |--------|-------------|---------| | `mode` | Display mode: `preview` (view only) or `edit` (with editor) | `preview` | -| `height` | Height of the preview container in pixels | `400` | +| `height` | Height of the preview container. Accepts CSS values like `100px`, `50vh`, `calc(100dvh - 200px)` | `auto` for preview, `calc(100dvh - 128px)` for edit | | `src` | Path to an external `.typ` file to load | - | ### Loading from External File From 6af02956a69bce4b64fa8a9a126230a850ecbbce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 31 Dec 2025 16:33:09 +0000 Subject: [PATCH 8/8] Remove scaling feature and typst-preview box-shadow Co-authored-by: mikebarkmin <2592379+mikebarkmin@users.noreply.github.com> --- .../markdown/assets/directive-typst/client.js | 93 +------------------ .../markdown/assets/directive-typst/style.css | 9 -- packages/markdown/locales/de.json | 6 +- packages/markdown/locales/en.json | 6 +- packages/markdown/src/remarkDirectiveTypst.ts | 58 +----------- .../remarkDirectiveTypst.test.ts.snap | 6 +- 6 files changed, 10 insertions(+), 168 deletions(-) diff --git a/packages/markdown/assets/directive-typst/client.js b/packages/markdown/assets/directive-typst/client.js index 38e25d8e..17e1a2d3 100644 --- a/packages/markdown/assets/directive-typst/client.js +++ b/packages/markdown/assets/directive-typst/client.js @@ -57,11 +57,8 @@ hyperbook.typst = (function () { return typstLoadPromise; }; - // Store original SVG dimensions - const svgOriginalDimensions = new Map(); - // Render typst code to SVG - const renderTypst = async (code, container, loadingIndicator, scaleState) => { + const renderTypst = async (code, container, loadingIndicator) => { // Show loading indicator if (loadingIndicator) { loadingIndicator.style.display = "flex"; @@ -72,19 +69,6 @@ hyperbook.typst = (function () { try { const svg = await $typst.svg({ mainContent: code }); container.innerHTML = svg; - - // Store original dimensions and apply scale - const svgElem = container.firstElementChild; - if (svgElem) { - const originalWidth = Number.parseFloat(svgElem.getAttribute("width")); - const originalHeight = Number.parseFloat(svgElem.getAttribute("height")); - - // Store original dimensions - svgOriginalDimensions.set(container, { width: originalWidth, height: originalHeight }); - - // Apply current scale - applyScale(container, scaleState); - } } catch (error) { container.innerHTML = `
${error.message || "Error rendering Typst"}
`; console.error("Typst rendering error:", error); @@ -96,39 +80,6 @@ hyperbook.typst = (function () { } }; - // Apply scale to SVG - const applyScale = (container, scaleState) => { - const svgElem = container.firstElementChild; - const original = svgOriginalDimensions.get(container); - - if (!svgElem || !original) return; - - const containerWidth = container.clientWidth - 32; // Account for padding - const containerHeight = container.clientHeight - 32; // Account for padding - - let newWidth, newHeight; - - if (scaleState.mode === "fit-width") { - // Fit to container width - newWidth = containerWidth; - newHeight = (original.height * containerWidth) / original.width; - } else if (scaleState.mode === "full-page") { - // Fit entire page in container without cut-offs - const scaleX = containerWidth / original.width; - const scaleY = containerHeight / original.height; - const scale = Math.min(scaleX, scaleY); - newWidth = original.width * scale; - newHeight = original.height * scale; - } else { - // Manual scale - newWidth = original.width * scaleState.scale; - newHeight = original.height * scaleState.scale; - } - - svgElem.setAttribute("width", newWidth); - svgElem.setAttribute("height", newHeight); - }; - // Export to PDF const exportPdf = async (code, id) => { await loadTypst(); @@ -155,18 +106,8 @@ hyperbook.typst = (function () { const downloadBtn = elem.querySelector(".download-pdf"); const copyBtn = elem.querySelector(".copy"); const resetBtn = elem.querySelector(".reset"); - const zoomInBtn = elem.querySelector(".zoom-in"); - const zoomOutBtn = elem.querySelector(".zoom-out"); - const fitWidthBtn = elem.querySelector(".fit-width"); - const fullPageBtn = elem.querySelector(".full-page"); const sourceTextarea = elem.querySelector(".typst-source"); - // Scale state for this element - const scaleState = { - scale: 1.0, - mode: "fit-width" // "fit-width", "full-page", or "manual" - }; - // Get initial code let initialCode = ""; if (editor) { @@ -179,48 +120,22 @@ hyperbook.typst = (function () { editor.value = result.code; } initialCode = editor.value; - renderTypst(initialCode, preview, loadingIndicator, scaleState); + renderTypst(initialCode, preview, loadingIndicator); // Listen for input changes editor.addEventListener("input", () => { store.typst?.put({ id, code: editor.value }); - renderTypst(editor.value, preview, loadingIndicator, scaleState); + renderTypst(editor.value, preview, loadingIndicator); }); }); } else if (sourceTextarea) { // Preview mode - code is in hidden textarea initialCode = sourceTextarea.value; loadTypst().then(() => { - renderTypst(initialCode, preview, loadingIndicator, scaleState); + renderTypst(initialCode, preview, loadingIndicator); }); } - // Zoom in button - zoomInBtn?.addEventListener("click", () => { - scaleState.mode = "manual"; - scaleState.scale = Math.min(scaleState.scale + 0.25, 5.0); - applyScale(preview, scaleState); - }); - - // Zoom out button - zoomOutBtn?.addEventListener("click", () => { - scaleState.mode = "manual"; - scaleState.scale = Math.max(scaleState.scale - 0.25, 0.25); - applyScale(preview, scaleState); - }); - - // Fit width button - fitWidthBtn?.addEventListener("click", () => { - scaleState.mode = "fit-width"; - applyScale(preview, scaleState); - }); - - // Full page button - fullPageBtn?.addEventListener("click", () => { - scaleState.mode = "full-page"; - applyScale(preview, scaleState); - }); - // Download PDF button downloadBtn?.addEventListener("click", async () => { const code = editor ? editor.value : initialCode; diff --git a/packages/markdown/assets/directive-typst/style.css b/packages/markdown/assets/directive-typst/style.css index 703db173..e10a4455 100644 --- a/packages/markdown/assets/directive-typst/style.css +++ b/packages/markdown/assets/directive-typst/style.css @@ -39,7 +39,6 @@ code-input { max-width: 100%; height: auto; border-radius: 8px; - box-shadow: rgba(0, 0, 0, 0.16) 0px 3px 6px, rgba(0, 0, 0, 0.16) 0px 3px 6px; } .directive-typst .typst-loading { @@ -130,14 +129,6 @@ code-input { cursor: pointer; } -.directive-typst button.zoom-in, -.directive-typst button.zoom-out { - flex: 0; - min-width: 40px; - font-weight: bold; - font-size: 1.2em; -} - .directive-typst button:last-child { border-right: none; } diff --git a/packages/markdown/locales/de.json b/packages/markdown/locales/de.json index 5d43fe66..b550e263 100644 --- a/packages/markdown/locales/de.json +++ b/packages/markdown/locales/de.json @@ -60,9 +60,5 @@ "typst-download-pdf": "PDF herunterladen", "typst-pdf-error": "Fehler beim PDF-Export", "typst-copy": "Kopieren", - "typst-loading": "Typst wird geladen...", - "typst-zoom-in": "+", - "typst-zoom-out": "-", - "typst-fit-width": "Breite anpassen", - "typst-full-page": "Volle Seite" + "typst-loading": "Typst wird geladen..." } diff --git a/packages/markdown/locales/en.json b/packages/markdown/locales/en.json index b72468be..d7b57b7a 100644 --- a/packages/markdown/locales/en.json +++ b/packages/markdown/locales/en.json @@ -60,9 +60,5 @@ "typst-download-pdf": "Download PDF", "typst-pdf-error": "Error exporting PDF", "typst-copy": "Copy", - "typst-loading": "Loading Typst...", - "typst-zoom-in": "+", - "typst-zoom-out": "-", - "typst-fit-width": "Fit Width", - "typst-full-page": "Full Page" + "typst-loading": "Loading Typst..." } diff --git a/packages/markdown/src/remarkDirectiveTypst.ts b/packages/markdown/src/remarkDirectiveTypst.ts index ab755a5d..6dfd8b84 100644 --- a/packages/markdown/src/remarkDirectiveTypst.ts +++ b/packages/markdown/src/remarkDirectiveTypst.ts @@ -151,62 +151,6 @@ export default (ctx: HyperbookContext) => () => { ], }; - const zoomInButton: Element = { - type: "element", - tagName: "button", - properties: { - class: "zoom-in", - }, - children: [ - { - type: "text", - value: i18n.get("typst-zoom-in"), - }, - ], - }; - - const zoomOutButton: Element = { - type: "element", - tagName: "button", - properties: { - class: "zoom-out", - }, - children: [ - { - type: "text", - value: i18n.get("typst-zoom-out"), - }, - ], - }; - - const fitWidthButton: Element = { - type: "element", - tagName: "button", - properties: { - class: "fit-width", - }, - children: [ - { - type: "text", - value: i18n.get("typst-fit-width"), - }, - ], - }; - - const fullPageButton: Element = { - type: "element", - tagName: "button", - properties: { - class: "full-page", - }, - children: [ - { - type: "text", - value: i18n.get("typst-full-page"), - }, - ], - }; - if (isEditMode) { // Edit mode: show editor and preview side by side data.hChildren = [ @@ -292,7 +236,7 @@ export default (ctx: HyperbookContext) => () => { properties: { class: "buttons-container", }, - children: [zoomOutButton, zoomInButton, fitWidthButton, fullPageButton, copyButton, downloadButton], + children: [copyButton, downloadButton], }, { type: "element", diff --git a/packages/markdown/tests/__snapshots__/remarkDirectiveTypst.test.ts.snap b/packages/markdown/tests/__snapshots__/remarkDirectiveTypst.test.ts.snap index 61e9cb72..eba39821 100644 --- a/packages/markdown/tests/__snapshots__/remarkDirectiveTypst.test.ts.snap +++ b/packages/markdown/tests/__snapshots__/remarkDirectiveTypst.test.ts.snap @@ -9,7 +9,7 @@ exports[`remarkDirectiveTypst > should default to preview mode 1`] = `
-
+
" `; @@ -23,7 +23,7 @@ exports[`remarkDirectiveTypst > should handle typst language code block 1`] = `
-
+
" `; @@ -57,7 +57,7 @@ exports[`remarkDirectiveTypst > should transform preview mode 1`] = `
-
+
" `;