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]>
Цей коміт міститься в:
l0ng-aiтаDeng Liu committed 2026-11-04 02:41:46 +08:00
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"
+4
Переглянути файл
@@ -0,0 +1,4 @@
web-src/node_modules/
# Built by plugins/build.sh, not checked in.
web/
web.sources
+28
Переглянути файл
@@ -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.
+5
Переглянути файл
@@ -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

+5
Переглянути файл
@@ -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

+6
Переглянути файл
@@ -0,0 +1,6 @@
id = "excalidraw"
name = "Excalidraw"
extensions = ["excalidraw"]
indent = { tab-size = 2, insert-spaces = true }
ui-mode = "JSON"
tree-sitter = "json"
+35
Переглянути файл
@@ -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"
+13
Переглянути файл
@@ -0,0 +1,13 @@
{
"type": "excalidraw",
"version": 2,
"source": "https://openidea.dev",
"elements": [],
"appState": {
"gridSize": 20,
"gridStep": 5,
"gridModeEnabled": false,
"viewBackgroundColor": "#ffffff"
},
"files": {}
}
+32
Переглянути файл
@@ -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>
+25
Переглянути файл
@@ -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
+6
Переглянути файл
@@ -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;
+25
Переглянути файл
@@ -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] }];
}
+123
Переглянути файл
@@ -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>);
}
+41
Переглянути файл
@@ -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[];
}
+40
Переглянути файл
@@ -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");
});
});
+170
Переглянути файл
@@ -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";
}
+107
Переглянути файл
@@ -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();
});
});
+128
Переглянути файл
@@ -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);
}
}
+10
Переглянути файл
@@ -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"];
+17
Переглянути файл
@@ -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"]
}
+48
Переглянути файл
@@ -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/] } },
},
});