From 8784a07b2b5aea033133ae3635f8405533ff7f88 Mon Sep 17 00:00:00 2001 From: 7sDream Date: Mon, 17 Jun 2024 17:17:11 +0800 Subject: [PATCH] feat: add font list export panel in summary tool (#322) * feat(vscode/summary): add font list export pannel * feat: json/csv format, export to file and state persistence --- editors/vscode/package.json | 3 +- editors/vscode/src/editor-tools.ts | 42 ++- tools/editor-tools/package.json | 1 + tools/editor-tools/src/features/summary.ts | 386 ++++++++++++++++++++- tools/editor-tools/src/icons.ts | 17 + tools/editor-tools/src/style.css | 8 +- tools/editor-tools/src/vscode.ts | 13 + yarn.lock | 34 +- 8 files changed, 494 insertions(+), 10 deletions(-) diff --git a/editors/vscode/package.json b/editors/vscode/package.json index cfd127f65..2d7bb361d 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -519,7 +519,8 @@ }, "dependencies": { "vscode-languageclient": "^9.0.1", - "vscode-variables": "^0.1.3" + "vscode-variables": "^0.1.3", + "editor-tools": "file:../../tools/editor-tools" }, "devDependencies": { "@types/node": "^20.8.10", diff --git a/editors/vscode/src/editor-tools.ts b/editors/vscode/src/editor-tools.ts index 6bb01982f..c3a8b0721 100644 --- a/editors/vscode/src/editor-tools.ts +++ b/editors/vscode/src/editor-tools.ts @@ -1,7 +1,8 @@ import * as vscode from "vscode"; import * as path from "path"; -import { readFile } from "fs/promises"; +import { readFile, writeFile } from "fs/promises"; import { getFocusingFile, getLastFocusingDoc } from "./extension"; +import { fontsExportConfigure, fontsExportDefaultConfigure } from "editor-tools/src/features/summary"; async function loadHTMLFile(context: vscode.ExtensionContext, relativePath: string) { const filePath = path.resolve(context.extensionPath, relativePath); @@ -38,6 +39,22 @@ export function getUserPackageData(context: vscode.ExtensionContext) { return userPackageData; } +const FONTS_EXPORT_CONFIGURE_VERSION = "0.0.1"; + +export function getFontsExportConfigure(context: vscode.ExtensionContext) { + const defaultConfigure: Versioned = { + version: FONTS_EXPORT_CONFIGURE_VERSION, + data: fontsExportDefaultConfigure, + }; + + const configure = context.globalState.get("fontsExportConfigure", defaultConfigure); + if (configure?.version !== FONTS_EXPORT_CONFIGURE_VERSION) { + return defaultConfigure; + } + + return configure; +} + export async function activateEditorTool( context: vscode.ExtensionContext, tool: "template-gallery" | "tracing" | "summary" | "symbol-view" @@ -110,6 +127,14 @@ async function activateEditorToolAt( }); break; } + case "saveFontsExportConfigure": { + const data = message.data; + context.globalState.update("fontsExportConfigure", { + version: FONTS_EXPORT_CONFIGURE_VERSION, + data, + }); + break; + } case "initTemplate": { const packageSpec = message.packageSpec; const initArgs = [packageSpec]; @@ -220,6 +245,18 @@ async function activateEditorToolAt( break; } + case "saveDataToFile": { + let { data, path, option } = message; + if (typeof path !== "string") { + const uri = await vscode.window.showSaveDialog(option); + path = uri?.fsPath; + } + if (typeof path !== "string") { + return; + } + await writeFile(path, data); + break; + } default: { console.error("Unknown message type", message.type); break; @@ -270,6 +307,8 @@ async function activateEditorToolAt( break; } case "summary": { + const fontsExportConfigure = getFontsExportConfigure(context); + const fontsExportConfigureData = JSON.stringify(fontsExportConfigure.data); const [docMetrics, serverInfo] = await fetchSummaryInfo(); if (!docMetrics || !serverInfo) { @@ -284,6 +323,7 @@ async function activateEditorToolAt( return; } + html = html.replace(":[[preview:FontsExportConfigure]]:", btoa(fontsExportConfigureData)); html = html.replace(":[[preview:DocumentMetrics]]:", btoa(docMetrics)); html = html.replace(":[[preview:ServerInfo]]:", btoa(serverInfo)); break; diff --git a/tools/editor-tools/package.json b/tools/editor-tools/package.json index 6adefdd98..9098f3c76 100644 --- a/tools/editor-tools/package.json +++ b/tools/editor-tools/package.json @@ -12,6 +12,7 @@ "coverage": "vitest run --coverage" }, "dependencies": { + "csv-stringify": "^6.5.0", "detypify-service": "0.2.4", "minisearch": "^6.3.0", "vanjs-core": "^1.5.0" diff --git a/tools/editor-tools/src/features/summary.ts b/tools/editor-tools/src/features/summary.ts index e36b7a2a2..55e11273b 100644 --- a/tools/editor-tools/src/features/summary.ts +++ b/tools/editor-tools/src/features/summary.ts @@ -1,6 +1,9 @@ -import van, { ChildDom } from "vanjs-core"; -import { requestRevealPath } from "../vscode"; -const { div, a, span, code, br } = van.tags; +import van, { ChildDom, State } from "vanjs-core"; +import { stringify as csvStringify } from "csv-stringify/browser/esm/sync"; +import { requestRevealPath, requestSaveFontsExportConfigure, saveDataToFile } from "../vscode"; +import { CopyIcon } from "../icons"; +import { startModal } from "../components/modal"; +const { div, a, span, code, br, button, form, textarea, label, input } = van.tags; interface ServerInfo { root: string; @@ -168,6 +171,24 @@ export const Summary = () => { ), div( { class: `tinymist-card`, style: "flex: 1; width: 100%; padding: 10px" }, + div( + { style: "position: relative; width: 100%; height: 0px" }, + button( + { + class: "tinymist-button", + style: "position: absolute; top: 0px; right: 0px", + onclick: () => { + startModal( + div( + { style: "height: calc(100% - 20px); box-sizing: border-box; padding-top: 4px" }, + fontsExportPannel({ fonts: docMetrics.val.fontInfo, sources: docMetrics.val.spanInfo.sources }), + ), + ); + }, + }, + CopyIcon(), + ), + ), div( van.derive( () => `This document uses ${docMetrics.val.fontInfo.length} fonts.` @@ -266,6 +287,365 @@ export const Summary = () => { ); }; +interface fontsExportPannelProps { + fonts: FontInfo[], + sources: FontSource[], +} + +interface fontInfoWithSource extends Omit { + source: FontSource | null, +} + +export type fontsCSVHeader = "name" | "postscript" | "style" | "weight" | "stretch" | "location" | "path"; + +interface csvFieldExtractor { + fieldName: H, + extractor: (input: T) => string | number, +} + +type fontCSVFieldExtractor = csvFieldExtractor + +class fontsCSVGenerator { + public static readonly fieldExtractors: fontCSVFieldExtractor[] = [ + { + fieldName: "name", + extractor: info => info.fullName ?? "", + }, + { + fieldName: "postscript", + extractor: info => info.postscriptName, + }, + { + fieldName: "style", + extractor: info => info.style ?? "", + }, + { + fieldName: "weight", + extractor: info => info.weight ?? "", + }, + { + fieldName: "stretch", + extractor: info => info.stretch ?? "", + }, + { + fieldName: "location", + extractor: info => { + switch (info.source?.kind ?? "") { + case "fs": return "fileSystem"; + case "memory": return "memory"; + default: return "unknown"; + } + }, + }, + { + fieldName: "path", + extractor: info => info.source?.kind === "fs" ? info.source.path : "", + } + ]; + + public generate(fonts: fontInfoWithSource[], config: fontsExportCSVConfigure): string { + const fields = fontsCSVGenerator.fieldExtractors.filter(field => config.fields.includes(field.fieldName)); + const headers = fields.map(field => field.fieldName); + + let rows = fonts.map(font => fields.map(field => field.extractor(font))); + + // If only field is file path, do a dedupp + if (fields.length === 1 && fields[0].fieldName === "path") { + const dedup = new Set(); + rows = rows.reduce((acc, item) => { + const path = item[0]; + if (!dedup.has(path)) { + dedup.add(path); + acc.push(item); + } + return acc; + }, [] as typeof rows); + } + + return csvStringify(rows, { + header: config.header, + columns: headers, + delimiter: config.delimiter, + }); + } +} + +export type fontLocation = FontSource extends { kind: (infer Kind) } ? Kind : never; + +export interface fontsExportCSVConfigure { + header: boolean, + delimiter: string, + fields: fontsCSVHeader[], +} + +export interface fontsExportJSONConfigure { + indent: number, +} + +export interface fontsExportFormatConfigure { + csv: fontsExportCSVConfigure, + json: fontsExportJSONConfigure, +} + +export type fontsExportFormat = keyof fontsExportFormatConfigure; + +interface fontsExportCommonConfigure { + format: fontsExportFormat, + filters: { + location: fontLocation[], + }, +} + +export type fontsExportConfigure = fontsExportCommonConfigure & fontsExportFormatConfigure; + +export const fontsExportDefaultConfigure: fontsExportConfigure = { + format: "csv", + filters: { + location: ["fs"], + }, + csv: { + header: false, + delimiter: ",", + fields: ["name", "path"], + }, + json: { + indent: 2, + }, +}; + +let savedConfigureData = `:[[preview:FontsExportConfigure]]:`; + +const fontsExportPannel = ({ fonts, sources }: fontsExportPannelProps) => { + const savedConfigure: fontsExportConfigure = savedConfigureData.startsWith(":") + ? fontsExportDefaultConfigure + : JSON.parse(atob(savedConfigureData)); + + const exportFormat = van.state(savedConfigure.format); + const locationFilter = van.state(savedConfigure.filters.location); + const csvConfigure = van.state(savedConfigure.csv); + const jsonConfigure = van.state(savedConfigure.json); + + // Save state when changed + van.derive(() => { + const configure: fontsExportConfigure = { + format: exportFormat.val, + filters: { + location: locationFilter.val, + }, + csv: csvConfigure.val, + json: jsonConfigure.val, + }; + + savedConfigureData = btoa(JSON.stringify(configure)); + requestSaveFontsExportConfigure(configure); + }); + + const data: fontInfoWithSource[] = fonts.map(font => { + let source = typeof font.source === "number" ? sources[font.source] : null; + return Object.assign({}, font, { source }); + }); + + const filteredData = van.derive(() => { + return data.filter(font => locationFilter.val.includes(font.source?.kind ?? "" as any)); + }); + + const exportText = van.derive(() => { + switch (exportFormat.val) { + case "csv": { + const csvGenerator = new fontsCSVGenerator(); + return csvGenerator.generate(filteredData.val, csvConfigure.val); + } + case "json": { + return JSON.stringify(filteredData.val, null, jsonConfigure.val.indent); + } + } + }); + + const titleWidth = 72; + const rowGap = 8; + + const labelInputGap = 4; + const itemGap = 10; + const groupGap = 20; + + const labeledInput = ( + title: string, el: HTMLInputElement, + { labelStyle } = { labelStyle: "" } + ) =>span({ style: `display: inline-flex; column-gap: ${labelInputGap}px; align-items: center` }, + label({ for: el.id, style: labelStyle }, title), el, + ); + + const makeArrayCheckbox = ( + id: string, value: string, state: State | string[], + ) => { + const checked = Array.isArray(state) ? state.includes(value) : state.val.includes(value); + return input({ + id, type: "checkbox", style: "margin: 0px", + value, checked, + onchange: (e: any) => { + if (e.target.checked) { + Array.isArray(state) ? state.push(e.target.value) : state.val = [...state.rawVal, e.target.value]; + } else { + if (Array.isArray(state)) { + let index = state.indexOf(e.target.value); + if (index !== -1) { + state.splice(index, 1); + } + } else { + state.val = state.val.filter(v => v !== e.target.value); + } + } + }, + }); + }; + + const filtersUI = () => div({ class: "flex-col", style: `row-gap: ${rowGap}px` }, + div({ class: "flex-row", style: "align-items: center" }, + div({ style: `width: ${titleWidth}px` }, "Location"), + div({ class: "flex-row", style: `flex: 1; flex-wrap: wrap; column-gap: ${itemGap}px` }, + labeledInput("FileSystem", makeArrayCheckbox("filter-locations-fs", "fs", locationFilter)), + labeledInput("Memory", makeArrayCheckbox("filter-locations-memory", "memory", locationFilter)), + ), + ), + ); + + const chooseExportFormatUI = () => div({ class: "flex-row", style: "align-items: center" }, + div({ style: `width: ${titleWidth}px` }, "Format"), + div({ class: "flex-row", style: `flex: 1; flex-wrap: wrap; column-gap: ${itemGap}px`}, + labeledInput("CSV", input({ + id: "export-format-csv", type: "radio", name: "export-format", style: "margin: 0px", + checked: exportFormat.val === "csv", + onchange: (e) => { + if (e.target.checked) { + exportFormat.val = "csv"; + } + }, + })), + labeledInput("JSON", input({ + id: "export-format-json", type: "radio", name: "export-format", style: "margin: 0px", + checked: exportFormat.val === "json", + onchange: (e) => { + if (e.target.checked) { + exportFormat.val = "json"; + } + }, + })), + ), + ); + + const csvConfigureUI = () => form( + { + class: "flex-col", style: `row-gap: ${rowGap}px`, + onchange: (_e) => { + csvConfigure.val = Object.assign({}, csvConfigure.val); + }, + onsubmit: (e) => e.preventDefault(), + }, + div({ class: "flex-row", style: "align-items: center" }, + div({ style: `width: ${titleWidth}px` }, "Settings"), + div({ class: "flex-row", style: `flex: 1; flex-wrap: wrap; column-gap: ${groupGap}px`}, + labeledInput("Header", input({ + id: "csv-header", type: "checkbox", style: "margin: 0px", + checked: csvConfigure.val.header, + onchange: e => csvConfigure.rawVal.header = e.target.checked + })), + labeledInput("Delimiter:", input({ + id: "csv-delimiter", type: "input", style: `width: 40px`, + value: csvConfigure.val.delimiter, + oninput: e => csvConfigure.rawVal.delimiter = e.target.value, + onkeydown: e => e.stopPropagation(), // prevent modal window closed by space when input + })), + ), + ), + div( + { class: "flex-row", style: "align-items: center"}, + div({ style: `width: ${titleWidth}px` }, "Fields"), + div({ class: "flex-row", style: `flex: 1; flex-wrap: wrap; column-gap: ${itemGap}px`}, + ...fontsCSVGenerator.fieldExtractors + .map(fe => fe.fieldName) + .map(field => labeledInput(field, makeArrayCheckbox(`csv-field-${field}`, field, csvConfigure.rawVal.fields))), + ), + ), + ); + + const jsonConfigureUI = () => form( + { + onchange: (_e) => { + jsonConfigure.val = Object.assign({}, jsonConfigure.val); + }, + onsubmit: (e) => e.preventDefault(), + }, + div({ class: "flex-row", style: "align-items: center" }, + div({ style: `width: ${titleWidth}px` }, "Settings"), + div({ class: "flex-row", style: `flex: 1; flex-wrap: wrap; column-gap: ${groupGap}px`}, + labeledInput("Indent:", input({ + id: "json-indent", type: "number", style: "width: 40px;", + min: "0", max: "8", step: "2", value: jsonConfigure.val.indent, + onchange: e => jsonConfigure.rawVal.indent = parseInt(e.target.value, 10), + }), { labelStyle: "margin-right: 0.5em" }), + ), + ), + ); + + const exportFormatConfigureUI = () => { + let ui; + switch (exportFormat.val) { + case "csv": { + ui = csvConfigureUI(); + break; + } + case "json": { + ui = jsonConfigureUI(); + break; + } + } + return ui; + }; + + return div({ class: "flex-col", style: `row-gap: ${rowGap}px; width: 100%; height: 100%` }, + filtersUI, + chooseExportFormatUI, + exportFormatConfigureUI, + textarea( + { + class: "tinymist-code", + style: "resize: none; width: 100%; flex: 1; white-space: pre; overflow-wrap: normal; overflow-x: scroll", + readOnly: true, onkeydown: e => e.stopPropagation(), + }, + exportText, + ), + div( + { style: `display: flex; align-items: center; column-gap:${itemGap}px` }, + button( + { + class: "tinymist-button", + style: "flex: 1", + onclick: () => { + const filterName = `${exportFormat.val.toLocaleUpperCase()} file`; + saveDataToFile({ + data: exportText.val, + option: { + filters: { + [filterName]: [exportFormat.val], + }, + }, + }); + }, + }, + "Export", + ), + button( + { + class: "tinymist-button", + style: "flex: 1", + onclick: () => navigator.clipboard.writeText(exportText.val), + }, + "Copy", + ), + ), + ); +} + interface SpanInfo { sources: FontSource[]; } diff --git a/tools/editor-tools/src/icons.ts b/tools/editor-tools/src/icons.ts index 90bc73233..064e88f79 100644 --- a/tools/editor-tools/src/icons.ts +++ b/tools/editor-tools/src/icons.ts @@ -47,3 +47,20 @@ export const AddIcon = (sz: number = 16) => `, }); + +export const CopyIcon = (sz: number = 16) => + div({ + class: "tinymist-icon", + style: `height: ${sz}px; width: ${sz}px;`, + innerHTML: ` + + + + + + + +`, + }); diff --git a/tools/editor-tools/src/style.css b/tools/editor-tools/src/style.css index 042a9196f..0beb40677 100644 --- a/tools/editor-tools/src/style.css +++ b/tools/editor-tools/src/style.css @@ -96,6 +96,10 @@ body { transition: color 0.05s; } +.tinymist-code { + font-family: Cascadia Code, Consolas, SF Mono, DejaVu Sans Mono, monospace; +} + #tinymist-app, .tinymist-main-window { margin: 24px 28px; } @@ -167,11 +171,11 @@ body.typst-preview-light .tinymist-button.warning.activated { background: rgba(243, 202, 99, 0.9); } -.tinymist-icon path { +.tinymist-icon :is(path, rect) { fill: currentColor; } -.tinymist-icon path.stroke-based { +.tinymist-icon :is(path, rect).stroke-based { fill: none; stroke: currentColor; } diff --git a/tools/editor-tools/src/vscode.ts b/tools/editor-tools/src/vscode.ts index 730d94328..b037d9e8c 100644 --- a/tools/editor-tools/src/vscode.ts +++ b/tools/editor-tools/src/vscode.ts @@ -1,4 +1,5 @@ import van from "vanjs-core"; +import type { fontsExportConfigure } from "./features/summary"; const vscodeAPI = typeof acquireVsCodeApi !== "undefined" && acquireVsCodeApi(); @@ -69,6 +70,12 @@ export function requestSavePackageData(data: any) { } } +export function requestSaveFontsExportConfigure(data: fontsExportConfigure) { + if (vscodeAPI?.postMessage) { + vscodeAPI.postMessage({ type: "saveFontsExportConfigure", data }); + } +} + export function requestInitTemplate(packageSpec: string) { if (vscodeAPI?.postMessage) { vscodeAPI.postMessage({ type: "initTemplate", packageSpec }); @@ -106,3 +113,9 @@ export function requestTextEdit(edit: TextEdit) { ); } } + +export function saveDataToFile({data, path, option}: { data: string, path?: string, option?: any}) { + if (vscodeAPI?.postMessage) { + vscodeAPI.postMessage({ type: "saveDataToFile", data, path, option}); + } +} diff --git a/yarn.lock b/yarn.lock index ac37ec88a..a06ba5b74 100644 --- a/yarn.lock +++ b/yarn.lock @@ -993,6 +993,11 @@ css-what@^6.1.0: resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== +csv-stringify@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/csv-stringify/-/csv-stringify-6.5.0.tgz#7b1491893c917e018a97de9bf9604e23b88647c2" + integrity sha512-edlXFVKcUx7r8Vx5zQucsuMg4wb/xT6qyz+Sr1vnLrdXqlLD1+UKyWNyZ9zn6mUW1ewmGxrpVwAcChGF0HQ/2Q== + debug@^3.2.7: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" @@ -1122,6 +1127,14 @@ eastasianwidth@^0.2.0: resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== +"editor-tools@file:tools/editor-tools": + version "0.0.0" + dependencies: + csv-stringify "^6.5.0" + detypify-service "0.2.4" + minisearch "^6.3.0" + vanjs-core "^1.5.0" + emoji-regex@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" @@ -2800,8 +2813,16 @@ std-env@^3.3.3: resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.7.0.tgz#c9f7386ced6ecf13360b6c6c55b8aaa4ef7481d2" integrity sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0: - name string-width-cjs +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -2853,7 +2874,14 @@ string_decoder@^1.1.1: dependencies: safe-buffer "~5.2.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==