feat: export tool with page/text preview features (#2182)

Added an editor tool to export and preview documents, with all
configurable options.

Not supported: output path customization (requires changes in server)

<img width="1381" height="1458" alt="image"
src="https://github.com/user-attachments/assets/d27e6e19-bcf4-4d1a-a20e-9bea0258be24"
/>
<img width="1392" height="877" alt="image"
src="https://github.com/user-attachments/assets/53d93601-3e05-4e36-b4fd-26384fda1347"
/>

---------

Co-authored-by: Myriad-Dreamin <camiyoru@gmail.com>
This commit is contained in:
QuadnucYard 2025-11-17 21:59:12 +08:00 committed by GitHub
parent 91d11177ba
commit a42700c04b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1510 additions and 45 deletions

View file

@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.2/schema.json",
"$schema": "https://biomejs.dev/schemas/2.3.5/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",

View file

@ -72,6 +72,7 @@ pub fn query_main(mut cmds: QueryCommands) -> Result<()> {
allow_overlapping_token: const_config.tokens_overlapping_token_support,
allow_multiline_token: const_config.tokens_multiline_token_support,
remove_html: !config.support_html_in_markdown,
support_client_codelens: true,
extended_code_action: config.extended_code_action,
completion_feat: config.completion.clone(),
color_theme: match config.color_theme.as_deref() {

View file

@ -65,6 +65,8 @@ pub struct Analysis {
pub allow_multiline_token: bool,
/// Whether to remove html from markup content in responses.
pub remove_html: bool,
/// Whether to add client-side code lens.
pub support_client_codelens: bool,
/// Whether to utilize the extended `tinymist.resolveCodeAction` at client
/// side.
///

View file

@ -21,7 +21,11 @@ impl SemanticRequest for CodeLensRequest {
let mut res = vec![];
let doc_start = ctx.to_lsp_range(0..0, &source);
let doc_lens = |title: &str, args: Vec<JsonValue>| CodeLens {
let mut doc_lens = |title: &str, args: Vec<JsonValue>| {
if !ctx.analysis.support_client_codelens {
return;
}
res.push(CodeLens {
range: doc_start,
command: Some(Command {
title: title.to_string(),
@ -29,16 +33,17 @@ impl SemanticRequest for CodeLensRequest {
arguments: Some(args),
}),
data: None,
})
};
res.push(doc_lens(
doc_lens(
&tinymist_l10n::t!("tinymist-query.code-action.profile", "Profile"),
vec!["profile".into()],
));
res.push(doc_lens(
);
doc_lens(
&tinymist_l10n::t!("tinymist-query.code-action.preview", "Preview"),
vec!["preview".into()],
));
);
let is_html = ctx
.world()
@ -46,22 +51,20 @@ impl SemanticRequest for CodeLensRequest {
.features
.is_enabled(typst::Feature::Html);
doc_lens(
&tinymist_l10n::t!("tinymist-query.code-action.export", "Export"),
vec!["export".into()],
);
if is_html {
res.push(doc_lens(
&tinymist_l10n::t!("tinymist-query.code-action.exportHtml", "Export HTML"),
vec!["export-html".into()],
));
doc_lens("HTML", vec!["export-html".into()]);
} else {
res.push(doc_lens(
&tinymist_l10n::t!("tinymist-query.code-action.exportPdf", "Export PDF"),
vec!["export-pdf".into()],
));
doc_lens("PDF", vec!["export-pdf".into()]);
}
res.push(doc_lens(
doc_lens(
&tinymist_l10n::t!("tinymist-query.code-action.more", "More .."),
vec!["more".into()],
));
);
Some(res)
}

View file

@ -174,6 +174,7 @@ impl ServerState {
allow_overlapping_token: const_config.tokens_overlapping_token_support,
allow_multiline_token: const_config.tokens_multiline_token_support,
remove_html: !config.support_html_in_markdown,
support_client_codelens: true,
extended_code_action: config.extended_code_action,
completion_feat: config.completion.clone(),
color_theme: match config.color_theme.as_deref() {

View file

@ -556,6 +556,10 @@ async function commandRunCodeLens(...args: string[]): Promise<void> {
void vscode.commands.executeCommand(`typst-preview.preview`);
return;
}
case "export": {
void vscode.commands.executeCommand(`tinymist.openExportTool`);
break;
}
case "export-html": {
await commandShow("Html");
break;

View file

@ -1,9 +1,9 @@
/** biome-ignore-all lint/suspicious/noExplicitAny: type-erased */
import * as vscode from "vscode";
import type { IContext } from "../context";
import type { ExtensionContext } from "../state";
import { extensionState, type ExtensionContext } from "../state";
import type { EditorTool, EditorToolContext } from "../tools";
import { loadHTMLFile } from "../util";
import { isTypstDocument, loadHTMLFile } from "../util";
import { handleMessage, type WebviewMessage } from "./tool/message-handler";
import { tools } from "./tool/registry";
import { ToolViewProvider } from "./tool/views";
@ -101,6 +101,29 @@ export async function updateEditorToolView<T extends EditorTool<TOptions>, TOpti
}),
);
// Track focused Typst document
let focusedDocVersion = 0;
if (isTypstDocument(extensionState.getFocusingDoc())) {
// Initial focus message
toolContext.postMessage({
type: "focusTypstDoc",
version: ++focusedDocVersion,
fsPath: extensionState.getFocusingFile(),
});
}
disposalManager.add(
vscode.window.onDidChangeTextEditorSelection(async (event) => {
if (!isTypstDocument(event.textEditor.document)) {
return;
}
toolContext.postMessage({
type: "focusTypstDoc",
version: ++focusedDocVersion,
fsPath: event.textEditor.document.uri.fsPath,
});
}),
);
// Handle panel disposal
disposalManager.add(panel.onDidDispose(dispose));

View file

@ -1,7 +1,9 @@
import * as vscode from "vscode";
import { extensionState } from "../../state";
import type { EditorToolContext } from "../../tools";
import { type ExportArgs, type ExportFormat, exportOps, provideFormats } from "../tasks.export";
import { FONTS_EXPORT_CONFIG_VERSION, USER_PACKAGE_VERSION } from "../tool";
import { base64Decode } from "../../util";
export interface WebviewMessage {
type: string;
@ -27,6 +29,7 @@ export async function handleMessage(
return false;
}
}
console.warn(`No handler for message type: ${message.type}`);
return false;
}
@ -73,6 +76,19 @@ interface StopServerProfilingMessage {
type: "stopServerProfiling";
}
interface ExportDocumentMessage {
type: "exportDocument";
format: ExportFormat;
extraArgs: ExportArgs;
}
interface GeneratePreviewMessage {
type: "generatePreview";
format: ExportFormat;
extraArgs: ExportArgs;
version: number;
}
export const messageHandlers: Record<string, MessageHandler> = {
copyToClipboard: async ({ content }: CopyToClipboardMessage) => {
await vscode.env.clipboard.writeText(content);
@ -221,4 +237,129 @@ export const messageHandlers: Record<string, MessageHandler> = {
postMessage({ type: "traceData", data: traceData });
},
exportDocument: async ({ format, extraArgs }: ExportDocumentMessage) => {
try {
const ops = exportOps(extraArgs);
const formatProvider = provideFormats(extraArgs);
// Get the active document
const uri = ops.resolveInputPath();
if (!uri) {
await vscode.window.showErrorMessage("No active document found");
return;
}
const provider = formatProvider[format];
if (!provider) {
await vscode.window.showErrorMessage(`Unsupported export format: ${format}`);
return;
}
// Execute export with configuration (file export by default)
const result = await provider.export(uri, provider.opts());
// Handle the response based on the new OnExportResponse format
if (!result) {
await vscode.window.showErrorMessage(`Export failed`);
return;
} else if ("path" in result) {
// Success case - Single
if (result.path) {
await vscode.window.showInformationMessage(`Exported successfully to: ${result.path}`);
} else {
await vscode.window.showInformationMessage("Export completed");
}
} else {
// Multiple files
const paths = result.items.map((item) => item.path).filter(Boolean);
if (paths.length > 0) {
await vscode.window.showInformationMessage(
`Exported successfully to:\n${paths.join("\n")}`,
);
} else {
await vscode.window.showInformationMessage("Export completed");
}
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await vscode.window.showErrorMessage(`Export failed: ${message}`);
}
},
generatePreview: async (
{ format, extraArgs, version }: GeneratePreviewMessage,
{ postMessage },
) => {
const sendError = (message: string) => {
postMessage({
type: "previewError",
format,
error: message,
});
};
try {
const ops = exportOps(extraArgs);
const formatProvider = provideFormats(extraArgs);
// Get the active document
const uri = ops.resolveInputPath();
if (!uri) {
// Just ignore if no active document
return;
}
// Use PNG for both PDF and PNG preview
const actualFormat = format === "pdf" ? "png" : format;
const provider = formatProvider[actualFormat];
if (!provider) {
sendError(`Unsupported export format: ${format}`);
await vscode.window.showErrorMessage(`Unsupported export format: ${format}`);
return;
}
// Execute export with configuration (file export by default)
const response = await provider.export(uri, provider.opts(), { write: false });
console.log("Preview generation response:", response);
if (!response) {
const failureMessage = "Failed to generate preview data";
sendError(failureMessage);
await vscode.window.showErrorMessage(failureMessage);
return;
}
// For visual formats, generate PNG/SVG previews
if (format === "pdf" || format === "png" || format === "svg") {
// Extract base64 data from the response
const renderedPages = "data" in response ? [{ page: 1, ...response }] : response.items;
// Determine MIME type
const mimeType = actualFormat === "svg" ? "image/svg+xml" : "image/png";
// Multiple pages
postMessage({
type: "previewGenerated",
version,
format,
pages: renderedPages.map((page) => ({
pageNumber: page.page,
imageData: `data:${mimeType};base64,${page.data}`,
})),
});
} else {
const text = "data" in response ? response.data : response.items[0].data;
postMessage({
type: "previewGenerated",
version,
format,
text: text && base64Decode(text),
});
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
sendError(`Preview generation failed: ${message}`);
}
},
};

View file

@ -1,5 +1,6 @@
import type { EditorTool } from "../../tools";
import docsView from "../../tools/docs-view";
import exporter from "../../tools/exporter";
import fontView from "../../tools/font-view";
import profileServer from "../../tools/profile-server";
import summary from "../../tools/summary";
@ -8,6 +9,7 @@ import templateGallery from "../../tools/template-gallery";
import tracing from "../../tools/tracing";
export const tools: EditorTool[] = [
exporter,
templateGallery,
summary,
tracing,

View file

@ -0,0 +1,10 @@
import { defineEditorTool } from ".";
export default defineEditorTool({
id: "exporter",
command: {
command: "tinymist.openExportTool",
title: "Export Tool",
tooltip: "Open Export Tool",
},
});

View file

@ -5,13 +5,9 @@
en = "rootPath or typstExtraArgs.root must be an absolute path: {root:?}"
zh = "rootPath 或 typstExtraArgs.root 必须是绝对路径:{root:?}"
[tinymist-query.code-action.exportHtml]
en = "Export HTML"
zh = "导出 HTML"
[tinymist-query.code-action.exportPdf]
en = "Export PDF"
zh = "导出 PDF"
[tinymist-query.code-action.export]
en = "Export"
zh = "导出"
[tinymist-query.code-action.more]
en = "More .."

View file

@ -22,7 +22,7 @@ fn test_lsp() {
});
let hash = replay_log(&root.join("neovim"));
insta::assert_snapshot!(hash, @"siphash128_13:276f3fcce1e6b59de00c4308935732ec");
insta::assert_snapshot!(hash, @"siphash128_13:f5a120d5d309c539e88e532d99cb843c");
}
{
@ -33,7 +33,7 @@ fn test_lsp() {
});
let hash = replay_log(&root.join("vscode"));
insta::assert_snapshot!(hash, @"siphash128_13:9a266bad2c9e8113b66eae3cfe83aab5");
insta::assert_snapshot!(hash, @"siphash128_13:bff2030a6c8d2038662fcbb443af583d");
}
{
@ -44,7 +44,7 @@ fn test_lsp() {
});
let hash = replay_log(&root.join("vscode-syntax-only"));
insta::assert_snapshot!(hash, @"siphash128_13:9a266bad2c9e8113b66eae3cfe83aab5");
insta::assert_snapshot!(hash, @"siphash128_13:bff2030a6c8d2038662fcbb443af583d");
}
}

View file

@ -0,0 +1,38 @@
import van, { type State } from "vanjs-core";
import { EXPORT_FORMATS } from "../formats";
import type { ExportFormat } from "../types";
const { div, span } = van.tags;
interface FormatSelectorProps {
selectedFormat: State<ExportFormat>;
}
export const FormatSelector = ({ selectedFormat }: FormatSelectorProps) => {
const handleFormatSelect = (format: ExportFormat) => {
selectedFormat.val = format;
};
return div(
{ class: "format-selector" },
...EXPORT_FORMATS.map(
(format) => () =>
FormatCard(format, selectedFormat.val.id === format.id, () => handleFormatSelect(format)),
),
);
};
const FormatCard = (format: ExportFormat, isSelected: boolean, onSelect: () => void) => {
return div(
{
class: `format-card ${isSelected ? "selected" : ""}`,
title: format.label,
onclick: onSelect,
},
div(
{ class: "flex justify-between items-center" },
span({ class: "font-semibold" }, format.label),
// span({ class: "badge font-mono" }, `.${format.fileExtension}`),
),
);
};

View file

@ -0,0 +1,50 @@
import van, { type ChildDom, type State } from "vanjs-core";
import { lastFocusedTypstDoc } from "@/vscode";
const { div, h3, input } = van.tags;
interface InputSectionProps {
inputPath: State<string>;
outputPath: State<string>;
actionButton?: ChildDom;
}
export const InputSection = ({ inputPath, actionButton }: InputSectionProps) => {
return div(
{ class: "flex flex-col gap-sm" },
h3(
{ class: "mb-xs", title: "Configure and export your Typst documents to various formats" },
"Export Document",
),
// Input Path Section
div(
{ class: "flex flex-row items-center gap-sm" },
input({
class: "input flex-1",
type: "text",
placeholder: () => lastFocusedTypstDoc.val || "Document Path",
value: inputPath,
oninput: (e: Event) => {
const target = e.target as HTMLInputElement;
inputPath.val = target.value;
},
}),
actionButton,
),
// Output Path Section (not supported yet)
/* div(
{ class: "flex flex-col gap-xs" },
h3({ class: "mb-xs" }, "Output Path"),
input({
class: "input",
type: "text",
placeholder: "Automatically decided based on input path",
value: outputPath,
oninput: (e: Event) => {
const target = e.target as HTMLInputElement;
outputPath.val = target.value;
},
}),
), */
);
};

View file

@ -0,0 +1,172 @@
import van, { type State } from "vanjs-core";
import type { ExportFormat, OptionSchema, Scalar } from "../types";
const { div, h3, label, input, select, option, span, p } = van.tags;
interface OptionsPanelProps {
format: ExportFormat;
optionStates: Record<string, State<Scalar>>;
}
export const OptionsPanel = ({ format, optionStates }: OptionsPanelProps) => {
const options = format.options;
for (const option of options) {
if (!optionStates[option.key]) {
optionStates[option.key] = van.state(option.default);
}
}
if (options.length === 0) {
return div(
{ class: "card" },
div(
{ class: "text-center" },
h3({ class: "mb-sm" }, "No Configuration Needed"),
p(
{ class: "text-desc" },
`${format.label} export doesn't require additional configuration.`,
),
),
);
}
return div(
{ class: "card" },
h3({ class: "mb-sm" }, ` Options`),
div(
{ class: "options-grid" },
...options
.filter((schema) => (schema.dependsOn ? optionStates[schema.dependsOn]?.val : true))
.map((schema) => {
const valueState = optionStates[schema.key];
if (!valueState) {
throw new Error(`Missing state for option ${schema.key}`);
}
return OptionField(schema, valueState);
}),
),
);
};
const OptionField = (schema: OptionSchema, valueState: State<Scalar>) => {
const { key, label: optionLabel, description } = schema;
const validationError = van.state<string | undefined>();
return div(
{ class: "flex flex-col gap-xs" },
label({ class: "text-sm font-medium", for: key }, optionLabel),
renderInput(schema, valueState, validationError),
() =>
validationError.val
? p({ class: "text-xs text-error" }, validationError.val)
: p({ class: "text-xs text-desc" }, description),
);
};
const renderInput = (
schema: OptionSchema,
valueState: State<Scalar | undefined>,
validationError: State<string | undefined>,
) => {
const { type, key, options: selectOptions, min, max } = schema;
switch (type) {
case "string":
return input({
class: () => (validationError.val ? "input input-error" : "input"),
type: "text",
id: key,
value: () => String((valueState.val ?? "") as string),
oninput: (e: Event) => {
const target = e.target as HTMLInputElement;
// Call custom validation function if provided
validationError.val = schema.validate?.(target.value);
valueState.val = target.value;
},
});
case "number":
return input({
class: "input",
type: "number",
id: key,
value: () => {
const current = valueState.val;
return current === undefined || current === null ? "" : String(current);
},
min: min?.toString() ?? null,
max: max?.toString() ?? null,
oninput: (e: Event) => {
const target = e.target as HTMLInputElement;
// Call custom validation function if provided
validationError.val = schema.validate?.(target.value);
valueState.val = target.value === "" ? undefined : parseFloat(target.value);
},
});
case "boolean":
return label(
{ class: "flex items-center cursor-pointer" },
input({
class: "input",
type: "checkbox",
id: key,
checked: () => Boolean(valueState.val),
onchange: (e: Event) => {
const target = e.target as HTMLInputElement;
valueState.val = target.checked;
},
}),
span({ class: "text-sm" }, "Enable"),
);
case "color":
return input({
class: "input",
type: "color",
id: key,
value: () => {
const current = valueState.val;
return typeof current === "string" && current ? current : "#ffffff";
},
onchange: (e: Event) => {
const target = e.target as HTMLInputElement;
valueState.val = target.value;
},
});
case "select":
if (!selectOptions) return span("No options available");
return select(
{
class: "select",
id: key,
value: () => {
const current = valueState.val;
return current === undefined || current === null ? "" : current.toString();
},
onchange: (e: Event) => {
const target = e.target as HTMLSelectElement;
const selectedOption = selectOptions.find(
(opt) => opt.value.toString() === target.value,
);
const newValue = selectedOption ? selectedOption.value : target.value;
valueState.val = newValue;
},
},
...selectOptions.map((opt) =>
option(
{
value: opt.value.toString(),
},
opt.label,
),
),
);
default:
return span("Unsupported option type");
}
};

View file

@ -0,0 +1,212 @@
import van, { type State } from "vanjs-core";
import type { ExportFormat, PreviewData, PreviewPage } from "../types";
const { div, h3, img, span, button, label, input } = van.tags;
interface PreviewGridProps {
format: State<ExportFormat>;
previewData: State<PreviewData>;
previewGenerating: State<boolean>;
autoPreview: State<boolean>;
onPreview: () => void;
}
export const PreviewGrid = (props: PreviewGridProps) => {
const { format, previewData, previewGenerating, autoPreview, onPreview } = props;
const thumbnailZoom = van.state<number>(100); // Percentage for thumbnail sizing
return div(
// Preview Header
div(
{ class: "flex justify-between items-center mb-md" },
div(
{ class: "flex items-center gap-sm" },
h3({ class: "text-lg font-semibold" }, () => `Preview (${format.val.label})`),
// Generate Preview Button
() =>
button(
{
class: "btn btn-secondary",
onclick: onPreview,
disabled: previewGenerating.val,
},
previewGenerating.val ? "Generating..." : "Generate Preview",
),
// Auto-preview toggle
label(
{ class: "flex items-center gap-xs cursor-pointer select-none" },
input({
type: "checkbox",
class: "toggle",
checked: autoPreview,
onchange: (e: Event) => {
const target = e.target as HTMLInputElement;
autoPreview.val = target.checked;
},
}),
span({ class: "text-sm" }, "Auto"),
),
),
() =>
div(
{ class: "flex items-center gap-sm", style: "min-height: 2rem;" },
// Only show zoom controls for image content (thumbnails)
!previewGenerating.val && previewData.val.pages && previewData.val.pages.length > 0
? ZoomControls(thumbnailZoom)
: null,
),
),
// Preview Content
() =>
(() => {
if (previewData.val.error) {
return PreviewError(previewData.val.error);
}
// Handle text content for text-based formats
if (previewData.val.text) {
return PreviewTextContent(previewData.val.text);
}
// Handle image pages for visual formats
if (previewData.val.pages) {
return PreviewPagesGrid(previewData.val.pages, thumbnailZoom.val);
}
if (previewGenerating.val) {
return PreviewLoading();
}
return PreviewEmpty(onPreview);
})(),
);
};
const PreviewLoading = () => {
return div(
{ class: "preview-loading" },
div({ class: "action-spinner" }),
"Generating preview...",
);
};
const PreviewError = (errorMessage: string) => {
return div(
{ class: "preview-error" },
span("⚠️ Failed to generate preview"),
span({ class: "text-sm" }, errorMessage),
);
};
const PreviewEmpty = (onGenerate: () => void) => {
return div(
{ class: "preview-loading" },
div(
{ class: "text-center" },
div({ class: "mb-md" }, "No preview available"),
button(
{
class: "btn",
onclick: onGenerate,
},
"Flush",
),
),
);
};
const PreviewPagesGrid = (
pages: PreviewPage[],
thumbnailZoom: number,
onImageClick?: (page: PreviewPage) => void,
) => {
const baseSize = 200; // Base thumbnail size
const scaledSize = Math.round(baseSize * (thumbnailZoom / 100));
return div(
{
class: "preview-grid",
style: `--thumbnail-size: ${scaledSize}px;`,
},
...pages.map((page) => PreviewPageCard(page, onImageClick)),
);
};
const PreviewPageCard = (page: PreviewPage, onImageClick?: (page: PreviewPage) => void) => {
return div(
{
class: "preview-page",
onclick: () => onImageClick?.(page),
},
img({
class: "preview-page-image",
src: page.imageData,
alt: `Page ${page.pageNumber + 1}`,
loading: "lazy",
}),
span(
{
class: "preview-page-number",
},
`${page.pageNumber + 1}`,
),
);
};
const PreviewTextContent = (text: string) => {
return div(
{ class: "preview-text-container" },
div({ class: "preview-text-content" }, text ?? "No text content available"),
);
};
const ZoomControls = (zoom: State<number>) => {
const MAX_ZOOM = 300;
const MIN_ZOOM = 25;
const adjustZoom = (delta: number) => {
const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom.val + delta));
zoom.val = newZoom;
};
return div(
{ class: "zoom-control flex items-center gap-xs" },
span({ class: "zoom-label" }, "Thumbnails:"),
button(
{
class: "btn btn-secondary",
onclick: () => adjustZoom(-25),
disabled: zoom.val <= MIN_ZOOM,
title: "Smaller thumbnails",
},
"",
),
span({ class: "text-xs font-medium", style: "width: 3em" }, () => `${zoom.val}%`),
button(
{
class: "btn btn-secondary",
onclick: () => adjustZoom(25),
disabled: zoom.val >= MAX_ZOOM,
title: "Larger thumbnails",
},
"+",
),
button(
{
class: "btn btn-secondary",
onclick: () => {
zoom.val = 100;
},
title: "Reset thumbnail size",
},
"100%",
),
);
};

View file

@ -0,0 +1,126 @@
import van, { type State } from "vanjs-core";
import { lastFocusedTypstDoc, requestExportDocument, requestGeneratePreview } from "@/vscode";
import { EXPORT_FORMATS } from "./formats";
import type { ExportFormat, PreviewData, PreviewPage, Scalar } from "./types";
type PreviewResponse = PreviewData & { type: string; version: number };
export function useExporter() {
const inputPath = van.state("");
const outputPath = van.state("");
const format = van.state(EXPORT_FORMATS[0]);
const optionStates: Record<string, State<Scalar>> = {};
let previewVersion = 0;
const previewGenerating = van.state(false);
const previewData = van.state<PreviewData>({});
const autoPreview = van.state(true);
const buildOptions = () => {
const extraOpts = Object.fromEntries(
format.val.options.map((option) => {
const val = optionStates[option.key]?.val;
return [option.key, val === "" ? undefined : val];
}),
);
return {
inputPath: inputPath.val.length > 0 ? inputPath.val : lastFocusedTypstDoc.val,
outputPath: outputPath.rawVal.length > 0 ? outputPath.rawVal : undefined,
...extraOpts,
};
};
const exportDocument = () => {
const exportOptions = buildOptions();
console.log("Exporting document as", format.val.id, "With options:", exportOptions);
requestExportDocument(format.val.id, exportOptions);
};
const generatePreview = () => {
previewGenerating.val = true;
const exportOptions = buildOptions();
console.log("Generate preview as", format.val.id, "With options:", exportOptions);
requestGeneratePreview(format.val.id, exportOptions, ++previewVersion);
if (import.meta.env.DEV) {
// Simulate preview generation in dev mode
setTimeout(
() => window.postMessage(createMockPreviewResponse(format.val, previewVersion)),
Math.random() * 100 + 100,
);
}
};
// Regenerate preview automatically when format or options change
van.derive(() => {
if (!autoPreview.val) return;
if (format.oldVal !== format.val) {
// Clear previous preview data when format changes
previewData.val = {};
}
generatePreview();
});
const handleMessage = (event: MessageEvent) => {
const data = event.data;
if (data.type === "previewGenerated" || data.type === "previewError") {
if (data.version < previewVersion) {
return;
}
previewData.val = data;
previewGenerating.val = false;
}
};
window?.addEventListener("message", handleMessage);
const cleanup = () => {
window?.removeEventListener("message", handleMessage);
};
return {
inputPath,
outputPath,
format,
optionStates,
previewGenerating,
previewData,
autoPreview,
exportDocument,
generatePreview,
cleanup,
};
}
function createMockPreviewResponse(format: ExportFormat, version: number): PreviewResponse {
if (Math.random() < 0.1) {
return {
type: "previewError",
version,
error: "Mock preview generation error",
};
}
if (format.id === "pdf" || format.id === "png" || format.id === "svg") {
const MOCK_IMAGE =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
const pages: PreviewPage[] = [];
for (let i = 0; i < 100; i++) {
pages.push({ pageNumber: i, imageData: MOCK_IMAGE });
}
return {
type: "previewGenerated",
version,
pages,
};
}
const text = `# ${format.label} Preview\n\nThis is mock preview data for ${format.label}.\n\n- Item 1\n- Item 2\n- Item 3`;
return {
type: "previewGenerated",
version,
text: text.repeat(10),
};
}

View file

@ -0,0 +1,256 @@
import type { ExportFormat, OptionSchema } from "./types";
const PAGES_OPT: OptionSchema = {
key: "pages",
type: "string",
label: "Page Range",
description: 'Page range to export (e.g., "1-3,5,7-9", leave empty for all pages)',
default: "",
validate: validatePageRanges,
};
const MERGE_OPTS: OptionSchema[] = [
{
key: "merged",
type: "boolean",
label: "Merge Pages",
description: "Combine selected pages into a single image",
default: false,
},
{
key: "merged.gap",
type: "string",
label: "Gap Between Pages",
description: 'Space between pages when merged (e.g., "10pt", "5mm")',
default: "0pt",
dependsOn: "merged", // Only show when merged is true
},
];
export const EXPORT_FORMATS: ExportFormat[] = [
{
id: "pdf",
label: "PDF",
fileExtension: "pdf",
options: [
PAGES_OPT,
{
key: "pdf.creationTimestamp",
type: "string",
label: "Creation Timestamp",
description:
"The document's creation date formatted as a UNIX timestamp. (leave empty for current time)",
default: "",
validate: (value: string) => {
if (value.trim() === "") {
return; // Allow empty input
}
if (!/^\d+$/.test(value)) {
return "Creation timestamp must be a valid non-negative integer UNIX timestamp";
}
const num = Number(value);
if (Number.isNaN(num) || !Number.isInteger(num) || num < 0) {
return "Creation timestamp must be a valid non-negative integer UNIX timestamp";
}
},
},
],
},
{
id: "png",
label: "PNG",
fileExtension: "png",
options: [
PAGES_OPT,
...MERGE_OPTS,
{
key: "png.ppi",
type: "number",
label: "PPI (Pixels per inch)",
description: "Resolution for the exported image",
default: 144,
min: 0,
validate: (value: string) => {
const num = Number(value);
if (Number.isNaN(num) || !Number.isFinite(num) || num <= 0) {
return "PPI must be a valid positive number";
}
},
},
{
key: "png.fill",
type: "color",
label: "Background Fill",
description: "Background color for transparent areas (use CLI instead if alpha is needed)",
default: "#ffffff",
},
],
},
{
id: "svg",
label: "SVG",
fileExtension: "svg",
options: [PAGES_OPT, ...MERGE_OPTS],
},
{
id: "html",
label: "HTML",
fileExtension: "html",
options: [],
},
{
id: "markdown",
label: "Markdown",
fileExtension: "md",
options: [
{
key: "markdown.processor",
type: "string",
label: "Processor",
description: 'Typst file for custom processing (e.g., "/processor.typ")',
default: "",
},
{
key: "markdown.assetsPath",
type: "string",
label: "Assets Path",
description: "Directory path for exported assets",
default: "",
},
],
},
{
id: "tex",
label: "TeX/LaTeX",
fileExtension: "tex",
options: [
{
key: "tex.processor",
type: "string",
label: "Processor",
description: 'Typst file for custom TeX processing (e.g., "/ieee-tex.typ")',
default: "",
},
{
key: "tex.assetsPath",
type: "string",
label: "Assets Path",
description: "Directory path for exported assets",
default: "",
},
],
},
{
id: "text",
label: "Plain Text",
fileExtension: "txt",
options: [],
},
{
id: "query",
label: "Query",
fileExtension: "json",
options: [
{
key: "query.format",
type: "select",
label: "Output Format",
description: "Format for the query results",
default: "json",
options: [
{ value: "json", label: "JSON" },
{ value: "yaml", label: "YAML" },
],
},
{
key: "query.outputExtension",
type: "string",
label: "File Extension",
description: "Custom file extension (without dot)",
default: "json",
},
{
key: "query.selector",
type: "string",
label: "Selector",
description: 'Query selector (e.g., "heading", "figure.caption")',
default: "heading",
},
{
key: "query.field",
type: "string",
label: "Field",
description: "Field to extract from selected elements",
default: "",
},
{
key: "query.strict",
type: "boolean",
label: "Strict Mode",
description: "Enable strict query parsing",
default: false,
},
{
key: "query.pretty",
type: "boolean",
label: "Pretty Print",
description: "Format output with indentation",
default: true,
},
{
key: "query.one",
type: "boolean",
label: "Single Result",
description: "Return only the first matching element",
default: false,
},
],
},
];
function validatePageRanges(value: string): string | undefined {
if (!value.trim()) {
return; // Allow empty input
}
const parts = value
.split(",")
.map((p) => p.trim())
.filter((p) => p);
for (const part of parts) {
const rangeParts = part.split("-").map((s) => s.trim());
if (rangeParts.length > 2) {
return `Invalid page range format: ${part}`;
}
if (rangeParts.length === 1) {
// Single page
const num = parseInt(rangeParts[0], 10);
if (Number.isNaN(num) || num <= 0) {
return `Invalid page number: ${part}`;
}
} else {
// Range
const [startStr, endStr] = rangeParts;
let startNum: number | undefined;
let endNum: number | undefined;
if (startStr) {
startNum = parseInt(startStr, 10);
if (Number.isNaN(startNum) || startNum <= 0) {
return `Invalid page range: ${part}`;
}
}
if (endStr) {
endNum = parseInt(endStr, 10);
if (Number.isNaN(endNum) || endNum <= 0) {
return `Invalid page range: ${part}`;
}
}
if (startNum !== undefined && endNum !== undefined && startNum > endNum) {
return `Invalid page range: ${part}`;
}
// If both start and end are empty, invalid
if (!startStr && !endStr) {
return `Invalid page range: ${part}`;
}
}
}
}

View file

@ -0,0 +1,65 @@
import van from "vanjs-core";
import "./styles.css";
import { FormatSelector } from "./components/format-selector";
import { InputSection } from "./components/inout";
import { OptionsPanel } from "./components/options-panel";
import { PreviewGrid } from "./components/preview-grid";
import { useExporter } from "./exporter";
const { div, button } = van.tags;
/**
* Main Export Tool Component
*/
const ExportTool = () => {
// Initialize state
const {
inputPath,
outputPath,
format,
optionStates,
previewGenerating,
previewData,
autoPreview,
exportDocument,
generatePreview,
} = useExporter();
// Note: cleanup() should be called when the component is unmounted
// In the current single-page architecture, this might not be needed,
// but it's available for future use if the tool becomes part of a larger app
const exportBtn = button(
{
title: "Immediately export the current document with these settings",
class: "btn action-button",
onclick: exportDocument,
},
"Export",
);
return div(
{ class: "export-tool-container flex flex-col gap-lg text-base-content" },
// Input Document Section
InputSection({ inputPath, outputPath, actionButton: exportBtn }),
// Format Selection
FormatSelector({ selectedFormat: format }),
// Options Configuration
() => OptionsPanel({ format: format.val, optionStates }),
// Preview Section
PreviewGrid({
format: format,
previewData,
previewGenerating,
autoPreview,
onPreview: generatePreview,
}),
);
};
export default ExportTool;

View file

@ -0,0 +1,227 @@
/* Export Tool Specific Styles */
.export-tool-container {
width: 100%;
max-width: 80rem;
margin: auto;
padding: 1rem;
transition: padding 0.2s ease;
}
/* Format Selector */
.format-selector {
display: grid;
gap: 0.75rem;
/* Responsive grid: 8 columns on wide screens, 4 on medium, 1 on narrow */
grid-template-columns: repeat(8, minmax(4rem, 1fr));
}
@media (max-width: 60rem) {
.format-selector {
grid-template-columns: repeat(4, minmax(4rem, 1fr));
}
}
@media (max-width: 35rem) {
.format-selector {
grid-template-columns: repeat(2, minmax(4rem, 1fr));
}
}
@media (max-width: 18rem) {
.format-selector {
grid-template-columns: 1fr;
}
}
.format-card {
padding: 0.5rem;
background: var(--vscode-panel-background, #1e1e1e);
border: 2px solid var(--vscode-panel-border, rgba(128, 128, 128, 0.35));
border-radius: 0.3rem;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.format-card:hover {
background: var(--vscode-list-hoverBackground, #2a2d2e);
border-color: var(--vscode-focusBorder, #007acc);
}
.format-card.selected {
border-color: var(--vscode-focusBorder, #007acc);
background: var(--vscode-inputOption-activeBackground, #094771);
box-shadow: 0 0 0 1px var(--vscode-focusBorder, #007acc);
}
/* Options Panel */
.options-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem 2rem;
}
/* Preview Grid */
.zoom-control {
padding: 4px 8px;
background: var(--vscode-editorWidget-background, #252526);
border-radius: 4px;
border: 1px solid var(--vscode-editorWidget-border, #454545);
}
.preview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(var(--thumbnail-size, 200px), 100%), 1fr));
gap: 0.5rem;
background: var(--vscode-editor-background, #1e1e1e);
border: 1px solid var(--vscode-panel-border, rgba(128, 128, 128, 0.35));
border-radius: 0.5rem;
padding: 1rem;
max-height: 70vh;
overflow-y: auto;
overflow-x: hidden;
}
.preview-page {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 8px;
border: 1px solid var(--vscode-widget-border);
border-radius: 0.25rem;
background: var(--vscode-editorWidget-background, #252526);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
cursor: pointer;
transition: transform 0.2s ease;
width: 100%;
height: auto;
}
.preview-page:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.preview-page-image {
width: 100%;
height: auto;
max-width: var(--thumbnail-size, 200px);
display: block;
object-fit: contain;
border: 1px solid var(--vscode-widget-border, #303031);
border-radius: 4px;
cursor: pointer;
}
.preview-page-number {
position: absolute;
top: 0.5rem;
right: 0.5rem;
margin-top: 8px;
font-size: 0.75rem;
background: rgba(0, 0, 0, 0.5);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-weight: 500;
pointer-events: none;
}
.preview-text-content {
max-height: 70vh;
overflow-y: auto;
border: 1px solid var(--vscode-widget-border, #303031);
border-radius: 4px;
padding: 16px;
background: var(--vscode-editor-background, #1e1e1e);
font-family: var(--vscode-editor-font-family, monospace);
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
margin: 8px 0;
}
.preview-loading {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: var(--vscode-descriptionForeground, #999999);
font-size: 0.875rem;
}
.preview-error {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: var(--vscode-errorForeground, #f14c4c);
font-size: 0.875rem;
text-align: center;
flex-direction: column;
gap: 0.5rem;
}
.zoom-label {
margin-right: 8px;
font-size: 12px;
color: var(--vscode-descriptionForeground, #999999);
font-weight: 500;
}
/* Action Buttons */
.action-button {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
}
.action-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--vscode-descriptionForeground, #999999);
}
.action-status.success {
color: var(--vscode-terminal-ansiGreen, #0dbc79);
}
.action-status.error {
color: var(--vscode-errorForeground, #f14c4c);
}
.action-spinner {
width: 1rem;
height: 1rem;
border: 2px solid transparent;
border-top: 2px solid currentColor;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Responsive adjustments */
@media (max-width: 768px) {
.export-tool-container {
padding: 0.5rem;
}
.options-grid {
grid-template-columns: 1fr;
}
.action-buttons {
flex-direction: column;
align-items: stretch;
}
}

View file

@ -0,0 +1,36 @@
export type Scalar = string | number | boolean;
export type ExportFormatId = "pdf" | "png" | "svg" | "html" | "markdown" | "tex" | "text" | "query";
export interface OptionSchema {
key: string;
type: "string" | "number" | "boolean" | "color" | "select";
label: string;
description?: string;
default: Scalar;
options?: Array<{ value: Scalar; label: string }>;
min?: number;
max?: number;
dependsOn?: string; // Key of another option that this option depends on
// Custom validation function for string fields
validate?: (value: string) => string | undefined; // Returns error message or undefined if valid
}
export interface ExportFormat {
id: ExportFormatId;
label: string;
fileExtension: string;
options: OptionSchema[];
}
export interface PreviewPage {
pageNumber: number; // zero-based
imageData: string; // base64 encoded PNG
}
export interface PreviewData {
// format: ExportFormatId;
pages?: PreviewPage[];
text?: string;
error?: string;
}

View file

@ -13,7 +13,8 @@ type PageComponent =
| "diagnostics"
| "symbol-view"
| "font-view"
| "docs";
| "docs"
| "exporter";
/// The frontend arguments that are passed from the backend.
interface Arguments {
@ -30,7 +31,7 @@ function retrieveArgs(): Arguments {
/// let frontend_html = frontend_html.replace(
/// "editor-tools-args:{}", ...);
/// ```
let mode = `editor-tools-args:{"page": "font-view"}`;
let mode = `editor-tools-args:{"page": "exporter"}`;
/// Remove the placeholder prefix.
mode = mode.replace("editor-tools-args:", "");

View file

@ -4,6 +4,7 @@ import { Tracing } from "./features/tracing";
import { Summary } from "./features/summary";
import { Diagnostics } from "./features/diagnostics";
import { Docs } from "./features/docs";
import Exporter from "./features/exporter";
import FontView from "./features/font-view";
mainHarness({
@ -14,4 +15,5 @@ mainHarness({
diagnostics: Diagnostics,
"font-view": FontView,
docs: Docs,
exporter: Exporter,
});

View file

@ -1,6 +1,10 @@
html,
body {
*,
::before,
::after {
margin: 0;
padding: 0;
box-sizing: border-box;
border: 0 solid;
}
/* aria-hidden */

View file

@ -15,6 +15,9 @@
.text-desc {
color: var(--vscode-descriptionForeground, rgba(204, 204, 204, 0.7));
}
.text-error {
color: var(--vscode-errorForeground, #f88070);
}
/* ===== Badges ===== */
.badge {
@ -180,13 +183,14 @@ body.typst-preview-light .btn.warning:hover {
/* ===== Form Inputs ===== */
.input {
padding: 0.25rem 0.5rem;
padding: 0.375rem 0.5rem;
font-size: 0.875rem;
background: var(--vscode-input-background, #2d2d30);
color: var(--vscode-input-foreground, #cccccc);
border: 1px solid var(--vscode-input-border, #3c3c3c);
border-radius: 4px;
outline: none;
transition: border-color 0.15s ease;
}
.input:focus {
@ -194,6 +198,38 @@ body.typst-preview-light .btn.warning:hover {
box-shadow: 0 0 0 1px var(--vscode-focusBorder, #007acc);
}
.input:disabled {
--vscode-disabledForeground: rgba(204, 204, 204, 0.5);
cursor: not-allowed;
}
.input::placeholder {
color: var(--vscode-input-placeholderForeground, #767676);
}
.input[type="checkbox"] {
width: auto;
margin-right: 0.5rem;
/* todo: larger checkbox */
}
.input[type="color"] {
width: 3rem;
height: 2rem;
padding: 0;
border-radius: 4px;
}
.select {
padding: 0.375rem 0.5rem;
font-size: 0.875rem;
background: var(--vscode-dropdown-background, #2d2d30);
color: var(--vscode-dropdown-foreground, #cccccc);
border: 1px solid var(--vscode-dropdown-border, #3c3c3c);
border-radius: 4px;
outline: none;
}
.select:focus {
border-color: var(--vscode-focusBorder, #007fd4);
}

View file

@ -1,3 +1,5 @@
@layer base, components, utilities;
@import "base.css" layer(base);
@import "components.css" layer(components);
@import "utilities.css" layer(utilities);

View file

@ -18,8 +18,14 @@
font-size: 1.5rem;
}
.font-medium {
font-weight: 500;
}
.font-semibold {
font-weight: 600;
}
.font-bold {
font-weight: bold;
font-weight: 700;
}
.italic {
@ -78,6 +84,9 @@
}
/* Spacing */
.mt-sm {
margin-top: 0.5rem;
}
.mb-sm {
margin-bottom: 0.5rem;
}
@ -87,6 +96,7 @@
.p-sm {
padding: 0.5rem;
margin-bottom: 1.0rem;
}
.gap-xs {
@ -98,8 +108,14 @@
.gap-md {
gap: 1rem;
}
.gap-lg {
gap: 1.5rem;
}
/* Interaction */
.cursor-pointer {
cursor: pointer;
}
.select-none {
user-select: none;
}

View file

@ -1,3 +1,8 @@
export interface Versioned<T> {
version: string;
data: T;
}
export interface FsFontSource {
kind: "fs";
path: string;

View file

@ -68,6 +68,9 @@ export const serverTrace = van.state<any | undefined>(undefined);
export const didStartServerProfiling = van.state<boolean>(false);
let lastFocusedTypstDocVersion = 0;
export const lastFocusedTypstDoc = van.state<string | undefined>(undefined);
export const styleAtCursor = van.state<StyleAtCursor | undefined>(undefined);
/// A frontend will try to setup a vscode channel if it is running
@ -85,15 +88,23 @@ export function setupVscodeChannel() {
serverTrace.val = event.data.data;
break;
}
case "focusTypstDoc": {
if (event.data.version >= lastFocusedTypstDocVersion) {
lastFocusedTypstDocVersion = event.data.version;
lastFocusedTypstDoc.val = event.data.fsPath;
}
break;
}
case "styleAtCursor": {
styleAtCursor.val = event.data.data;
break;
}
}
});
}
}
export function requestSavePackageData(data: any) {
export function requestSavePackageData(data: unknown) {
if (vscodeAPI?.postMessage) {
vscodeAPI.postMessage({ type: "savePackageData", data });
}
@ -168,9 +179,32 @@ export function saveDataToFile({
}: {
data: string;
path?: string;
option?: any;
option?: Record<string, unknown>;
}) {
if (vscodeAPI?.postMessage) {
vscodeAPI.postMessage({ type: "saveDataToFile", data, path, option });
}
}
export function requestGeneratePreview(
format: string,
extraArgs: Record<string, unknown>,
version: number = 0,
) {
console.log("requestGeneratePreview", format, extraArgs, version);
vscodeAPI?.postMessage?.({
type: "generatePreview",
format,
extraArgs: extraArgs ?? {},
version,
});
}
export function requestExportDocument(format: string, extraArgs: Record<string, unknown>) {
console.log("requestExportDocument", format, extraArgs);
vscodeAPI?.postMessage?.({
type: "exportDocument",
format,
extraArgs: extraArgs ?? {},
});
}