@ameyanagi/reshiki 0.0.0-stage → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +218 -2
- package/dist/editor/reshiki.d.ts +93 -0
- package/dist/editor/reshiki.js +3317 -0
- package/dist/editor/reshiki_bg.wasm +0 -0
- package/dist/editor/reshiki_bg.wasm.d.ts +29 -0
- package/dist/licenses/LICENSE +20 -0
- package/dist/licenses/LICENSE-APACHE +202 -0
- package/dist/licenses/LICENSE-MIT +21 -0
- package/dist/licenses/NOTICE +38 -0
- package/dist/licenses/THIRD-PARTY-NOTICES.txt +114672 -0
- package/dist/wasm/reshiki_web.d.ts +91 -0
- package/dist/wasm/reshiki_web.js +537 -0
- package/dist/wasm/reshiki_web_bg.wasm +0 -0
- package/dist/wasm/reshiki_web_bg.wasm.d.ts +20 -0
- package/package.json +49 -4
- package/src/editor.d.ts +58 -0
- package/src/editor.js +57 -0
- package/src/element.d.ts +19 -0
- package/src/element.js +68 -0
- package/src/embed.d.ts +29 -0
- package/src/embed.js +83 -0
- package/src/fonts.d.ts +30 -0
- package/src/fonts.js +95 -0
- package/src/frame.html +53 -0
- package/src/index.d.ts +70 -0
- package/src/index.js +56 -0
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ameyanagi/reshiki",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.12.0",
|
|
4
|
+
"description": "ReShiki in the browser: the drawing editor and structure conversion in WebAssembly",
|
|
5
|
+
"homepage": "https://github.com/Ameyanagi/ReShiki/tree/main/packages/editor#readme",
|
|
6
|
+
"bugs": "https://github.com/Ameyanagi/ReShiki/issues",
|
|
7
|
+
"license": "MIT OR Apache-2.0",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Ameyanagi/ReShiki.git",
|
|
11
|
+
"directory": "packages/editor"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"type": "module",
|
|
18
|
+
"sideEffects": [
|
|
19
|
+
"./src/element.js"
|
|
20
|
+
],
|
|
21
|
+
"types": "./src/index.d.ts",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./src/index.d.ts",
|
|
25
|
+
"default": "./src/index.js"
|
|
26
|
+
},
|
|
27
|
+
"./editor": {
|
|
28
|
+
"types": "./src/editor.d.ts",
|
|
29
|
+
"default": "./src/editor.js"
|
|
30
|
+
},
|
|
31
|
+
"./element": {
|
|
32
|
+
"types": "./src/element.d.ts",
|
|
33
|
+
"default": "./src/element.js"
|
|
34
|
+
},
|
|
35
|
+
"./embed": {
|
|
36
|
+
"types": "./src/embed.d.ts",
|
|
37
|
+
"default": "./src/embed.js"
|
|
38
|
+
},
|
|
39
|
+
"./frame.html": "./src/frame.html",
|
|
40
|
+
"./reshiki_web_bg.wasm": "./dist/wasm/reshiki_web_bg.wasm",
|
|
41
|
+
"./reshiki_bg.wasm": "./dist/editor/reshiki_bg.wasm"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "../../scripts/build_wasm.sh",
|
|
48
|
+
"test": "bun test",
|
|
49
|
+
"demo": "bun examples/serve.ts"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/editor.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Input, InputFormat, OutputFormat, BinaryFormat, TextFormat } from "./index.js";
|
|
2
|
+
|
|
3
|
+
/** Figures as the desktop's Export makes them. */
|
|
4
|
+
export type ImageFormat = "svg" | "png" | "pdf";
|
|
5
|
+
|
|
6
|
+
export interface MountOptions {
|
|
7
|
+
/** Where to load the editor's WebAssembly from; next to this package's
|
|
8
|
+
* `dist/editor/reshiki_bg.wasm` by default. */
|
|
9
|
+
module?: string | URL | Request | Response | BufferSource | WebAssembly.Module;
|
|
10
|
+
/** A drawing or structure to open. */
|
|
11
|
+
data?: Input;
|
|
12
|
+
/** The format of `data`; `auto` by default. */
|
|
13
|
+
format?: InputFormat;
|
|
14
|
+
/** Called after each edit to the drawing in front. */
|
|
15
|
+
onChange?: () => void;
|
|
16
|
+
/** TrueType or OpenType fonts (or collections) to draw with, as URLs or
|
|
17
|
+
* bytes. They load before the editor starts; text whose font lacks a glyph
|
|
18
|
+
* falls back to them. URLs on another origin need CORS. */
|
|
19
|
+
fonts?: FontSource[];
|
|
20
|
+
/** The Japanese (CJK) fallback, loaded the first time a caption needs it
|
|
21
|
+
* when no other font has its glyphs. The computer's own Japanese font is
|
|
22
|
+
* used when the page already has Local Font Access permission; otherwise
|
|
23
|
+
* this URL is downloaded: Noto Sans JP from jsDelivr by default. `false`
|
|
24
|
+
* loads nothing. */
|
|
25
|
+
cjkFont?: string | URL | false;
|
|
26
|
+
/** Called with fonts that could not be loaded; they are logged by default. */
|
|
27
|
+
onError?: (error: Error) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A font's URL, or its bytes. */
|
|
31
|
+
export type FontSource = string | URL | Uint8Array | ArrayBuffer;
|
|
32
|
+
|
|
33
|
+
export interface Editor {
|
|
34
|
+
/** Replace the drawing in front. Rejects with the app's message when the
|
|
35
|
+
* data cannot be read. */
|
|
36
|
+
load(data: Input, format?: InputFormat): Promise<void>;
|
|
37
|
+
/** The drawing in front; `rsk` (a native drawing) by default. */
|
|
38
|
+
save(format: BinaryFormat): Promise<Uint8Array>;
|
|
39
|
+
save(format?: TextFormat): Promise<string>;
|
|
40
|
+
save(format: OutputFormat): Promise<string | Uint8Array>;
|
|
41
|
+
/** The drawing in front as a figure; `svg` by default. */
|
|
42
|
+
exportImage(format?: "svg"): Promise<string>;
|
|
43
|
+
exportImage(format: "png" | "pdf"): Promise<Uint8Array>;
|
|
44
|
+
exportImage(format: ImageFormat): Promise<string | Uint8Array>;
|
|
45
|
+
/** Call `callback` after each edit; returns a function that stops it. */
|
|
46
|
+
onChange(callback: () => void): () => void;
|
|
47
|
+
/** Stop the editor and empty its element. A page cannot mount another
|
|
48
|
+
* editor afterwards; reload it, or use embed() for several editors. */
|
|
49
|
+
destroy(): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Load the editor's WebAssembly; mount() calls it. */
|
|
53
|
+
export function initEditor(module?: MountOptions["module"]): Promise<void>;
|
|
54
|
+
|
|
55
|
+
/** Start the editor in `element`, which it fills. A page can mount one
|
|
56
|
+
* editor; a second mount rejects. Data that cannot be read rejects before the
|
|
57
|
+
* editor starts, so the page can mount again. */
|
|
58
|
+
export function mount(element: HTMLElement, options?: MountOptions): Promise<Editor>;
|
package/src/editor.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The interactive editor: the desktop app built for the browser, mounted into
|
|
2
|
+
// a page element. The page and the editor share one thread and one WebAssembly
|
|
3
|
+
// instance, so a page holds one editor; embed.js puts more in iframes.
|
|
4
|
+
import load, * as wasm from "../dist/editor/reshiki.js";
|
|
5
|
+
import { loadFonts } from "./fonts.js";
|
|
6
|
+
|
|
7
|
+
const BINARY = new Set(["png", "pdf", "cdx"]);
|
|
8
|
+
const FIGURES = new Set(["svg", "png", "pdf"]);
|
|
9
|
+
const encoder = new TextEncoder();
|
|
10
|
+
const decoder = new TextDecoder();
|
|
11
|
+
let loading;
|
|
12
|
+
|
|
13
|
+
export function initEditor(module) {
|
|
14
|
+
loading ??= load(module === undefined ? undefined : { module_or_path: module });
|
|
15
|
+
return loading.then(() => undefined);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const bytes = (data) => (typeof data === "string" ? encoder.encode(data) : data);
|
|
19
|
+
|
|
20
|
+
export async function mount(element, options = {}) {
|
|
21
|
+
await initEditor(options.module);
|
|
22
|
+
// The drawing is read before the editor starts, so data that cannot be read
|
|
23
|
+
// rejects here and the page can mount again.
|
|
24
|
+
const data = options.data === undefined ? undefined : bytes(options.data);
|
|
25
|
+
const onMissingFont = await loadFonts(wasm, options);
|
|
26
|
+
await wasm.mount(element, data, options.format ?? "auto", onMissingFont);
|
|
27
|
+
let listeners = [];
|
|
28
|
+
const editor = {
|
|
29
|
+
async load(data, format = "auto") {
|
|
30
|
+
await wasm.load(bytes(data), format);
|
|
31
|
+
},
|
|
32
|
+
async save(format = "rsk") {
|
|
33
|
+
const output = await wasm.save(format);
|
|
34
|
+
return BINARY.has(format) ? output : decoder.decode(output);
|
|
35
|
+
},
|
|
36
|
+
exportImage(format = "svg") {
|
|
37
|
+
if (!FIGURES.has(format)) {
|
|
38
|
+
return Promise.reject(new Error(`Unknown image format ${format}`));
|
|
39
|
+
}
|
|
40
|
+
return editor.save(format);
|
|
41
|
+
},
|
|
42
|
+
onChange(callback) {
|
|
43
|
+
listeners = [...listeners, callback];
|
|
44
|
+
return () => {
|
|
45
|
+
listeners = listeners.filter((listener) => listener !== callback);
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
destroy() {
|
|
49
|
+
wasm.destroy();
|
|
50
|
+
listeners = [];
|
|
51
|
+
element.replaceChildren();
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
wasm.onChange(() => listeners.forEach((listener) => listener()));
|
|
55
|
+
if (options.onChange) editor.onChange(options.onChange);
|
|
56
|
+
return editor;
|
|
57
|
+
}
|
package/src/element.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Editor } from "./editor.js";
|
|
2
|
+
|
|
3
|
+
/** `<reshiki-editor src="…" format="…" fonts="…" cjk-font="…">`, defined by
|
|
4
|
+
* importing `@ameyanagi/reshiki/element`. `fonts` lists font URLs separated by
|
|
5
|
+
* spaces; `cjk-font` is the Japanese fallback's URL, or `false` for none.
|
|
6
|
+
* Fires `ready`, `change`, and `error` for a font that could not load. */
|
|
7
|
+
export class ReshikiEditor extends HTMLElement {
|
|
8
|
+
/** Resolves once the editor runs. */
|
|
9
|
+
readonly ready: Promise<Editor>;
|
|
10
|
+
load: Editor["load"];
|
|
11
|
+
save: Editor["save"];
|
|
12
|
+
exportImage: Editor["exportImage"];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
declare global {
|
|
16
|
+
interface HTMLElementTagNameMap {
|
|
17
|
+
"reshiki-editor": ReshikiEditor;
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/element.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// <reshiki-editor>: the editor as a custom element. Importing this module
|
|
2
|
+
// defines it. The element fills its own box, so give it a size.
|
|
3
|
+
//
|
|
4
|
+
// <reshiki-editor src="caffeine.mol" format="mol" style="height: 600px">
|
|
5
|
+
//
|
|
6
|
+
// `fonts` lists font URLs separated by spaces, and `cjk-font` is the Japanese
|
|
7
|
+
// fallback's URL, or "false" for none. It fires `ready` once the editor runs,
|
|
8
|
+
// `change` after each edit and `error` for a font that could not load, and has
|
|
9
|
+
// the editor's load, save and exportImage methods.
|
|
10
|
+
import { mount } from "./editor.js";
|
|
11
|
+
|
|
12
|
+
export class ReshikiEditor extends HTMLElement {
|
|
13
|
+
#editor;
|
|
14
|
+
|
|
15
|
+
connectedCallback() {
|
|
16
|
+
if (this.#editor) return;
|
|
17
|
+
if (!this.style.display) this.style.display = "block";
|
|
18
|
+
this.#editor = this.#start();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A move disconnects and reconnects the element at once; only stop the
|
|
22
|
+
// editor if it is still out of the page afterwards.
|
|
23
|
+
disconnectedCallback() {
|
|
24
|
+
this.#editor
|
|
25
|
+
?.then((editor) => {
|
|
26
|
+
if (!this.isConnected) editor.destroy();
|
|
27
|
+
})
|
|
28
|
+
.catch(() => {});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async #start() {
|
|
32
|
+
const src = this.getAttribute("src");
|
|
33
|
+
const cjkFont = this.getAttribute("cjk-font");
|
|
34
|
+
const data = src ? new Uint8Array(await (await fetch(src)).arrayBuffer()) : undefined;
|
|
35
|
+
const editor = await mount(this, {
|
|
36
|
+
data,
|
|
37
|
+
format: this.getAttribute("format") ?? "auto",
|
|
38
|
+
fonts: this.getAttribute("fonts")?.split(/\s+/).filter(Boolean),
|
|
39
|
+
cjkFont: cjkFont === "false" ? false : (cjkFont ?? undefined),
|
|
40
|
+
onChange: () => this.dispatchEvent(new Event("change")),
|
|
41
|
+
onError: (error) =>
|
|
42
|
+
this.dispatchEvent(new ErrorEvent("error", { error, message: error.message })),
|
|
43
|
+
});
|
|
44
|
+
this.dispatchEvent(new Event("ready"));
|
|
45
|
+
return editor;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Resolves once the editor runs; rejects if it could not start. */
|
|
49
|
+
get ready() {
|
|
50
|
+
return this.#editor ?? Promise.reject(new Error("The element is not in a page"));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async load(data, format) {
|
|
54
|
+
return (await this.ready).load(data, format);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async save(format) {
|
|
58
|
+
return (await this.ready).save(format);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async exportImage(format) {
|
|
62
|
+
return (await this.ready).exportImage(format);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!customElements.get("reshiki-editor")) {
|
|
67
|
+
customElements.define("reshiki-editor", ReshikiEditor);
|
|
68
|
+
}
|
package/src/embed.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Editor } from "./editor.js";
|
|
2
|
+
|
|
3
|
+
export interface EmbedOptions {
|
|
4
|
+
/** The frame page; this package's `src/frame.html` by default. It must be
|
|
5
|
+
* served with the package's `src` and `dist` folders beside it. */
|
|
6
|
+
frameUrl?: string | URL;
|
|
7
|
+
/** The iframe's accessible title. */
|
|
8
|
+
title?: string;
|
|
9
|
+
/** Called after each edit. */
|
|
10
|
+
onChange?: () => void;
|
|
11
|
+
/** Font URLs, as for mount(); relative URLs resolve against this page. */
|
|
12
|
+
fonts?: (string | URL)[];
|
|
13
|
+
/** The Japanese fallback's URL, or `false` for none, as for mount(). */
|
|
14
|
+
cjkFont?: string | URL | false;
|
|
15
|
+
/** Called with fonts that could not be loaded; they are logged by default. */
|
|
16
|
+
onError?: (error: Error) => void;
|
|
17
|
+
/** Milliseconds to wait for the frame to start before `ready` and waiting
|
|
18
|
+
* calls reject; 60000 by default. */
|
|
19
|
+
timeout?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface EmbeddedEditor extends Editor {
|
|
23
|
+
/** Resolves once the editor in the frame runs. */
|
|
24
|
+
readonly ready: Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Put the editor in an iframe inside `element`, which it fills. Each frame
|
|
28
|
+
* has its own editor, so a page can embed several. */
|
|
29
|
+
export function embed(element: HTMLElement, options?: EmbedOptions): EmbeddedEditor;
|
package/src/embed.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// The editor in an iframe, for style isolation or several editors on a page.
|
|
2
|
+
// The frame page (frame.html) runs the editor; this side forwards calls to it
|
|
3
|
+
// with postMessage and returns the same editor handle as mount().
|
|
4
|
+
const FRAME = new URL("./frame.html", import.meta.url);
|
|
5
|
+
|
|
6
|
+
export function embed(element, options = {}) {
|
|
7
|
+
const frameUrl = new URL(options.frameUrl ?? FRAME, location.href);
|
|
8
|
+
// The frame fetches fonts itself, so it gets their full URLs.
|
|
9
|
+
const fonts = (options.fonts ?? []).map((font) => new URL(font, location.href).href);
|
|
10
|
+
const cjkFont = options.cjkFont ? new URL(options.cjkFont, location.href).href : options.cjkFont;
|
|
11
|
+
if (fonts.length || cjkFont !== undefined) {
|
|
12
|
+
frameUrl.searchParams.set("fonts", JSON.stringify({ fonts, cjkFont }));
|
|
13
|
+
}
|
|
14
|
+
const frame = document.createElement("iframe");
|
|
15
|
+
// A frame on another origin needs these to copy and paste.
|
|
16
|
+
frame.allow = "clipboard-read; clipboard-write";
|
|
17
|
+
frame.src = frameUrl.href;
|
|
18
|
+
frame.title = options.title ?? "ReShiki editor";
|
|
19
|
+
frame.style.cssText = "display: block; width: 100%; height: 100%; border: 0";
|
|
20
|
+
const pending = new Map();
|
|
21
|
+
let next = 0;
|
|
22
|
+
let listeners = options.onChange ? [options.onChange] : [];
|
|
23
|
+
let started;
|
|
24
|
+
let removed = false;
|
|
25
|
+
const ready = new Promise((resolve, reject) => (started = { resolve, reject }));
|
|
26
|
+
// Calls see a failed start; it is not unhandled when nothing else waits.
|
|
27
|
+
ready.catch(() => {});
|
|
28
|
+
// A frame whose page or scripts fail to load never reports, so stop waiting.
|
|
29
|
+
const timer = setTimeout(
|
|
30
|
+
() => started.reject(new Error("The editor frame did not start")),
|
|
31
|
+
options.timeout ?? 60_000,
|
|
32
|
+
);
|
|
33
|
+
ready.finally(() => clearTimeout(timer)).catch(() => {});
|
|
34
|
+
|
|
35
|
+
const receive = ({ source, origin, data }) => {
|
|
36
|
+
if (source !== frame.contentWindow || origin !== frameUrl.origin) return;
|
|
37
|
+
if (data?.event === "ready") started.resolve();
|
|
38
|
+
else if (data?.event === "error") started.reject(new Error(data.error));
|
|
39
|
+
else if (data?.event === "change") listeners.forEach((listener) => listener());
|
|
40
|
+
else if (data?.event === "fonterror") {
|
|
41
|
+
(options.onError ?? console.error)(new Error(data.error));
|
|
42
|
+
} else if (pending.has(data?.id)) {
|
|
43
|
+
const { resolve, reject } = pending.get(data.id);
|
|
44
|
+
pending.delete(data.id);
|
|
45
|
+
if ("error" in data) reject(new Error(data.error));
|
|
46
|
+
else resolve(data.result);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
addEventListener("message", receive);
|
|
50
|
+
element.append(frame);
|
|
51
|
+
|
|
52
|
+
const call = async (method, ...args) => {
|
|
53
|
+
await ready;
|
|
54
|
+
if (removed) throw new Error("The editor was removed");
|
|
55
|
+
const id = ++next;
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
pending.set(id, { resolve, reject });
|
|
58
|
+
frame.contentWindow.postMessage({ id, method, args }, frameUrl.origin);
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
ready,
|
|
64
|
+
load: (data, format = "auto") => call("load", data, format),
|
|
65
|
+
save: (format = "rsk") => call("save", format),
|
|
66
|
+
exportImage: (format = "svg") => call("exportImage", format),
|
|
67
|
+
onChange(callback) {
|
|
68
|
+
listeners = [...listeners, callback];
|
|
69
|
+
return () => {
|
|
70
|
+
listeners = listeners.filter((listener) => listener !== callback);
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
destroy() {
|
|
74
|
+
removed = true;
|
|
75
|
+
started.reject(new Error("The editor was removed"));
|
|
76
|
+
removeEventListener("message", receive);
|
|
77
|
+
pending.forEach(({ reject }) => reject(new Error("The editor was removed")));
|
|
78
|
+
pending.clear();
|
|
79
|
+
listeners = [];
|
|
80
|
+
frame.remove();
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
package/src/fonts.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { FontSource, MountOptions } from "./editor.js";
|
|
2
|
+
|
|
3
|
+
/** The default Japanese fallback: Noto Sans JP Regular on jsDelivr. */
|
|
4
|
+
export const NOTO_SANS_JP: string;
|
|
5
|
+
|
|
6
|
+
/** The page's globals these functions use; tests pass their own. */
|
|
7
|
+
export interface FontPage {
|
|
8
|
+
navigator?: { permissions?: { query(descriptor: { name: string }): Promise<{ state: string }> } };
|
|
9
|
+
queryLocalFonts?: () => Promise<{ family: string; style: string; blob(): Promise<Blob> }[]>;
|
|
10
|
+
fetch?: typeof fetch;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The bytes of a font URL, or the bytes given. */
|
|
14
|
+
export function fontBytes(font: FontSource, fetchFont?: typeof fetch): Promise<Uint8Array>;
|
|
15
|
+
|
|
16
|
+
/** The best Japanese system font's regular and bold faces when the page
|
|
17
|
+
* already has Local Font Access permission; none otherwise. */
|
|
18
|
+
export function systemCjkFonts(page?: FontPage): Promise<Uint8Array[]>;
|
|
19
|
+
|
|
20
|
+
/** The Japanese fallback: system fonts when allowed, else `cjkFont` (Noto Sans
|
|
21
|
+
* JP by default) downloaded; nothing when it is false. */
|
|
22
|
+
export function cjkFonts(cjkFont?: MountOptions["cjkFont"], page?: FontPage): Promise<Uint8Array[]>;
|
|
23
|
+
|
|
24
|
+
/** Add `options.fonts` to the editor, and return the callback that loads the
|
|
25
|
+
* Japanese fallback when the editor first needs one (undefined for none). */
|
|
26
|
+
export function loadFonts(
|
|
27
|
+
wasm: { addFont(bytes: Uint8Array): number },
|
|
28
|
+
options?: Pick<MountOptions, "fonts" | "cjkFont" | "onError">,
|
|
29
|
+
page?: FontPage,
|
|
30
|
+
): Promise<(() => Promise<void>) | undefined>;
|
package/src/fonts.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Fonts for the editor, which can only draw with fonts loaded into it as
|
|
2
|
+
// bytes. The page's own fonts load before the editor starts. For Japanese
|
|
3
|
+
// (CJK) text the editor asks once for a fallback: the computer's own font when
|
|
4
|
+
// the page may already read local fonts, so no prompt appears, or else
|
|
5
|
+
// Noto Sans JP, downloaded once.
|
|
6
|
+
|
|
7
|
+
/** Noto Sans JP Regular (SIL Open Font License), pinned to release Sans2.004. */
|
|
8
|
+
export const NOTO_SANS_JP =
|
|
9
|
+
"https://cdn.jsdelivr.net/gh/notofonts/noto-cjk@Sans2.004/Sans/SubsetOTF/JP/NotoSansJP-Regular.otf";
|
|
10
|
+
|
|
11
|
+
// Japanese system fonts, best first: macOS, Windows, then Linux.
|
|
12
|
+
const SYSTEM_CJK = [
|
|
13
|
+
"Hiragino Sans",
|
|
14
|
+
"Hiragino Kaku Gothic ProN",
|
|
15
|
+
"Yu Gothic",
|
|
16
|
+
"Meiryo",
|
|
17
|
+
"Noto Sans CJK JP",
|
|
18
|
+
"Noto Sans JP",
|
|
19
|
+
];
|
|
20
|
+
const REGULAR_OR_BOLD = /^(regular|normal|w3|bold|w6)$/i;
|
|
21
|
+
|
|
22
|
+
/** The bytes of `font`: a URL to fetch, or the bytes themselves. */
|
|
23
|
+
export async function fontBytes(font, fetchFont = fetch) {
|
|
24
|
+
if (typeof font !== "string" && !(font instanceof URL)) {
|
|
25
|
+
return font instanceof ArrayBuffer ? new Uint8Array(font) : font;
|
|
26
|
+
}
|
|
27
|
+
let response;
|
|
28
|
+
try {
|
|
29
|
+
response = await fetchFont(font);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
throw new Error(`Could not load the font ${font}: ${error?.message ?? error}`);
|
|
32
|
+
}
|
|
33
|
+
if (!response.ok) throw new Error(`Could not load the font ${font}: HTTP ${response.status}`);
|
|
34
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The regular and bold faces of the best Japanese system font, when the
|
|
38
|
+
* page already has Local Font Access; none otherwise, without a prompt. */
|
|
39
|
+
export async function systemCjkFonts(page = globalThis) {
|
|
40
|
+
try {
|
|
41
|
+
const status = await page.navigator?.permissions?.query({ name: "local-fonts" });
|
|
42
|
+
if (status?.state !== "granted" || typeof page.queryLocalFonts !== "function") return [];
|
|
43
|
+
const fonts = await page.queryLocalFonts();
|
|
44
|
+
for (const family of SYSTEM_CJK) {
|
|
45
|
+
const faces = fonts.filter((font) => font.family === family);
|
|
46
|
+
const chosen = faces.filter((font) => REGULAR_OR_BOLD.test(font.style));
|
|
47
|
+
const use = chosen.length ? chosen : faces.slice(0, 1);
|
|
48
|
+
if (use.length) {
|
|
49
|
+
return await Promise.all(
|
|
50
|
+
use.map(async (font) => new Uint8Array(await (await font.blob()).arrayBuffer())),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// Browsers without the API or the permission name throw; download instead.
|
|
56
|
+
}
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The Japanese fallback's bytes: system fonts when allowed, else `cjkFont`
|
|
61
|
+
* (Noto Sans JP by default) downloaded; nothing when `cjkFont` is false. */
|
|
62
|
+
export async function cjkFonts(cjkFont, page = globalThis) {
|
|
63
|
+
if (cjkFont === false) return [];
|
|
64
|
+
const system = await systemCjkFonts(page);
|
|
65
|
+
if (system.length) return system;
|
|
66
|
+
return [await fontBytes(cjkFont ?? NOTO_SANS_JP, page.fetch?.bind(page) ?? fetch)];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Add the page's `fonts` to the editor (`wasm.addFont`) now, and return the
|
|
70
|
+
* callback the editor calls when it first needs the Japanese fallback, or
|
|
71
|
+
* undefined for none. Failures go to `onError`. */
|
|
72
|
+
export async function loadFonts(wasm, options = {}, page = globalThis) {
|
|
73
|
+
const onError = options.onError ?? ((error) => console.error(error));
|
|
74
|
+
const add = (bytes) => {
|
|
75
|
+
try {
|
|
76
|
+
wasm.addFont(bytes);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
onError(new Error(`Could not add a font: ${error?.message ?? error}`));
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const fetchFont = page.fetch?.bind(page) ?? fetch;
|
|
82
|
+
const loaded = await Promise.allSettled(
|
|
83
|
+
(options.fonts ?? []).map((font) => fontBytes(font, fetchFont)),
|
|
84
|
+
);
|
|
85
|
+
for (const result of loaded) {
|
|
86
|
+
if (result.status === "fulfilled") add(result.value);
|
|
87
|
+
else onError(result.reason);
|
|
88
|
+
}
|
|
89
|
+
if (options.cjkFont === false) return undefined;
|
|
90
|
+
return () =>
|
|
91
|
+
cjkFonts(options.cjkFont, page).then(
|
|
92
|
+
(fonts) => fonts.forEach(add),
|
|
93
|
+
(error) => onError(error),
|
|
94
|
+
);
|
|
95
|
+
}
|
package/src/frame.html
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>ReShiki editor</title>
|
|
7
|
+
<style>
|
|
8
|
+
html,
|
|
9
|
+
body,
|
|
10
|
+
#editor {
|
|
11
|
+
margin: 0;
|
|
12
|
+
width: 100%;
|
|
13
|
+
height: 100%;
|
|
14
|
+
overflow: hidden;
|
|
15
|
+
}
|
|
16
|
+
</style>
|
|
17
|
+
</head>
|
|
18
|
+
<body>
|
|
19
|
+
<div id="editor"></div>
|
|
20
|
+
<script type="module">
|
|
21
|
+
// The page embed.js loads into an iframe. It answers its parent's
|
|
22
|
+
// { id, method, args } messages with { id, result } or { id, error },
|
|
23
|
+
// and posts { event: "ready" | "change" } and { event: "fonterror", error }.
|
|
24
|
+
// embed() passes its fonts as JSON in the `fonts` query parameter.
|
|
25
|
+
import { mount } from "./editor.js";
|
|
26
|
+
|
|
27
|
+
const METHODS = new Set(["load", "save", "exportImage"]);
|
|
28
|
+
const post = (message, origin = "*") => parent.postMessage(message, origin);
|
|
29
|
+
const fonts = JSON.parse(new URLSearchParams(location.search).get("fonts") ?? "{}");
|
|
30
|
+
const ready = mount(document.getElementById("editor"), {
|
|
31
|
+
fonts: fonts.fonts,
|
|
32
|
+
cjkFont: fonts.cjkFont,
|
|
33
|
+
onChange: () => post({ event: "change" }),
|
|
34
|
+
onError: (error) => post({ event: "fonterror", error: String(error?.message ?? error) }),
|
|
35
|
+
});
|
|
36
|
+
ready.then(
|
|
37
|
+
() => post({ event: "ready" }),
|
|
38
|
+
(error) => post({ event: "error", error: String(error) }),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
addEventListener("message", async ({ source, origin, data }) => {
|
|
42
|
+
if (source !== parent || !METHODS.has(data?.method)) return;
|
|
43
|
+
try {
|
|
44
|
+
const editor = await ready;
|
|
45
|
+
const result = await editor[data.method](...(data.args ?? []));
|
|
46
|
+
post({ id: data.id, result }, origin);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
post({ id: data.id, error: String(error?.message ?? error) }, origin);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
</script>
|
|
52
|
+
</body>
|
|
53
|
+
</html>
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** Formats a structure or drawing can be read from. `rsk` is a native
|
|
2
|
+
* ReShiki drawing, `cdx` the binary ChemDraw file, `rsmi` reaction SMILES;
|
|
3
|
+
* `auto` tells the text formats apart. */
|
|
4
|
+
export type InputFormat =
|
|
5
|
+
| "rsk"
|
|
6
|
+
| "mol"
|
|
7
|
+
| "smiles"
|
|
8
|
+
| "inchi"
|
|
9
|
+
| "cdxml"
|
|
10
|
+
| "cdx"
|
|
11
|
+
| "rxn"
|
|
12
|
+
| "rsmi"
|
|
13
|
+
| "auto";
|
|
14
|
+
|
|
15
|
+
/** Outputs returned as bytes. */
|
|
16
|
+
export type BinaryFormat = "png" | "pdf" | "cdx";
|
|
17
|
+
/** Outputs returned as text. */
|
|
18
|
+
export type TextFormat = "rsk" | "svg" | "mol" | "smiles" | "inchi" | "cdxml" | "rxn" | "rsmi";
|
|
19
|
+
export type OutputFormat = BinaryFormat | TextFormat;
|
|
20
|
+
|
|
21
|
+
/** Text is read as UTF-8; `cdx` input must be bytes. */
|
|
22
|
+
export type Input = string | Uint8Array;
|
|
23
|
+
|
|
24
|
+
export interface PngOptions {
|
|
25
|
+
/** Default 1600; 1 to 8192. */
|
|
26
|
+
maxWidth?: number;
|
|
27
|
+
/** Default 1000; 1 to 8192. */
|
|
28
|
+
maxHeight?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Load the WebAssembly module. Every function calls it, so calling it first
|
|
32
|
+
* is needed only to load from somewhere other than next to this package's
|
|
33
|
+
* `reshiki_web_bg.wasm`, or from bytes or a compiled module. */
|
|
34
|
+
export function init(
|
|
35
|
+
module?: string | URL | Request | Response | BufferSource | WebAssembly.Module,
|
|
36
|
+
): Promise<void>;
|
|
37
|
+
|
|
38
|
+
/** Convert between formats. svg, png and pdf are figures as the desktop's
|
|
39
|
+
* Export makes them. Rejects with an Error carrying the app's message. */
|
|
40
|
+
export function convert(data: Input, from: InputFormat, to: BinaryFormat): Promise<Uint8Array>;
|
|
41
|
+
export function convert(data: Input, from: InputFormat, to: TextFormat): Promise<string>;
|
|
42
|
+
export function convert(
|
|
43
|
+
data: Input,
|
|
44
|
+
from: InputFormat,
|
|
45
|
+
to: OutputFormat,
|
|
46
|
+
): Promise<string | Uint8Array>;
|
|
47
|
+
|
|
48
|
+
/** The editor's SVG preview, with a transparent surround. */
|
|
49
|
+
export function renderSvg(data: Input, format: InputFormat): Promise<string>;
|
|
50
|
+
|
|
51
|
+
/** The editor's PNG preview on white, scaled up to 3x and down to fit. */
|
|
52
|
+
export function renderPng(
|
|
53
|
+
data: Input,
|
|
54
|
+
format: InputFormat,
|
|
55
|
+
options?: PngOptions,
|
|
56
|
+
): Promise<Uint8Array>;
|
|
57
|
+
|
|
58
|
+
/** The canonical SMILES. */
|
|
59
|
+
export function smiles(data: Input, format: InputFormat): Promise<string>;
|
|
60
|
+
|
|
61
|
+
/** The standard InChI. */
|
|
62
|
+
export function inchi(data: Input, format: InputFormat): Promise<string>;
|
|
63
|
+
|
|
64
|
+
/** Add a TrueType or OpenType font (or collection), from a URL or bytes, for
|
|
65
|
+
* figures and previews; text whose font lacks a glyph, such as Japanese,
|
|
66
|
+
* falls back to it. Resolves to the number of faces added. */
|
|
67
|
+
export function addFont(font: string | URL | Uint8Array | ArrayBuffer): Promise<number>;
|
|
68
|
+
|
|
69
|
+
/** The library version. */
|
|
70
|
+
export function version(): Promise<string>;
|