mirror of
https://github.com/Myriad-Dreamin/tinymist.git
synced 2025-11-19 19:44:18 +00:00
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:
parent
91d11177ba
commit
a42700c04b
29 changed files with 1510 additions and 45 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -21,24 +21,29 @@ 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 {
|
||||
range: doc_start,
|
||||
command: Some(Command {
|
||||
title: title.to_string(),
|
||||
command: "tinymist.runCodeLens".to_string(),
|
||||
arguments: Some(args),
|
||||
}),
|
||||
data: None,
|
||||
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(),
|
||||
command: "tinymist.runCodeLens".to_string(),
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -202,8 +202,8 @@ async function languageActivate(context: IContext) {
|
|||
|
||||
const initTemplateCommand =
|
||||
(inPlace: boolean) =>
|
||||
(...args: string[]) =>
|
||||
initTemplate(context.context, inPlace, ...args);
|
||||
(...args: string[]) =>
|
||||
initTemplate(context.context, inPlace, ...args);
|
||||
|
||||
// prettier-ignore
|
||||
context.subscriptions.push(
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
10
editors/vscode/src/tools/exporter.ts
Normal file
10
editors/vscode/src/tools/exporter.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { defineEditorTool } from ".";
|
||||
|
||||
export default defineEditorTool({
|
||||
id: "exporter",
|
||||
command: {
|
||||
command: "tinymist.openExportTool",
|
||||
title: "Export Tool",
|
||||
tooltip: "Open Export Tool",
|
||||
},
|
||||
});
|
||||
|
|
@ -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 .."
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`),
|
||||
),
|
||||
);
|
||||
};
|
||||
50
tools/editor-tools/src/features/exporter/components/inout.ts
Normal file
50
tools/editor-tools/src/features/exporter/components/inout.ts
Normal 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;
|
||||
},
|
||||
}),
|
||||
), */
|
||||
);
|
||||
};
|
||||
|
|
@ -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");
|
||||
}
|
||||
};
|
||||
|
|
@ -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%",
|
||||
),
|
||||
);
|
||||
};
|
||||
126
tools/editor-tools/src/features/exporter/exporter.ts
Normal file
126
tools/editor-tools/src/features/exporter/exporter.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
256
tools/editor-tools/src/features/exporter/formats.ts
Normal file
256
tools/editor-tools/src/features/exporter/formats.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
65
tools/editor-tools/src/features/exporter/index.ts
Normal file
65
tools/editor-tools/src/features/exporter/index.ts
Normal 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;
|
||||
227
tools/editor-tools/src/features/exporter/styles.css
Normal file
227
tools/editor-tools/src/features/exporter/styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
36
tools/editor-tools/src/features/exporter/types.ts
Normal file
36
tools/editor-tools/src/features/exporter/types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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:", "");
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
html,
|
||||
body {
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
border: 0 solid;
|
||||
}
|
||||
|
||||
/* aria-hidden */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
@layer base, components, utilities;
|
||||
|
||||
@import "base.css" layer(base);
|
||||
@import "components.css" layer(components);
|
||||
@import "utilities.css" layer(utilities);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
export interface Versioned<T> {
|
||||
version: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface FsFontSource {
|
||||
kind: "fs";
|
||||
path: string;
|
||||
|
|
|
|||
|
|
@ -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 ?? {},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue