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
This commit is contained in:
7sDream 2024-06-17 17:17:11 +08:00 committed by GitHub
parent 1c653d5fd2
commit 8784a07b2b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 494 additions and 10 deletions

View file

@ -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",

View file

@ -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<fontsExportConfigure> = {
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;

View file

@ -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"

View file

@ -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<FontInfo, "source"> {
source: FontSource | null,
}
export type fontsCSVHeader = "name" | "postscript" | "style" | "weight" | "stretch" | "location" | "path";
interface csvFieldExtractor<H, T> {
fieldName: H,
extractor: (input: T) => string | number,
}
type fontCSVFieldExtractor = csvFieldExtractor<fontsCSVHeader, fontInfoWithSource>
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<fontsExportFormat>(savedConfigure.format);
const locationFilter = van.state<fontLocation[]>(savedConfigure.filters.location);
const csvConfigure = van.state<fontsExportCSVConfigure>(savedConfigure.csv);
const jsonConfigure = van.state<fontsExportJSONConfigure>(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<string>(() => {
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[]> | 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[];
}

View file

@ -47,3 +47,20 @@ export const AddIcon = (sz: number = 16) =>
<path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"></path>
</svg>`,
});
export const CopyIcon = (sz: number = 16) =>
div({
class: "tinymist-icon",
style: `height: ${sz}px; width: ${sz}px;`,
innerHTML: `<svg width="${sz}px" height="${sz}px" viewBox="0 0 16 16" version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<g stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect class="stroke-based" width="9.8202543" height="11.792212" x="1.742749" y="3.4055943" ry="0.49967012" />
<path class="stroke-based" d="m 5.1841347,0.82574918 9.0495613,0.0341483 V 12.129165" />
<path class="stroke-based" d="M 3.6542046,6.2680732 H 9.3239071" />
<path class="stroke-based" d="M 3.6542046,12.48578 H 7.7302609" />
<path class="stroke-based" d="M 3.6542046,9.3769264 H 7.7302609" />
</g>
</svg>`,
});

View file

@ -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;
}

View file

@ -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});
}
}

View file

@ -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==