дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
feat(plugins): web views, document bridge and the Excalidraw official plugin
Plugins can now ship a web page as the UI of a view or custom editor (`web = "web/index.html"`), so editors built on web libraries are plugins rather than part of the core. - Web views: every file under the page's directory is part of the package (checked, copied on install, covered by the digest and signature) and served through the `openidea-plugin` URL scheme, under a per-revision path with a CSP of its own, into an `<iframe sandbox="allow-scripts">`. An update or reinstall reloads open pages. - Frame bridge (`window.openidea`), shared by html bodies and web pages: theme, messages to the plugin's code, `document.get/edit/replace/onChange` (versioned edits that join the text editor's undo history, Save and dirty state), `saveFile`/`openFile` through the IDE's dialogs, and IDE shortcuts the page leaves alone. - Custom editor layouts: tab, split (Editor and X) or preview over the text editor, switchable from the tab's actions. - New points `fileIcons` and `fileTemplates`; new optional capability `clipboard` for web views. - Excalidraw official plugin: `.excalidraw` / `.excalidraw.json` open as an interactive canvas kept in step with the file's text, with self-hosted fonts, PNG/SVG export, images, file icons, a New Drawing template and JSON highlighting. Closes #732. - Fixes: a lock-order deadlock between listing plugins and refreshing project pins; the dev server ignored edits under `src/features/plugins`. - `plugins/build.sh` builds and checks web pages; CI typechecks and tests them. Co-authored-by: Deng Liu <[email protected]>
Цей коміт міститься в:
1 джерело
78e0c7a23e
коміт
900f2e6819
55 files changed
+6237
-135
No files matched your search
+67
-15
@@ -98,21 +98,23 @@ work), but 0.1 code no longer runs: rebuild it against the 1.0 SDK.
|
||||
|
||||
### Contribution points
|
||||
|
||||
| Point | Declaration | Code needed |
|
||||
| --------------- | -------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| `commands` | `id`, `title`, `category?`, `when?` | to run them |
|
||||
| `themes` | `path` (theme JSON), `editor-scheme?` | no |
|
||||
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
|
||||
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
|
||||
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
|
||||
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
|
||||
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
|
||||
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `tools`), `command`, `when?` | no |
|
||||
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
|
||||
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
|
||||
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
|
||||
| `views` | `id`, `title`, `icon?`, `location` (`left`, `right`, `bottom`) | yes |
|
||||
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`) | yes |
|
||||
| Point | Declaration | Code needed |
|
||||
| --------------- | -------------------------------------------------------------------------------------------------- | ------------- |
|
||||
| `commands` | `id`, `title`, `category?`, `when?` | to run them |
|
||||
| `themes` | `path` (theme JSON), `editor-scheme?` | no |
|
||||
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
|
||||
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
|
||||
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
|
||||
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
|
||||
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
|
||||
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `tools`), `command`, `when?` | no |
|
||||
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
|
||||
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
|
||||
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
|
||||
| `views` | `id`, `title`, `icon?`, `location` (`left`, `right`, `bottom`), `web?` (see _Web views_) | without `web` |
|
||||
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` |
|
||||
| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no |
|
||||
| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no |
|
||||
|
||||
Declared contributions are live as soon as the plugin is enabled — commands
|
||||
show in Find Action, menus and the status bar, shortcuts work, themes and icon
|
||||
@@ -162,6 +164,7 @@ machine. Users can revoke optional capabilities in Settings › Plugins.
|
||||
| `process:<program>` | high | `Project::run_process`, language servers, debug adapters, runs |
|
||||
| `net:<host>` | medium | `host::fetch` to that host (`*.example.com`: its subdomains) |
|
||||
| `secrets` | medium | `{{secret:<name>}}` in request headers, `host::secret_is_set` |
|
||||
| `clipboard` | medium | the clipboard API in the plugin's web views (keyboard copy / paste work without it) |
|
||||
|
||||
`process:` capabilities are also derived from what the contributions start: a
|
||||
language's `language-servers` and `debugger` commands and a run detector's
|
||||
@@ -384,6 +387,12 @@ plugin's editor, or through _Open With_ for `option`. For a custom editor
|
||||
Undo / Redo are one history with it, and Save, the dirty mark and the close
|
||||
prompts are the document's. _Open as Text_ opens the text editor.
|
||||
|
||||
`layout` puts a custom editor beside the text editor instead of in a tab of
|
||||
its own: with `"split"` or `"preview"` the file opens in the text editor,
|
||||
with your editor next to it (the layout files start in) and the _Editor Only /
|
||||
Editor and … / … Only_ switch in the tab strip, as for Markdown. `"tab"`
|
||||
(the default) is the editor's own tab.
|
||||
|
||||
`Html` is the escape hatch for content the declarative bodies can't show. It
|
||||
runs in a sandboxed frame — an opaque origin under a CSP that allows only its
|
||||
inline scripts and styles and `data:` images, no network, no IDE API — and
|
||||
@@ -407,6 +416,49 @@ Scripts get the same as `openidea.theme` (`{ dark, vars }`) and
|
||||
`openidea.onThemeChange(fn)`, for drawing on a canvas, say. Variables are only
|
||||
ever added.
|
||||
|
||||
#### Web views
|
||||
|
||||
A view or custom editor can instead be a web page of the package, for UIs the
|
||||
declarative bodies can't express (a drawing canvas, a diagram editor) — no
|
||||
code needed:
|
||||
|
||||
```toml
|
||||
[[contributes.customEditors]]
|
||||
id = "canvas"
|
||||
title = "Drawing"
|
||||
patterns = ["*.excalidraw"]
|
||||
layout = "preview"
|
||||
web = "web/index.html" # the page; its directory is what it can load
|
||||
```
|
||||
|
||||
The page's directory (never the package root) is part of the declaration:
|
||||
every file under it is checked (at most 4096 files and 256 MiB, no links),
|
||||
installed and signed with the package. The IDE loads the page from its own
|
||||
`openidea-plugin` URL scheme into an `<iframe sandbox="allow-scripts">`: an
|
||||
opaque origin, no network, no cookies or storage, no IDE API, and only the
|
||||
page's own files (relative URLs, ES modules, fonts, WebAssembly) — so build
|
||||
it with relative paths (Vite: `base: "./"`). The same `openidea` object as
|
||||
in `html` bodies is there before the page's scripts run, plus:
|
||||
|
||||
| `window.openidea` | What it does |
|
||||
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `document.get()` | `{ version, text, readOnly, name }` of the custom editor's document (`name`: the file's, without its directory; none in a tool window) |
|
||||
| `document.edit(version, edits)` | Applies `[{ from, to, insert }]` (UTF-16 offsets) as one change of the document's undo history; rejects with `stale` when not at `version`; resolves `{ version }` |
|
||||
| `document.replace(version, text)` | The same with the whole text (sent as the smallest edit) |
|
||||
| `document.onChange(fn)` | `{ version, text }` after changes made elsewhere: the text editor, Undo, another window |
|
||||
| `saveFile({ name, data, filters? })` | The IDE's save dialog; `data` is a string, bytes or a Blob; the chosen path, or `null`. The page never names a path itself |
|
||||
| `openFile({ filters? })` | The IDE's open dialog; `{ name, data }` (bytes), or `null` |
|
||||
| `postMessage` / `onMessage` | To and from the plugin's code, when it has some |
|
||||
|
||||
Save, the dirty mark and Undo are the document's, as for every custom editor.
|
||||
IDE shortcuts the page doesn't handle itself (Save, Close, Find Action…) still
|
||||
work while it has focus; editing keys (⌘C, ⌘V, ⌘Z…) stay with the page. With
|
||||
the `clipboard` capability the page may use `navigator.clipboard`.
|
||||
`plugins/official/excalidraw` is a complete example: a Vite + React page in
|
||||
`web-src/`, built into `web/` by `plugins/build.sh`. The built page is not
|
||||
checked in (it is tens of MB): run `plugins/build.sh official/excalidraw`
|
||||
before installing the plugin from the repository.
|
||||
|
||||
### Settings
|
||||
|
||||
```toml
|
||||
|
||||
+57
-13
@@ -1,17 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the repository's Rust plugins into WebAssembly components
|
||||
# (wasm32-wasip2 emits a component directly; no cargo-component / wasm-tools).
|
||||
# Builds the repository's plugins: Rust code into WebAssembly components
|
||||
# (wasm32-wasip2 emits a component directly; no cargo-component / wasm-tools),
|
||||
# and web pages (`web-src/`, a Vite project) into `web/`.
|
||||
#
|
||||
# plugins/build.sh # every component the repo checks in
|
||||
# plugins/build.sh # every plugin with code or a web page
|
||||
# plugins/build.sh hello # one plugin (a name under plugins/, or a path)
|
||||
# plugins/build.sh --check [...] # change nothing; fail if a checked-in component
|
||||
# # is stale or a plugin doesn't build (CI)
|
||||
# plugins/build.sh --check [...] # change nothing tracked; fail if a checked-in
|
||||
# # component is stale or a plugin doesn't build (CI)
|
||||
#
|
||||
# Output: <plugin>/dist/plugin.wasm (referenced by `wasm = ...` in plugin.toml)
|
||||
# and <plugin>/dist/plugin.wasm.sources, a fingerprint of the sources it was
|
||||
# built from: the plugin (minus target/ and dist/), the SDK and wit/. Builds
|
||||
# are not byte-reproducible across machines, so --check compares fingerprints,
|
||||
# not bytes. CARGO_TARGET_DIR, when set, is shared by all plugins.
|
||||
# built from: the plugin (minus target/, dist/, web/ and web-src/), the SDK
|
||||
# and wit/. Components are small and checked in; builds are not
|
||||
# byte-reproducible across machines, so --check compares fingerprints, not
|
||||
# bytes. A web page (web-src/, a pnpm project) is built into <plugin>/web/,
|
||||
# which is not checked in (it can be tens of MB): build it before installing
|
||||
# the plugin from the repository. web.sources (not checked in either) skips
|
||||
# rebuilding an unchanged page; --check builds it too and checks the result.
|
||||
# CARGO_TARGET_DIR, when set, is shared by all plugins.
|
||||
set -euo pipefail
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
repo="$(cd "$here/.." && pwd)"
|
||||
@@ -33,20 +39,58 @@ list() {
|
||||
local dir="$1"
|
||||
shift
|
||||
(cd "$dir" && git ls-files --cached --others --exclude-standard -- "$@") |
|
||||
{ grep -Ev '(^|/)(dist|target)/|(^|/)\.DS_Store$' || true; } | LC_ALL=C sort -u | while read -r f; do
|
||||
{ grep -Ev '(^|/)(dist|target|node_modules)/|(^|/)\.DS_Store$|^web(-src)?/|^web\.sources$' || true; } | LC_ALL=C sort -u | while read -r f; do
|
||||
if [ -f "$dir/$f" ]; then printf '%s %s\n' "$f" "$(sha256 <"$dir/$f" | cut -d' ' -f1)"; fi
|
||||
done
|
||||
}
|
||||
|
||||
# Prints the fingerprint of everything plugin $1 is built from.
|
||||
# Prints the fingerprint of everything plugin $1's component is built from.
|
||||
fingerprint() {
|
||||
{ list "$1" .; list "$repo" sdk/rust/openidea-plugin/Cargo.toml sdk/rust/openidea-plugin/src sdk/rust/openidea-plugin/wit wit; } |
|
||||
sha256 | cut -d' ' -f1
|
||||
}
|
||||
|
||||
# Prints the fingerprint of plugin $1's web page sources.
|
||||
web_fingerprint() {
|
||||
(cd "$1/web-src" && git ls-files --cached --others --exclude-standard -- .) |
|
||||
{ grep -Ev '(^|/)node_modules/|(^|/)\.DS_Store$' || true; } | LC_ALL=C sort -u | while read -r f; do
|
||||
if [ -f "$1/web-src/$f" ]; then printf '%s %s\n' "$f" "$(sha256 <"$1/web-src/$f" | cut -d' ' -f1)"; fi
|
||||
done | sha256 | cut -d' ' -f1
|
||||
}
|
||||
|
||||
# Builds plugin $1's web page into web/ unless it was built from the current
|
||||
# web-src/, then checks what the manifest's `web = "…"` entries need: each
|
||||
# page is there and loads its files by relative URL (it is served from the package).
|
||||
build_web() {
|
||||
local dir="$1" rel="${1#"$repo"/}" want entry
|
||||
want="$(web_fingerprint "$dir")"
|
||||
if [ "$want" != "$(cat "$dir/web.sources" 2>/dev/null || true)" ] || [ ! -d "$dir/web" ]; then
|
||||
echo "building the web page of $rel"
|
||||
if ! { pnpm --dir "$dir/web-src" install --frozen-lockfile --ignore-workspace --silent &&
|
||||
pnpm --dir "$dir/web-src" run build; }; then
|
||||
echo "FAIL $rel: the web page does not build"
|
||||
failed=1
|
||||
return
|
||||
fi
|
||||
echo "$want" >"$dir/web.sources"
|
||||
fi
|
||||
for entry in $(sed -n 's/^web *= *"\(.*\)".*/\1/p' "$dir/plugin.toml" | sort -u); do
|
||||
if [ ! -f "$dir/$entry" ]; then
|
||||
echo "FAIL $rel: $entry was not built"
|
||||
failed=1
|
||||
elif grep -Eq '(src|href)="/' "$dir/$entry"; then
|
||||
echo "FAIL $rel: $entry loads files by absolute URL; build it with relative paths (Vite: base: \"./\")"
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
echo "ok $rel (web, $(du -sh "$dir/web" | cut -f1))"
|
||||
}
|
||||
|
||||
failed=0
|
||||
build() {
|
||||
local dir="$1"
|
||||
if [ -f "$dir/web-src/package.json" ]; then build_web "$dir"; fi
|
||||
[ -f "$dir/Cargo.toml" ] || return 0
|
||||
local name target out
|
||||
name="$(sed -n 's/^name *= *"\(.*\)"/\1/p' "$dir/Cargo.toml" | head -1)"
|
||||
target="${CARGO_TARGET_DIR:-$dir/target}"
|
||||
@@ -86,10 +130,10 @@ if [ $# -gt 0 ]; then
|
||||
fi
|
||||
done
|
||||
else
|
||||
# Every checked-in component built from a plugin.toml + Cargo.toml directory.
|
||||
for manifest in "$here"/*/Cargo.toml "$here"/official/*/Cargo.toml "$repo"/crates/ide-plugin/tests/fixtures/*/Cargo.toml; do
|
||||
# Every component (plugin.toml + Cargo.toml) and web page (plugin.toml + web-src/).
|
||||
for manifest in "$here"/*/plugin.toml "$here"/official/*/plugin.toml "$repo"/crates/ide-plugin/tests/fixtures/*/plugin.toml; do
|
||||
dir="$(dirname "$manifest")"
|
||||
[ -f "$dir/plugin.toml" ] && build "$dir"
|
||||
if [ -f "$dir/Cargo.toml" ] || [ -f "$dir/web-src/package.json" ]; then build "$dir"; fi
|
||||
done
|
||||
fi
|
||||
exit "$failed"
|
||||
@@ -0,0 +1,4 @@
|
||||
web-src/node_modules/
|
||||
# Built by plugins/build.sh, not checked in.
|
||||
web/
|
||||
web.sources
|
||||
@@ -0,0 +1,28 @@
|
||||
# Third-party software
|
||||
|
||||
The page in `web/` is built from `web-src/` and bundles:
|
||||
|
||||
- **Excalidraw** (`@excalidraw/excalidraw` 0.18.1), MIT License, Copyright (c) 2020 Excalidraw —
|
||||
https://github.com/excalidraw/excalidraw — with the libraries it loads on demand (among them
|
||||
Mermaid, KaTeX and Cytoscape.js, MIT License).
|
||||
- **React** and **React DOM**, MIT License, Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
- The fonts Excalidraw ships in `web/fonts/` (Excalifont, Virgil, Xiaolai, Nunito, Lilita One,
|
||||
Comic Shanns, Cascadia Code, Liberation Sans, Assistant), each under its own licence (SIL Open
|
||||
Font License 1.1, or MIT for Comic Shanns); see the Excalidraw repository for their texts.
|
||||
|
||||
The MIT License:
|
||||
|
||||
> Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||
> and associated documentation files (the "Software"), to deal in the Software without
|
||||
> restriction, including without limitation the rights to use, copy, modify, merge, publish,
|
||||
> distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
|
||||
> Software is furnished to do so, subject to the following conditions:
|
||||
>
|
||||
> The above copyright notice and this permission notice shall be included in all copies or
|
||||
> substantial portions of the Software.
|
||||
>
|
||||
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
|
||||
> BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<rect x="1.5" y="2.5" width="13" height="11" rx="2" fill="#F1EFFF" stroke="#6E5BD8"/>
|
||||
<path d="M4 10.5c1.2-2.6 2.4-3.9 3.6-3.9 1.5 0 1.1 2.4 2.6 2.4.9 0 1.5-.8 1.8-1.6" stroke="#6E5BD8" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="4" cy="10.5" r="1" fill="#6E5BD8"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 411 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<rect x="1.5" y="2.5" width="13" height="11" rx="2" fill="#2B2747" stroke="#A99CFF"/>
|
||||
<path d="M4 10.5c1.2-2.6 2.4-3.9 3.6-3.9 1.5 0 1.1 2.4 2.6 2.4.9 0 1.5-.8 1.8-1.6" stroke="#A99CFF" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="4" cy="10.5" r="1" fill="#A99CFF"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 411 B |
@@ -0,0 +1,6 @@
|
||||
id = "excalidraw"
|
||||
name = "Excalidraw"
|
||||
extensions = ["excalidraw"]
|
||||
indent = { tab-size = 2, insert-spaces = true }
|
||||
ui-mode = "JSON"
|
||||
tree-sitter = "json"
|
||||
@@ -0,0 +1,35 @@
|
||||
id = "openidea.excalidraw"
|
||||
name = "Excalidraw"
|
||||
version = "0.1.0"
|
||||
description = "Draw and edit Excalidraw diagrams (.excalidraw). The drawing is the file's text, so the text editor follows, Undo and Save are shared, and the IDE theme applies."
|
||||
authors = ["OpenIDEA contributors"]
|
||||
license = "Apache-2.0"
|
||||
change-notes = "0.1.0 — first release: the Excalidraw canvas next to (or instead of) the file's text, kept in step both ways; export to PNG and SVG through the save dialog; a file icon and a New › Excalidraw Drawing template. Based on the Excalidraw editor proposed by Deng Liu (#738)."
|
||||
engines.openidea = "^1.0"
|
||||
|
||||
[capabilities]
|
||||
# Copy and paste through Excalidraw's menus (the keyboard works without it).
|
||||
optional = ["clipboard"]
|
||||
|
||||
[[contributes.customEditors]]
|
||||
id = "canvas"
|
||||
title = "Drawing"
|
||||
patterns = ["*.excalidraw", "*.excalidraw.json"]
|
||||
priority = "default"
|
||||
layout = "preview"
|
||||
web = "web/index.html"
|
||||
|
||||
[[contributes.languages]]
|
||||
path = "languages/excalidraw.toml"
|
||||
|
||||
[[contributes.fileIcons]]
|
||||
id = "excalidraw"
|
||||
patterns = ["*.excalidraw", "*.excalidraw.json"]
|
||||
icon = "icons/excalidraw.svg"
|
||||
iconDark = "icons/excalidraw_dark.svg"
|
||||
|
||||
[[contributes.fileTemplates]]
|
||||
id = "drawing"
|
||||
name = "Excalidraw Drawing"
|
||||
extension = "excalidraw"
|
||||
path = "templates/drawing.excalidraw"
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"type": "excalidraw",
|
||||
"version": 2,
|
||||
"source": "https://openidea.dev",
|
||||
"elements": [],
|
||||
"appState": {
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"gridModeEnabled": false,
|
||||
"viewBackgroundColor": "#ffffff"
|
||||
},
|
||||
"files": {}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Excalidraw</title>
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.openidea-error {
|
||||
box-sizing: border-box;
|
||||
padding: 24px;
|
||||
font: var(--openidea-font-size, 13px) var(--openidea-font-ui, system-ui, sans-serif);
|
||||
color: var(--openidea-fg);
|
||||
}
|
||||
.openidea-error pre {
|
||||
white-space: pre-wrap;
|
||||
color: var(--openidea-danger);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "openidea-plugin-excalidraw-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "The Excalidraw plugin's web page; `pnpm build` writes it to ../web.",
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/excalidraw": "0.18.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"jsdom": "^30.1.3",
|
||||
"typescript": "~6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^5.0.5"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Excalidraw loads its fonts from `EXCALIDRAW_ASSET_PATH` (else from a CDN,
|
||||
* which the frame can't reach): the page's own directory, where the build
|
||||
* copies them. Imported before Excalidraw.
|
||||
*/
|
||||
window.EXCALIDRAW_ASSET_PATH = new URL("./", window.location.href).href;
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* The frame may not download files (no `allow-downloads`): Excalidraw's
|
||||
* "Save to disk" and image export click a download link, which this turns
|
||||
* into the IDE's save dialog (`openidea.saveFile`).
|
||||
*/
|
||||
const click = HTMLAnchorElement.prototype.click;
|
||||
|
||||
export function routeDownloadsToTheIde(bridge: OpenideaBridge) {
|
||||
HTMLAnchorElement.prototype.click = function (this: HTMLAnchorElement) {
|
||||
const href = this.href;
|
||||
if (!this.hasAttribute("download") || !/^(blob|data):/.test(href)) return click.call(this);
|
||||
const name = this.download || "untitled";
|
||||
void fetch(href)
|
||||
.then((r) => r.blob())
|
||||
.then((data) => bridge.saveFile({ name, data, filters: filtersFor(name) }))
|
||||
.catch((e: unknown) => console.error("could not save", name, e));
|
||||
};
|
||||
}
|
||||
|
||||
function filtersFor(name: string): OpenideaFileFilter[] | undefined {
|
||||
const ext = /\.([\w]+)$/.exec(name)?.[1]?.toLowerCase();
|
||||
if (!ext) return undefined;
|
||||
const label: Record<string, string> = { png: "PNG image", svg: "SVG image", excalidraw: "Excalidraw drawing" };
|
||||
return [{ name: label[ext] ?? ext.toUpperCase(), extensions: [ext] }];
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* The Excalidraw plugin's page: the canvas of the `.excalidraw` file its
|
||||
* custom editor shows, kept in step with the file's text (see sync.ts), in
|
||||
* the IDE's light or dark theme.
|
||||
*/
|
||||
import "./assets";
|
||||
import { Excalidraw, MainMenu, THEME } from "@excalidraw/excalidraw";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
import type { ExcalidrawImperativeAPI, UIOptions } from "@excalidraw/excalidraw/types";
|
||||
import { StrictMode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { routeDownloadsToTheIde } from "./downloads";
|
||||
import { applyScene, codec, exportName, loadSceneFonts, type Scene } from "./scene";
|
||||
import { DocumentSync } from "./sync";
|
||||
|
||||
// The canvas is the file's: the IDE opens, saves and themes it, so the page
|
||||
// keeps only what works on the drawing itself (no Open / Save to, no links
|
||||
// out of a page without network).
|
||||
const UI_OPTIONS: UIOptions = {
|
||||
canvasActions: {
|
||||
changeViewBackgroundColor: true,
|
||||
clearCanvas: true,
|
||||
export: false,
|
||||
loadScene: false,
|
||||
saveAsImage: true,
|
||||
saveToActiveFile: false,
|
||||
toggleTheme: false,
|
||||
},
|
||||
};
|
||||
|
||||
function Menu() {
|
||||
return (
|
||||
<MainMenu>
|
||||
<MainMenu.DefaultItems.SaveAsImage />
|
||||
<MainMenu.DefaultItems.SearchMenu />
|
||||
<MainMenu.DefaultItems.Help />
|
||||
<MainMenu.DefaultItems.ClearCanvas />
|
||||
<MainMenu.Separator />
|
||||
<MainMenu.DefaultItems.ChangeCanvasBackground />
|
||||
</MainMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function useIdeTheme(bridge: OpenideaBridge): "dark" | "light" {
|
||||
const [dark, setDark] = useState(bridge.theme?.dark ?? window.matchMedia?.("(prefers-color-scheme: dark)").matches);
|
||||
useEffect(() => bridge.onThemeChange((t) => setDark(t.dark)), [bridge]);
|
||||
return dark ? THEME.DARK : THEME.LIGHT;
|
||||
}
|
||||
|
||||
type Start = { scene: Scene | null; error: string | null; readOnly: boolean; name: string };
|
||||
|
||||
function App({ bridge }: { bridge: OpenideaBridge }) {
|
||||
const theme = useIdeTheme(bridge);
|
||||
const [api, setApi] = useState<ExcalidrawImperativeAPI | null>(null);
|
||||
const [start, setStart] = useState<Start | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// The document's `source`: the canvas doesn't keep it, the file should.
|
||||
const source = useRef<string | undefined>(undefined);
|
||||
// `apply` reads the API when the document changes, which is after the canvas exists.
|
||||
const sync = useMemo(() => {
|
||||
let current: ExcalidrawImperativeAPI | null = null;
|
||||
const s = new DocumentSync(bridge.document, codec, {
|
||||
apply: (scene) => {
|
||||
source.current = scene.source;
|
||||
if (current) applyScene(current, scene);
|
||||
},
|
||||
error: setError,
|
||||
});
|
||||
return Object.assign(s, { bind: (a: ExcalidrawImperativeAPI | null) => void (current = a) });
|
||||
}, [bridge]);
|
||||
|
||||
useEffect(() => {
|
||||
sync.start().then(
|
||||
(s) => {
|
||||
source.current = s.scene?.source;
|
||||
setStart(s);
|
||||
setError(s.error);
|
||||
},
|
||||
(e: unknown) =>
|
||||
setStart({ scene: null, error: String(e instanceof Error ? e.message : e), readOnly: true, name: "" }),
|
||||
);
|
||||
}, [sync]);
|
||||
|
||||
useEffect(() => {
|
||||
sync.bind(api);
|
||||
if (api) void loadSceneFonts(api);
|
||||
}, [api, sync]);
|
||||
|
||||
if (!start) return null;
|
||||
if (!start.scene && error)
|
||||
return (
|
||||
<div className="openidea-error">
|
||||
<p>This file can't be shown as a drawing until its text is valid again:</p>
|
||||
<pre>{error}</pre>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Excalidraw
|
||||
excalidrawAPI={setApi}
|
||||
initialData={start.scene ? { ...start.scene, scrollToContent: true } : null}
|
||||
theme={theme}
|
||||
viewModeEnabled={start.readOnly}
|
||||
name={exportName(start.name)}
|
||||
UIOptions={UI_OPTIONS}
|
||||
onChange={(elements, appState, files) => sync.changed({ elements, appState, files, source: source.current })}
|
||||
>
|
||||
<Menu />
|
||||
</Excalidraw>
|
||||
);
|
||||
}
|
||||
|
||||
const bridge = window.openidea;
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
if (bridge) {
|
||||
routeDownloadsToTheIde(bridge);
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<App bridge={bridge} />
|
||||
</StrictMode>,
|
||||
);
|
||||
} else {
|
||||
root.render(<p className="openidea-error">This page runs inside OpenIDEA.</p>);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/** The IDE's frame bridge (`window.openidea`), injected before the page's scripts. */
|
||||
interface OpenideaDocumentState {
|
||||
version: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface OpenideaTextEdit {
|
||||
from: number;
|
||||
to: number;
|
||||
insert: string;
|
||||
}
|
||||
|
||||
interface OpenideaFileFilter {
|
||||
name: string;
|
||||
extensions: string[];
|
||||
}
|
||||
|
||||
interface OpenideaBridge {
|
||||
postMessage(text: string): void;
|
||||
onMessage(fn: (text: string) => void): void;
|
||||
readonly theme: { dark: boolean; vars: Readonly<Record<string, string>> } | null;
|
||||
onThemeChange(fn: (theme: { dark: boolean; vars: Readonly<Record<string, string>> }) => void): void;
|
||||
document: {
|
||||
/** `name`: the file's name, without its directory. */
|
||||
get(): Promise<OpenideaDocumentState & { readOnly: boolean; name: string }>;
|
||||
onChange(fn: (state: OpenideaDocumentState) => void): void;
|
||||
edit(version: number, edits: OpenideaTextEdit[]): Promise<{ version: number }>;
|
||||
replace(version: number, text: string): Promise<{ version: number }>;
|
||||
};
|
||||
saveFile(options: {
|
||||
name: string;
|
||||
data: string | Uint8Array | ArrayBuffer | Blob;
|
||||
filters?: OpenideaFileFilter[];
|
||||
}): Promise<string | null>;
|
||||
openFile(options?: { filters?: OpenideaFileFilter[] }): Promise<{ name: string; data: Uint8Array } | null>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
openidea?: OpenideaBridge;
|
||||
EXCALIDRAW_ASSET_PATH?: string | string[];
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SOURCE, exportName, parseScene, serializeScene } from "./scene";
|
||||
|
||||
const scene = (text: string) => {
|
||||
const parsed = parseScene(text);
|
||||
if ("error" in parsed) throw new Error(parsed.error);
|
||||
return parsed.scene;
|
||||
};
|
||||
|
||||
describe("scene files", () => {
|
||||
it("keeps the file's source and writes ours, never the page's address, when it had none", () => {
|
||||
const own = JSON.parse(
|
||||
serializeScene(scene('{"type":"excalidraw","source":"https://excalidraw.com","elements":[]}')),
|
||||
);
|
||||
expect(own.source).toBe("https://excalidraw.com");
|
||||
const none = JSON.parse(serializeScene(scene('{"type":"excalidraw","elements":[]}')));
|
||||
expect(none.source).toBe(SOURCE);
|
||||
for (const page of ["openidea-plugin://localhost", "http://openidea-plugin.localhost"]) {
|
||||
const fixed = JSON.parse(serializeScene(scene(`{"type":"excalidraw","source":"${page}","elements":[]}`)));
|
||||
expect(fixed.source).toBe(SOURCE);
|
||||
}
|
||||
});
|
||||
|
||||
it("reads element skeletons and refuses what isn't a drawing", () => {
|
||||
// (Labels become text elements in the browser; jsdom can't measure text.)
|
||||
const s = scene('[{"type":"rectangle","x":0,"y":0,"width":10,"height":10}]');
|
||||
expect(s.elements[0]).toMatchObject({ type: "rectangle", width: 10 });
|
||||
expect(typeof s.elements[0]!.seed).toBe("number");
|
||||
expect(parseScene("{not json")).toHaveProperty("error");
|
||||
expect(parseScene('{"type":"other"}')).toHaveProperty("error");
|
||||
expect(parseScene("")).toHaveProperty("scene");
|
||||
});
|
||||
|
||||
it("names exports after the file", () => {
|
||||
expect(exportName("diagram.excalidraw")).toBe("diagram");
|
||||
expect(exportName("Flow.Excalidraw.json")).toBe("Flow");
|
||||
expect(exportName("notes.v2.excalidraw")).toBe("notes.v2");
|
||||
expect(exportName("")).toBe("drawing");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* The one module that talks to `@excalidraw/excalidraw`'s data API: reading a
|
||||
* `.excalidraw` file into a scene (through Excalidraw's own `restore`, so old
|
||||
* files and element skeletons work), writing it back in the format
|
||||
* Excalidraw saves, and showing a scene that changed in the text.
|
||||
*/
|
||||
import {
|
||||
CaptureUpdateAction,
|
||||
convertToExcalidrawElements,
|
||||
getNonDeletedElements,
|
||||
getSceneVersion,
|
||||
restore,
|
||||
serializeAsJSON,
|
||||
} from "@excalidraw/excalidraw";
|
||||
import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
|
||||
import type { AppState, BinaryFiles, ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
||||
import type { SceneCodec } from "./sync";
|
||||
|
||||
export interface Scene {
|
||||
elements: readonly OrderedExcalidrawElement[];
|
||||
appState: Partial<AppState>;
|
||||
files: BinaryFiles;
|
||||
/** The file's `source`, kept as it was. */
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/** `source` of files that had none (Excalidraw would write the page's address). */
|
||||
export const SOURCE = "https://openidea.dev";
|
||||
|
||||
type Skeleton = NonNullable<Parameters<typeof convertToExcalidrawElements>[0]>[number];
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
|
||||
/**
|
||||
* Hand-written or generated files often hold element skeletons
|
||||
* (`{ type: "rectangle", x, y, label: { text } }`) rather than full elements:
|
||||
* expand those, keep full elements (their ids and versions) as they are.
|
||||
*/
|
||||
function expandSkeletons(raw: readonly unknown[]): unknown[] {
|
||||
const out: unknown[] = [];
|
||||
for (const entry of raw) {
|
||||
if (!isRecord(entry) || typeof entry.type !== "string") continue;
|
||||
const skeleton = "label" in entry || typeof entry.seed !== "number";
|
||||
if (!skeleton) {
|
||||
out.push(entry);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
out.push(...convertToExcalidrawElements([entry as unknown as Skeleton], { regenerateIds: false }));
|
||||
} catch {
|
||||
out.push(entry);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseScene(text: string): { scene: Scene } | { error: string } {
|
||||
let data: unknown = {};
|
||||
if (text.trim()) {
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}
|
||||
if (Array.isArray(data)) data = { elements: data };
|
||||
if (!isRecord(data)) return { error: "An Excalidraw file is a JSON object (or an array of elements)." };
|
||||
if (typeof data.type === "string" && !data.type.startsWith("excalidraw"))
|
||||
return { error: `Not an Excalidraw file: its "type" is "${data.type}".` };
|
||||
if (data.elements !== undefined && !Array.isArray(data.elements)) return { error: '"elements" must be an array.' };
|
||||
const restored = restore(
|
||||
{
|
||||
elements: expandSkeletons((data.elements as unknown[]) ?? []) as never,
|
||||
appState: (isRecord(data.appState) ? data.appState : {}) as Partial<AppState>,
|
||||
files: (isRecord(data.files) ? data.files : {}) as BinaryFiles,
|
||||
},
|
||||
null,
|
||||
null,
|
||||
{ refreshDimensions: false, repairBindings: true },
|
||||
);
|
||||
// The page's own address (what Excalidraw writes here) is no source worth keeping.
|
||||
const own = /^(openidea-plugin:|https?:\/\/openidea-plugin\.localhost)/;
|
||||
const source =
|
||||
typeof data.source === "string" && data.source.trim() && !own.test(data.source) ? data.source : undefined;
|
||||
return { scene: { elements: restored.elements, appState: restored.appState, files: restored.files ?? {}, source } };
|
||||
}
|
||||
|
||||
/**
|
||||
* As Excalidraw saves to disk (`serializeAsJSON(…, "local")`), with a final
|
||||
* newline, keeping the file's `source`: Excalidraw writes the page's origin,
|
||||
* which here is the IDE's internal plugin URL.
|
||||
*/
|
||||
export function serializeScene(scene: Scene): string {
|
||||
const json = serializeAsJSON(scene.elements, scene.appState, scene.files, "local");
|
||||
const source = JSON.stringify(scene.source ?? SOURCE);
|
||||
// `source` is the third key, before any element: the first match is the file's own.
|
||||
return `${json.replace(/"source": "(?:[^"\\]|\\.)*"/, `"source": ${source}`)}\n`;
|
||||
}
|
||||
|
||||
/** What is saved changes iff this does: pointer moves, zoom and selection don't. */
|
||||
export function sceneSignature(scene: Scene): string {
|
||||
const elements = getNonDeletedElements(scene.elements);
|
||||
const a = scene.appState;
|
||||
const files = Object.keys(scene.files).sort().join(",");
|
||||
return [
|
||||
getSceneVersion(elements),
|
||||
elements.length,
|
||||
a.viewBackgroundColor,
|
||||
a.gridSize,
|
||||
a.gridStep,
|
||||
a.gridModeEnabled,
|
||||
files,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
export const codec: SceneCodec<Scene> = { parse: parseScene, serialize: serializeScene, signature: sceneSignature };
|
||||
|
||||
/** Shows a scene the text changed to, keeping the view (scroll, zoom) and the canvas's undo history. */
|
||||
export function applyScene(api: ExcalidrawImperativeAPI, scene: Scene) {
|
||||
const files = Object.values(scene.files);
|
||||
if (files.length) api.addFiles(files);
|
||||
const { viewBackgroundColor, gridSize, gridStep, gridModeEnabled } = scene.appState;
|
||||
const appState: Pick<AppState, "viewBackgroundColor" | "gridSize" | "gridStep" | "gridModeEnabled"> = {
|
||||
viewBackgroundColor: viewBackgroundColor ?? "#ffffff",
|
||||
gridSize: gridSize ?? 20,
|
||||
gridStep: gridStep ?? 5,
|
||||
gridModeEnabled: gridModeEnabled ?? false,
|
||||
};
|
||||
api.updateScene({ elements: scene.elements, appState, captureUpdate: CaptureUpdateAction.NEVER });
|
||||
void loadSceneFonts(api);
|
||||
}
|
||||
|
||||
/**
|
||||
* Excalidraw loads the fonts of the scene it starts with, not of scenes set
|
||||
* later, and WebKit's canvas doesn't load a font by drawing with it: load
|
||||
* them, then redraw text (new element objects drop Excalidraw's cached text
|
||||
* canvases) without recording a change.
|
||||
*/
|
||||
export async function loadSceneFonts(api: ExcalidrawImperativeAPI) {
|
||||
const fonts = new Set<string>();
|
||||
for (const el of api.getSceneElements()) {
|
||||
if (el.type === "text") fonts.add(`${el.fontSize}px ${fontStack(el.fontFamily)}`);
|
||||
}
|
||||
if (!fonts.size || !document.fonts?.load) return;
|
||||
await Promise.allSettled([...fonts].map((f) => document.fonts.load(f)));
|
||||
api.updateScene({
|
||||
elements: api.getSceneElementsIncludingDeleted().map((el) => ({ ...el })),
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
});
|
||||
}
|
||||
|
||||
/** Excalidraw's font family ids (`FONT_FAMILY`) → their CSS stack. */
|
||||
function fontStack(id: number): string {
|
||||
const names: Record<number, string> = {
|
||||
1: "Virgil",
|
||||
2: "Helvetica",
|
||||
3: "Cascadia",
|
||||
5: "Excalifont, Xiaolai",
|
||||
6: "Nunito",
|
||||
7: "Lilita One",
|
||||
8: "Comic Shanns",
|
||||
9: "Liberation Sans",
|
||||
};
|
||||
return `${names[id] ?? "Excalifont, Xiaolai"}, Segoe UI Emoji`;
|
||||
}
|
||||
|
||||
/** What exports are called: the file's name without its extension. */
|
||||
export function exportName(file: string): string {
|
||||
return file.replace(/\.excalidraw(\.json)?$/i, "") || "drawing";
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DocumentSync, type SceneCodec, type SyncPort } from "./sync";
|
||||
|
||||
/** Scenes are plain strings here: the text is the scene. */
|
||||
const codec: SceneCodec<string> = {
|
||||
parse: (text) => (text.startsWith("!") ? { error: "bad" } : { scene: text }),
|
||||
serialize: (scene) => scene,
|
||||
signature: (scene) => scene,
|
||||
};
|
||||
|
||||
function port(initial: string) {
|
||||
let version = 0;
|
||||
let text = initial;
|
||||
let listener: (s: { version: number; text: string }) => void = () => {};
|
||||
const p = {
|
||||
get: vi.fn(async () => ({ version, text, readOnly: false, name: "a.excalidraw" })),
|
||||
replace: vi.fn(async (at: number, next: string) => {
|
||||
if (at !== version) throw new Error("stale");
|
||||
text = next;
|
||||
return { version: ++version };
|
||||
}),
|
||||
onChange: (fn: typeof listener) => void (listener = fn),
|
||||
/** An edit made in the text editor. */
|
||||
external(next: string) {
|
||||
text = next;
|
||||
listener({ version: ++version, text });
|
||||
},
|
||||
text: () => text,
|
||||
} satisfies SyncPort & Record<string, unknown>;
|
||||
return p;
|
||||
}
|
||||
|
||||
const hooks = () => ({ apply: vi.fn(), error: vi.fn() });
|
||||
|
||||
describe("DocumentSync", () => {
|
||||
it("starts from the document and writes canvas changes back, once per saved change", async () => {
|
||||
const p = port("a");
|
||||
const h = hooks();
|
||||
const sync = new DocumentSync(p, codec, h, 0);
|
||||
expect(await sync.start()).toEqual({ scene: "a", error: null, readOnly: false, name: "a.excalidraw" });
|
||||
sync.changed("a");
|
||||
await sync.flush();
|
||||
expect(p.replace).not.toHaveBeenCalled();
|
||||
sync.changed("ab");
|
||||
sync.changed("abc");
|
||||
await sync.flush();
|
||||
expect(p.replace).toHaveBeenCalledTimes(1);
|
||||
expect(p.text()).toBe("abc");
|
||||
});
|
||||
|
||||
it("shows edits made elsewhere and doesn't write them back", async () => {
|
||||
const p = port("a");
|
||||
const h = hooks();
|
||||
const sync = new DocumentSync(p, codec, h, 0);
|
||||
await sync.start();
|
||||
p.external("from the text editor");
|
||||
expect(h.apply).toHaveBeenCalledWith("from the text editor");
|
||||
sync.changed("from the text editor");
|
||||
await sync.flush();
|
||||
expect(p.replace).not.toHaveBeenCalled();
|
||||
// The next canvas edit is based on the new version.
|
||||
sync.changed("canvas");
|
||||
await sync.flush();
|
||||
expect(p.text()).toBe("canvas");
|
||||
});
|
||||
|
||||
it("lets the document win a race: a stale write is dropped and the canvas takes the text", async () => {
|
||||
const p = port("a");
|
||||
const h = hooks();
|
||||
const sync = new DocumentSync(p, codec, h, 0);
|
||||
await sync.start();
|
||||
sync.changed("canvas");
|
||||
// The document moved on without the sync hearing of it yet.
|
||||
p.replace.mockImplementationOnce(async () => {
|
||||
throw new Error("stale");
|
||||
});
|
||||
await sync.flush();
|
||||
expect(p.get).toHaveBeenCalledTimes(2);
|
||||
expect(p.text()).toBe("a");
|
||||
});
|
||||
|
||||
it("never overwrites text that doesn't parse, and resumes when it does", async () => {
|
||||
const p = port("a");
|
||||
const h = hooks();
|
||||
const sync = new DocumentSync(p, codec, h, 0);
|
||||
await sync.start();
|
||||
p.external("!broken");
|
||||
expect(h.error).toHaveBeenLastCalledWith("bad");
|
||||
sync.changed("canvas");
|
||||
await sync.flush();
|
||||
expect(p.text()).toBe("!broken");
|
||||
p.external("fixed");
|
||||
expect(h.error).toHaveBeenLastCalledWith(null);
|
||||
sync.changed("canvas");
|
||||
await sync.flush();
|
||||
expect(p.text()).toBe("canvas");
|
||||
});
|
||||
|
||||
it("starts read-only and invalid documents without writing", async () => {
|
||||
const p = port("!broken");
|
||||
const sync = new DocumentSync(p, codec, hooks(), 0);
|
||||
expect(await sync.start()).toEqual({ scene: null, error: "bad", readOnly: false, name: "a.excalidraw" });
|
||||
sync.changed("x");
|
||||
await sync.flush();
|
||||
expect(p.replace).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Keeps the canvas and the file's text in step through the IDE's document
|
||||
* bridge. The text is the truth: the canvas starts from it, its edits are
|
||||
* written back (debounced, versioned) and edits made elsewhere (the text
|
||||
* editor, Undo, another window) replace what the canvas shows.
|
||||
*
|
||||
* - Canvas changes that don't change what is saved (pointer moves, zoom,
|
||||
* selection) write nothing: they're compared by `signature`.
|
||||
* - A write that lost a race (`stale`) is dropped and the canvas takes the
|
||||
* document's text.
|
||||
* - Text that doesn't parse is never overwritten: the canvas stops writing
|
||||
* until the text is valid again.
|
||||
*/
|
||||
export interface SyncPort {
|
||||
get(): Promise<{ version: number; text: string; readOnly: boolean; name?: string }>;
|
||||
replace(version: number, text: string): Promise<{ version: number }>;
|
||||
onChange(fn: (state: { version: number; text: string }) => void): void;
|
||||
}
|
||||
|
||||
export interface SceneCodec<S> {
|
||||
parse(text: string): { scene: S } | { error: string };
|
||||
serialize(scene: S): string;
|
||||
/** Equal for scenes that save the same. */
|
||||
signature(scene: S): string;
|
||||
}
|
||||
|
||||
export interface SyncHooks<S> {
|
||||
/** Show a scene the document changed to. */
|
||||
apply(scene: S): void;
|
||||
/** The document's text doesn't parse (`null`: it does again). */
|
||||
error(message: string | null): void;
|
||||
}
|
||||
|
||||
export class DocumentSync<S> {
|
||||
private version = 0;
|
||||
private text = "";
|
||||
private readOnly = false;
|
||||
private valid = true;
|
||||
private signature: string | null = null;
|
||||
private pending: S | null = null;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private writing: Promise<void> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly port: SyncPort,
|
||||
private readonly codec: SceneCodec<S>,
|
||||
private readonly hooks: SyncHooks<S>,
|
||||
private readonly delayMs = 250,
|
||||
) {}
|
||||
|
||||
/** The document's scene to start from. */
|
||||
async start(): Promise<{ scene: S | null; error: string | null; readOnly: boolean; name: string }> {
|
||||
const doc = await this.port.get();
|
||||
this.version = doc.version;
|
||||
this.text = doc.text;
|
||||
this.readOnly = doc.readOnly;
|
||||
this.port.onChange((state) => this.external(state));
|
||||
const parsed = this.codec.parse(doc.text);
|
||||
if ("error" in parsed) {
|
||||
this.valid = false;
|
||||
return { scene: null, error: parsed.error, readOnly: doc.readOnly, name: doc.name ?? "" };
|
||||
}
|
||||
this.signature = this.codec.signature(parsed.scene);
|
||||
return { scene: parsed.scene, error: null, readOnly: doc.readOnly, name: doc.name ?? "" };
|
||||
}
|
||||
|
||||
/** The canvas changed. */
|
||||
changed(scene: S) {
|
||||
if (this.readOnly || !this.valid) return;
|
||||
const signature = this.codec.signature(scene);
|
||||
if (signature === this.signature) return;
|
||||
this.signature = signature;
|
||||
this.pending = scene;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
void this.flush();
|
||||
}, this.delayMs);
|
||||
}
|
||||
|
||||
/** Writes what is pending now (also waits for a write in flight). */
|
||||
async flush(): Promise<void> {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
while (this.writing) await this.writing;
|
||||
const scene = this.pending;
|
||||
this.pending = null;
|
||||
if (scene === null) return;
|
||||
const text = this.codec.serialize(scene);
|
||||
if (text === this.text) return;
|
||||
this.writing = (async () => {
|
||||
try {
|
||||
const { version } = await this.port.replace(this.version, text);
|
||||
this.version = version;
|
||||
this.text = text;
|
||||
} catch (e) {
|
||||
if (!(e instanceof Error && e.message === "stale")) throw e;
|
||||
// Someone else changed it first: theirs wins.
|
||||
this.external(await this.port.get());
|
||||
}
|
||||
})().finally(() => {
|
||||
this.writing = null;
|
||||
});
|
||||
await this.writing;
|
||||
}
|
||||
|
||||
private external(state: { version: number; text: string }) {
|
||||
this.version = state.version;
|
||||
if (state.text === this.text) return;
|
||||
this.text = state.text;
|
||||
// The document moved on: what the canvas had pending is based on the old text.
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
this.pending = null;
|
||||
const parsed = this.codec.parse(state.text);
|
||||
if ("error" in parsed) {
|
||||
this.valid = false;
|
||||
this.hooks.error(parsed.error);
|
||||
return;
|
||||
}
|
||||
this.valid = true;
|
||||
this.signature = this.codec.signature(parsed.scene);
|
||||
this.hooks.error(null);
|
||||
this.hooks.apply(parsed.scene);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* jsdom has no canvas: Excalidraw probes a 2D context when it loads (and
|
||||
* measures text with it). A context whose methods do nothing is enough for
|
||||
* its data API.
|
||||
*/
|
||||
const context = new Proxy(
|
||||
{ filter: "none", measureText: (text: string) => ({ width: text.length * 8 }) } as Record<string | symbol, unknown>,
|
||||
{ get: (target, key) => (key in target ? target[key] : () => undefined) },
|
||||
);
|
||||
HTMLCanvasElement.prototype.getContext = (() => context) as unknown as HTMLCanvasElement["getContext"];
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Builds the page into ../web (checked in, like the official plugins'
|
||||
* dist/plugin.wasm). Relative URLs throughout: the IDE serves the page from
|
||||
* the plugin package under its own URL scheme. Excalidraw's fonts are copied
|
||||
* next to it; the page points Excalidraw at them (src/assets.ts).
|
||||
*/
|
||||
import { cpSync, existsSync, rmSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import type { Plugin } from "vite";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const outDir = path.resolve(import.meta.dirname, "../web");
|
||||
|
||||
function excalidrawFonts(): Plugin {
|
||||
return {
|
||||
name: "openidea:excalidraw-fonts",
|
||||
apply: "build",
|
||||
closeBundle() {
|
||||
const fonts = path.resolve(path.dirname(require.resolve("@excalidraw/excalidraw")), "fonts");
|
||||
if (!existsSync(fonts)) throw new Error(`no Excalidraw fonts at ${fonts}`);
|
||||
rmSync(path.join(outDir, "fonts"), { recursive: true, force: true });
|
||||
cpSync(fonts, path.join(outDir, "fonts"), { recursive: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
plugins: [react(), excalidrawFonts()],
|
||||
define: { "process.env.IS_PREACT": JSON.stringify("false") },
|
||||
build: {
|
||||
outDir,
|
||||
emptyOutDir: true,
|
||||
target: "es2022",
|
||||
sourcemap: false,
|
||||
assetsInlineLimit: 0,
|
||||
chunkSizeWarningLimit: 4096,
|
||||
},
|
||||
test: {
|
||||
// Excalidraw needs a DOM even for its data API; its packages ship JSON and CSS imports Node can't load.
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./src/test-setup.ts"],
|
||||
server: { deps: { inline: [/@excalidraw/, /open-color/] } },
|
||||
},
|
||||
});
|
||||
Reference in new issue
Заблокувати користувача