feat: add excalidraw editor + live collaboration

pull/8104/head
NGPixel 1 week ago
parent 45af5eb7fb
commit b87c236d4a
No known key found for this signature in database

@ -324,6 +324,16 @@ password field being `v-if`'d away whenever a continuation token is in hand. And
the **Welcome overlay** over the header while there is no home page, so navigate to any other path to the **Welcome overlay** over the header while there is no home page, so navigate to any other path to
get at the real one. get at the real one.
**A successful login is `authenticated: true`**, and it answers `nextAction: 'redirect'` — so a script
that reads "did this work" as the ABSENCE of a `nextAction` decides every good login was a bad one,
and then burns its retry on the seeded password against the limiter above.
**Saving from the UI goes through a dialog, twice over.** A page being created opens the **Save As**
tree browser, and a save of any kind opens the **reason-for-change** dialog unless the site's
`features.reasonForChange` is `off`. So a script that clicks Save and waits for the request sees
nothing at all, and reads as a broken save; dismiss both. The button is disabled off
`editorStore.hasPendingChanges`, which is the honest thing to assert on rather than a request.
**Tearing down** is killing your own PID — a dev instance shows up as `node backend` too, so match on **Tearing down** is killing your own PID — a dev instance shows up as `node backend` too, so match on
start time or the `CONFIG_FILE` in `/proc/<pid>/environ` rather than on the name — then start time or the `CONFIG_FILE` in `/proc/<pid>/environ` rather than on the name — then
`DROP DATABASE wikitest` and deleting `config.test.yml` and `data-test/`. Neither is gitignored: `DROP DATABASE wikitest` and deleting `config.test.yml` and `data-test/`. Neither is gitignored:
@ -634,6 +644,68 @@ Consequences worth knowing:
[Utilities and dates](#utilities-and-dates); the `lodash-es` and `luxon` still present in older [Utilities and dates](#utilities-and-dates); the `lodash-es` and `luxon` still present in older
files are on their way out. files are on their way out.
### Drawings (the Excalidraw editor)
A page whose whole body is one drawing, written with `excalidraw` and stored as the `.excalidraw`
document Excalidraw itself writes. `frontend/src/editor/excalidraw/` is the whole of it, plus
`EditorExcalidraw.vue` for the Vue side.
**This is the only React in the app, and it is quarantined.** Excalidraw is a React application and
there is no Vue port worth having, so `editor/excalidraw/index.js` owns `react`, `react-dom` and
`@excalidraw/excalidraw`, and nothing else imports any of them. `EditorExcalidraw.vue` hands it a
`<div>` and gets back a plain object of methods. There is no JSX and no JSX toolchain —
`createElement` is called directly, which one component can afford. Both are reached only through the
`defineAsyncComponent` in `pages/Index.vue`, so ~405 kB gzipped is fetched the first time somebody
opens a drawing and never on an instance that has none.
**A reader never loads any of it.** The page's `render` is an SVG the editor exported at save time,
which is the same bargain the markdown pipeline already makes — a page's HTML is produced once, by
the browser that changed it. Three things follow, and each is load-bearing:
- **The drawing is exported LIGHT, always**, and `_page-contents.scss` inverts it under `.body--dark`
with Excalidraw's own `THEME_FILTER`. Excalidraw does dark mode by inverting the finished picture
rather than by recolouring what is in it, so an export made at night has the filter baked onto its
root element — stored that way it would be a photographic negative for every reader on a light
page. One stored render, two themes to serve it in; `exportSceneSvg` and that rule have to stay in
step.
- **`skipInliningFonts` is on**, so the SVG names its fonts instead of carrying a base64 copy of each
one. The `@font-face` rules a reader needs are generated at build time from the Excalidraw package
by `excalidrawAssets` in `vite.config.js` and imported by `main.js` — about a kilobyte, fetching no
font until a glyph calls for one. It also keeps the font subsetter out of the bundle, which is a
megabyte of WebAssembly-in-JavaScript for a job nothing here needs done.
- **The fonts are served from `/_assets/excalidraw/`**, declared as `window.EXCALIDRAW_ASSET_PATH`
before Excalidraw loads (`assetPath.js`, a side-effect module imported first for exactly that).
Excalidraw appends its own CDN as a fallback even when that is set, so shipping the complete set is
the only thing that keeps a reader's browser at home — which is what `offline` is about. **Xiaolai
is deliberately not shipped**: 13 MB for Excalidraw's CJK handwriting fallback, so a drawing with
CJK text in it needs the internet. One name in `EXCALIDRAW_SKIPPED_FONTS` reverses that.
**An image inside a drawing is an asset, not base64.** Excalidraw keeps pictures in a `files` map as
data URLs; left alone that puts a screenshot in the `content` column and in every version of the page
for ever. Nothing about that field requires a `data:` — it is assigned to `img.src` and written as an
SVG `href` — so a pasted image goes through `editorStore.addPendingAsset` like a markdown one, and
the upload just before a save rewrites it. Note `addFiles` deliberately ignores an id it already
holds, so repointing an image means giving it a NEW file id; the abandoned entry is dropped by
`serializeScene`, which keeps only the files an element uses.
**Collaboration is the existing room with a third shared type** — read `core/collab.ts` and
`editor/visual/collab.js` first. Elements live in a `Y.Map` keyed by id rather than a `Y.Array`,
because z order is carried by each element's own fractional `index` and there is no array position
for two authors to fight over. The seed is built client-side under `SEED_CLIENT_ID = 2`; the server's
is 0 and ProseMirror's is 1, and a seed reusing an id the document has seen is discarded in silence.
`buildSeed` on the server skips `content` for a canvas editor (`CANVAS_EDITORS`), since a scene is
tens of kilobytes of JSON that no client would ever bind to.
**The sanitizer had to learn two things** (`models/rendering.ts`): the inert text and font attributes
an export puts on every `text` element, and the `image` element. Neither grants anything new — `href`
is scheme-checked like every other link, and `img` beside it could always fetch a picture. `data:` is
not an allowed scheme, which is the other half of why images go to the asset store. `extractText`
also puts a space after each `svg text`, because an export writes every LINE as a `text` of its own
and a two-line label came out as one unsearchable run of letters.
Ctrl+S does not save here, the same as in the Visual and Redirection editors: that shortcut is a
Monaco keybinding, not something each editor implements.
### Storage targets ### Storage targets
A storage target is one module from `modules/storage/<key>/` configured for one site. Six ship, each A storage target is one module from `modules/storage/<key>/` configured for one site. Six ship, each

@ -299,6 +299,18 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
} }
} }
}, },
excalidraw: {
type: 'object',
properties: {
isActive: {
type: 'boolean'
},
config: {
type: 'object',
additionalProperties: true
}
}
},
markdown: { markdown: {
type: 'object', type: 'object',
properties: { properties: {

@ -180,6 +180,9 @@ editors:
asciidoc: asciidoc:
contentType: adoc contentType: adoc
config: {} config: {}
excalidraw:
contentType: excalidraw
config: {}
markdown: markdown:
contentType: markdown contentType: markdown
config: config:

@ -156,6 +156,23 @@ function toBytes(data: unknown): Uint8Array {
return new Uint8Array(data as ArrayBuffer) return new Uint8Array(data as ArrayBuffer)
} }
/**
* The editors whose source is not text, and whose shared document the server therefore cannot build.
*
* A room holds the header fields for every editor, and the `content` text for the ones that edit one.
* Markdown and AsciiDoc are bound straight to that text. The Visual editor is not — a ProseMirror
* document is a tree, shared as a `Y.XmlFragment` — but its SOURCE is still the markdown in `content`,
* so seeding the text costs nothing and the two live side by side.
*
* Excalidraw is the case where seeding it is wrong rather than merely unused. Its source is a JSON
* scene, routinely tens of kilobytes of it, and putting that in a `Y.Text` nobody binds would push a
* dead copy of the whole drawing through the sync, through the relay and into every participant — a
* second representation of the page, growing with it, that no client ever reads. So the room gets the
* header fields and nothing else, and the drawing is seeded by the first browser in, the way the
* Visual editor seeds its fragment. See `editor/excalidraw/collab.js`.
*/
const CANVAS_EDITORS = new Set(['excalidraw'])
/** /**
* The state a room starts from when it has to build one itself, as a Yjs update. * The state a room starts from when it has to build one itself, as a Yjs update.
* *
@ -163,6 +180,7 @@ function toBytes(data: unknown): Uint8Array {
* the page — see the note at the top of this file on why that matters. * the page — see the note at the top of this file on why that matters.
*/ */
function buildSeed(page: { function buildSeed(page: {
editor?: string | null
content?: string | null content?: string | null
title?: string | null title?: string | null
description?: string | null description?: string | null
@ -171,7 +189,9 @@ function buildSeed(page: {
const seed = new Y.Doc() const seed = new Y.Doc()
seed.clientID = 0 seed.clientID = 0
seed.transact(() => { seed.transact(() => {
seed.getText('content').insert(0, page.content ?? '') if (!CANVAS_EDITORS.has(page.editor ?? '')) {
seed.getText('content').insert(0, page.content ?? '')
}
const props = seed.getMap('props') const props = seed.getMap('props')
props.set('title', page.title ?? '') props.set('title', page.title ?? '')
props.set('description', page.description ?? '') props.set('description', page.description ?? '')

@ -410,6 +410,8 @@
"admin.editors.channelDescription": "Create discussion channels to collaborate in real-time with your team.", "admin.editors.channelDescription": "Create discussion channels to collaborate in real-time with your team.",
"admin.editors.channelName": "Discussion Channels", "admin.editors.channelName": "Discussion Channels",
"admin.editors.configuration": "Configuration", "admin.editors.configuration": "Configuration",
"admin.editors.excalidrawDescription": "Create Excalidraw diagrams, sketches and whiteboards on a canvas.",
"admin.editors.excalidrawName": "Drawing Editor",
"admin.editors.markdown.allowHTML": "Allow HTML", "admin.editors.markdown.allowHTML": "Allow HTML",
"admin.editors.markdown.allowHTMLHint": "Allow HTML tags in content.", "admin.editors.markdown.allowHTMLHint": "Allow HTML tags in content.",
"admin.editors.markdown.general": "General", "admin.editors.markdown.general": "General",
@ -431,7 +433,7 @@
"admin.editors.markdownDescription": "Use the Markdown syntax to write content. Includes real-time preview and code completion features.", "admin.editors.markdownDescription": "Use the Markdown syntax to write content. Includes real-time preview and code completion features.",
"admin.editors.markdownName": "Markdown Editor", "admin.editors.markdownName": "Markdown Editor",
"admin.editors.redirectDescription": "Create redirections to other pages / external links.", "admin.editors.redirectDescription": "Create redirections to other pages / external links.",
"admin.editors.redirectName": "Redirection", "admin.editors.redirectName": "Redirection Editor",
"admin.editors.saveSuccess": "Editors state saved successfully.", "admin.editors.saveSuccess": "Editors state saved successfully.",
"admin.editors.subtitle": "Manage editors and their configuration", "admin.editors.subtitle": "Manage editors and their configuration",
"admin.editors.title": "Editors", "admin.editors.title": "Editors",
@ -1723,6 +1725,7 @@
"common.createPage.asciidoc": "New AsciiDoc Page", "common.createPage.asciidoc": "New AsciiDoc Page",
"common.createPage.blog": "New Blog", "common.createPage.blog": "New Blog",
"common.createPage.channel": "New Discussion Space", "common.createPage.channel": "New Discussion Space",
"common.createPage.excalidraw": "New Drawing Page",
"common.createPage.markdown": "New Markdown Page", "common.createPage.markdown": "New Markdown Page",
"common.createPage.redirect": "New Redirection", "common.createPage.redirect": "New Redirection",
"common.createPage.uploadAsset": "Upload Media Asset", "common.createPage.uploadAsset": "Upload Media Asset",
@ -2034,6 +2037,9 @@
"editor.emoji.smileysEmotion": "Smileys & Emotion", "editor.emoji.smileysEmotion": "Smileys & Emotion",
"editor.emoji.symbols": "Symbols", "editor.emoji.symbols": "Symbols",
"editor.emoji.travelPlaces": "Travel & Places", "editor.emoji.travelPlaces": "Travel & Places",
"editor.excalidraw.exportFailed": "The drawing could not be prepared for display. Your work is safe — try again in a moment.",
"editor.excalidraw.imageFailed": "That image could not be added to the drawing.",
"editor.excalidraw.loading": "Loading the drawing editor...",
"editor.localeRel.appliesOnSave": "Locale relations are stored when the page is saved.", "editor.localeRel.appliesOnSave": "Locale relations are stored when the page is saved.",
"editor.localeRel.checkFailed": "Could not check the existing relations of that page.", "editor.localeRel.checkFailed": "Could not check the existing relations of that page.",
"editor.localeRel.clear": "Remove this relation", "editor.localeRel.clear": "Remove this relation",

@ -19,6 +19,7 @@ const EDITOR_CONTENT_TYPES: Record<string, string> = {
markdown: 'markdown', markdown: 'markdown',
visual: 'markdown', visual: 'markdown',
asciidoc: 'adoc', asciidoc: 'adoc',
excalidraw: 'excalidraw',
redirect: 'redirect', redirect: 'redirect',
blog: 'blog' blog: 'blog'
} }
@ -54,6 +55,14 @@ export const PAGE_FILE_EXTENSIONS: Record<string, string> = {
markdown: 'md', markdown: 'md',
html: 'html', html: 'html',
adoc: 'adoc', adoc: 'adoc',
/*
Not `json`, though the document is one. `.excalidraw` is the extension the format already has
everywhere else -- it is what Excalidraw itself saves, and what every tool that reads a drawing
looks for -- so a tree exported to disk holds files that open in the editor they came from. It
also keeps this out of the `.json` ambiguity that `EDITOR_FOR_PAGE_EXTENSION` below has to
arbitrate, since nothing else writes it.
*/
excalidraw: 'excalidraw',
redirect: 'json', redirect: 'json',
blog: 'json' blog: 'json'
} }
@ -87,6 +96,7 @@ export function pageFileExtension(contentType: string): string {
const EDITOR_FOR_PAGE_EXTENSION: Record<string, string> = { const EDITOR_FOR_PAGE_EXTENSION: Record<string, string> = {
md: 'markdown', md: 'markdown',
adoc: 'asciidoc', adoc: 'asciidoc',
excalidraw: 'excalidraw',
json: 'redirect' json: 'redirect'
} }

@ -212,6 +212,17 @@ const BASE_ALLOWED_TAGS = [
'desc', 'desc',
'ellipse', 'ellipse',
'g', 'g',
/*
A bitmap placed inside a drawing. `href` is scheme-checked like every other link here -- it is on
sanitize-html's `allowedSchemesAppliedToAttributes` list, so `javascript:` never survives -- and
what remains is a URL that fetches a picture, which `img` beside it has always been allowed to do.
So this grants nothing `img` did not already.
`data:` is NOT among the allowed schemes, here or on `img`, so an inline bitmap is dropped rather
than stored. That is the reason the Excalidraw editor puts a pasted image in the asset store and
refers to it -- see `pendingAssets` in `EditorExcalidraw.vue`.
*/
'image',
'line', 'line',
'linearGradient', 'linearGradient',
'marker', 'marker',
@ -229,21 +240,44 @@ const BASE_ALLOWED_TAGS = [
'use' 'use'
] ]
/** Presentation attributes shared across the SVG subset above. None of them can execute. */ /**
* Presentation attributes shared across the SVG subset above. None of them can execute.
*
* The text and font half of the list is here rather than on `text` alone, where three of them used to
* be. An Excalidraw export gives every line of every label its own `text` element carrying the family,
* the size, the anchor, the writing direction and the baseline it was drawn with, and an attribute
* missing from here is dropped in silence -- which is not a mangled attribute but a mangled picture,
* in the wrong font, at the wrong size, aligned from the wrong edge. Text is also not the only element
* that inherits a font: a `g` around a label carries one just as well, which is why these are shared
* rather than spelled per tag.
*
* They are inert in the same way the shape attributes above are: each names a length, a colour, a
* keyword or a font family, and none of them is a URL or a script.
*/
const SVG_ATTRIBUTES = [ const SVG_ATTRIBUTES = [
'clip-path', 'clip-path',
'clip-rule', 'clip-rule',
'color',
'cx', 'cx',
'cy', 'cy',
'd', 'd',
'direction',
'dominant-baseline',
'fill', 'fill',
'fill-opacity', 'fill-opacity',
'fill-rule', 'fill-rule',
'font-family',
'font-size',
'font-style',
'font-variant',
'font-weight',
'height', 'height',
'href', 'href',
'letter-spacing',
'mask', 'mask',
'offset', 'offset',
'opacity', 'opacity',
'paint-order',
'points', 'points',
'preserveAspectRatio', 'preserveAspectRatio',
'r', 'r',
@ -253,13 +287,20 @@ const SVG_ATTRIBUTES = [
'stop-opacity', 'stop-opacity',
'stroke', 'stroke',
'stroke-dasharray', 'stroke-dasharray',
'stroke-dashoffset',
'stroke-linecap', 'stroke-linecap',
'stroke-linejoin', 'stroke-linejoin',
'stroke-miterlimit',
'stroke-opacity', 'stroke-opacity',
'stroke-width', 'stroke-width',
'text-anchor',
'text-decoration',
'transform', 'transform',
'vector-effect',
'viewBox', 'viewBox',
'width', 'width',
'word-spacing',
'writing-mode',
'x', 'x',
'x1', 'x1',
'x2', 'x2',
@ -310,6 +351,7 @@ const BASE_ALLOWED_ATTRIBUTES: Record<string, string[]> = {
defs: SVG_ATTRIBUTES, defs: SVG_ATTRIBUTES,
ellipse: SVG_ATTRIBUTES, ellipse: SVG_ATTRIBUTES,
g: SVG_ATTRIBUTES, g: SVG_ATTRIBUTES,
image: SVG_ATTRIBUTES,
line: SVG_ATTRIBUTES, line: SVG_ATTRIBUTES,
linearGradient: [...SVG_ATTRIBUTES, 'gradientUnits', 'gradientTransform'], linearGradient: [...SVG_ATTRIBUTES, 'gradientUnits', 'gradientTransform'],
marker: [...SVG_ATTRIBUTES, 'markerWidth', 'markerHeight', 'orient', 'refX', 'refY'], marker: [...SVG_ATTRIBUTES, 'markerWidth', 'markerHeight', 'orient', 'refX', 'refY'],
@ -322,7 +364,8 @@ const BASE_ALLOWED_ATTRIBUTES: Record<string, string[]> = {
rect: SVG_ATTRIBUTES, rect: SVG_ATTRIBUTES,
stop: SVG_ATTRIBUTES, stop: SVG_ATTRIBUTES,
symbol: SVG_ATTRIBUTES, symbol: SVG_ATTRIBUTES,
text: [...SVG_ATTRIBUTES, 'dx', 'dy', 'text-anchor', 'font-size', 'font-family'], // -> `text-anchor`, `font-size` and `font-family` were spelled here; they are in SVG_ATTRIBUTES now
text: [...SVG_ATTRIBUTES, 'dx', 'dy'],
tspan: [...SVG_ATTRIBUTES, 'dx', 'dy'], tspan: [...SVG_ATTRIBUTES, 'dx', 'dy'],
use: SVG_ATTRIBUTES use: SVG_ATTRIBUTES
} }
@ -854,10 +897,17 @@ class Rendering {
* *
* Works on a copy: scripts and styles read as text but are not prose, and a page carrying them * Works on a copy: scripts and styles read as text but are not prose, and a page carrying them
* would otherwise turn up in results for whatever its code happens to mention. * would otherwise turn up in results for whatever its code happens to mention.
*
* An SVG `text` element is given a space after it, because nothing in the markup separates one from
* the next. `text()` concatenates, the way `textContent` does, and a drawing has no prose to carry
* the gaps: an Excalidraw export writes every LINE of every label as a `text` of its own, so a
* two-line note came out of here as one unsearchable run of letters. Only inside an `svg`, and only
* for that element, so nothing about how a page's own paragraphs are indexed changes.
*/ */
private extractText($: cheerio.CheerioAPI): string { private extractText($: cheerio.CheerioAPI): string {
const $copy = cheerio.load($.html(), null, false) const $copy = cheerio.load($.html(), null, false)
$copy('script, style').remove() $copy('script, style').remove()
$copy('svg text').after(' ')
return $copy.root().text().replaceAll(/\s+/g, ' ').trim() return $copy.root().text().replaceAll(/\s+/g, ' ').trim()
} }

@ -212,6 +212,15 @@ class Sites {
isActive: true, isActive: true,
config: {} config: {}
}, },
/*
No config of its own: a drawing is drawn rather than written, so there is no syntax, no
pipeline and nothing about how it is authored to set per site. This flag is only whether
the site offers `New Drawing` at all.
*/
excalidraw: {
isActive: true,
config: {}
},
markdown: { markdown: {
isActive: true, isActive: true,
config: { config: {
@ -498,6 +507,15 @@ class Sites {
isActive: true, isActive: true,
config: {} config: {}
}, },
/*
No config of its own: a drawing is drawn rather than written, so there is no syntax, no
pipeline and nothing about how it is authored to set per site. This flag is only whether
the site offers `New Drawing` at all.
*/
excalidraw: {
isActive: true,
config: {}
},
markdown: { markdown: {
isActive: true, isActive: true,
config: { config: {

File diff suppressed because it is too large Load Diff

@ -1,10 +1,9 @@
{ {
"name": "wiki-ux", "name": "wiki-ux",
"version": "3.0.0", "version": "3.0.0",
"private": true,
"description": "The most powerful and extensible open source Wiki software", "description": "The most powerful and extensible open source Wiki software",
"productName": "Wiki.js",
"author": "Nicolas Giard <nick@requarks.io>", "author": "Nicolas Giard <nick@requarks.io>",
"private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --force", "dev": "vite --force",
@ -19,6 +18,7 @@
}, },
"dependencies": { "dependencies": {
"@asciidoctor/core": "4.0.11", "@asciidoctor/core": "4.0.11",
"@excalidraw/excalidraw": "0.18.1",
"@simplewebauthn/browser": "13.3.0", "@simplewebauthn/browser": "13.3.0",
"@tailwindcss/vite": "4.3.3", "@tailwindcss/vite": "4.3.3",
"@twemoji/api": "17.0.2", "@twemoji/api": "17.0.2",
@ -63,6 +63,8 @@
"prosemirror-tables": "1.8.5", "prosemirror-tables": "1.8.5",
"prosemirror-transform": "1.12.1", "prosemirror-transform": "1.12.1",
"prosemirror-view": "1.42.3", "prosemirror-view": "1.42.3",
"react": "19.3.0",
"react-dom": "19.3.0",
"semver": "7.8.5", "semver": "7.8.5",
"slugify": "1.6.9", "slugify": "1.6.9",
"sortablejs": "1.15.7", "sortablejs": "1.15.7",
@ -106,5 +108,6 @@
}, },
"engines": { "engines": {
"node": ">= 26.0" "node": ">= 26.0"
} },
"productName": "Wiki.js"
} }

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="80px" height="80px"><path fill="#98ccfd" d="M15.462 -1.332H24.538V41.332H15.462z" transform="rotate(-45.001 20 20)"/><path fill="#4788c7" d="M8.125,2.414l29.461,29.461l-5.711,5.711L2.414,8.125L8.125,2.414 M8.125,1L1,8.125L31.875,39 L39,31.875L8.125,1L8.125,1z"/><path fill="#dff0fe" d="M7.517 27.774L10.106 25.184 15.153 29.58 12.25 32.484 3.987 36.013z"/><path fill="#4788c7" d="M10.13,25.868l4.291,3.737l-2.457,2.457l-7.027,3.001l3.001-7.004L10.13,25.868 M10.083,24.501 l-2.988,2.988l-4.059,9.474l9.499-4.057l3.351-3.351L10.083,24.501L10.083,24.501z"/><path fill="#4788c7" d="M5.34 31.596L3.039 36.966 8.423 34.666z"/><path fill="#b6dcfe" d="M29.341,5.936l1.424-1.423c0.63-0.63,1.469-0.978,2.361-0.978c0.892,0,1.73,0.347,2.36,0.978 c1.302,1.302,1.302,3.419,0,4.721l-1.423,1.424L29.341,5.936z"/><path fill="#4788c7" d="M33.126,4.034c0.758,0,1.471,0.295,2.007,0.831c0.536,0.536,0.831,1.249,0.831,2.007 c0,0.758-0.295,1.471-0.831,2.007l-1.07,1.07l-4.014-4.014l1.07-1.07C31.655,4.33,32.368,4.034,33.126,4.034 M33.126,3.034 c-0.982,0-1.965,0.375-2.714,1.124l-1.777,1.777l5.429,5.429l1.777-1.777c1.499-1.499,1.499-3.93,0-5.429l0,0 C35.091,3.409,34.108,3.034,33.126,3.034L33.126,3.034z"/><path fill="#b6dcfe" d="M12.88,31.853c-0.066-0.153-0.157-0.308-0.281-0.453c-0.31-0.364-0.75-0.585-1.311-0.661 c-0.2-1.267-1.206-1.804-1.99-1.965c-0.132-0.863-0.649-1.342-1.201-1.582L27.332,7.945l4.722,4.722L12.88,31.853z"/><path fill="#4788c7" d="M27.332,8.652l4.014,4.014L12.966,31.06c-0.242-0.278-0.638-0.59-1.261-0.748 c-0.306-1.078-1.156-1.685-1.984-1.943c-0.151-0.546-0.447-0.968-0.821-1.272L27.332,8.652 M27.332,7.238L7.095,27.488 c0,0,0.007,0,0.021,0c0.197,0,1.715,0.054,1.715,1.731c0,0,1.993,0.062,1.993,1.99c1.982,0,1.71,1.697,1.71,1.697l20.226-20.24 L27.332,7.238L27.332,7.238z"/><path fill="#dff0fe" d="M28.554 5.892H32.980999999999995V12.568999999999999H28.554z" transform="rotate(-44.996 30.766 9.23)"/><path fill="#4788c7" d="M29.972 6.012l4.014 4.014-2.424 2.424-4.014-4.014L29.972 6.012M29.972 4.597l-3.838 3.838 5.429 5.429 3.838-3.838L29.972 4.597 29.972 4.597zM34.646 28.646l-1.501 1.501c-.194.194-.194.513 0 .707.194.194.513.194.707 0l1.501-1.501L34.646 28.646zM27.146 21.146l-3.001 3.001c-.194.194-.194.513 0 .707.194.194.513.194.707 0l3.001-3.001L27.146 21.146zM32.146 26.146l-3.001 3.001c-.194.194-.194.513 0 .707.194.194.513.194.707 0l3.001-3.001L32.146 26.146zM17.629 11.629l-1.518 1.518c-.194.194-.194.513 0 .707s.513.194.707 0l1.518-1.518L17.629 11.629zM15.164 9.164l-3.018 3.018c-.194.194-.194.513 0 .707.194.194.513.194.707 0l3.018-3.018L15.164 9.164zM12.664 6.664l-1.518 1.518c-.194.194-.194.513 0 .707.194.194.513.194.707 0l1.518-1.518L12.664 6.664zM10.164 4.164L7.146 7.183c-.194.194-.194.513 0 .707v0c.194.194.513.194.707 0l3.018-3.018L10.164 4.164zM29.646 23.646l-1.501 1.501c-.194.194-.194.513 0 .707.194.194.513.194.707 0l1.501-1.501L29.646 23.646z"/></svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

@ -0,0 +1,552 @@
<template>
<div class="editor-excalidraw">
<div ref="canvasEl" class="editor-excalidraw-canvas" />
<!--
Over the canvas while the editor is being fetched, and it is a real wait: Excalidraw and React
together are the largest thing this app loads on demand, so an empty rectangle with no
explanation is what a slow connection would otherwise show for several seconds.
-->
<div v-if="!state.ready" class="editor-excalidraw-veil">
<w-spinner size="42px" color="primary" />
<div class="mt-3 text-caption">{{ t('editor.excalidraw.loading') }}</div>
</div>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { debounce, throttle } from 'es-toolkit/function'
import { useDark } from '@/composables/dark'
import { notify } from '@/composables/notify'
import { collabHandles, startCollabSession, stopCollabSession } from '@/composables/collab'
import { useCollabStore } from '@/stores/collab'
import { useCommonStore } from '@/stores/common'
import { useEditorStore } from '@/stores/editor'
import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
import { FILES_PREFIX } from '@/helpers/assets'
/*
Ordinary static imports, though every one of these is large. This component is itself reached by
`defineAsyncComponent` (`pages/Index.vue`), so its whole import graph is already a chunk nobody
fetches until a drawing is opened -- and importing them from inside `onMounted` instead would only
turn one download into a waterfall of them.
*/
import { CaptureUpdateAction, newElementWith } from '@excalidraw/excalidraw'
import {
exportSceneSvg,
mountExcalidraw,
parseScene,
resolveLangCode,
serializeScene
} from '@/editor/excalidraw'
import {
collabElements,
collabFiles,
collabParticipants,
collabScene,
publishScene,
readScene,
seedIfEmpty
} from '@/editor/excalidraw/collab'
/**
* The Excalidraw editor: a page whose whole body is one drawing.
*
* Everything React lives behind `editor/excalidraw/index.js`, which this mounts into a `div` and then
* talks to through a handle. Read that file first — it covers what a drawing is stored as, and why an
* image inside one is an asset rather than base64.
*
* The contract with the rest of the app is the one every editor here has: the source goes into
* `pageStore.content`, the HTML a reader will be served goes into `pageStore.setRender`, and
* `editorStore.lastChangeTimestamp` is what turns the header's Save button on. Nothing about saving
* happens in this file.
*
* The render is the drawing exported as an SVG. It is produced HERE, at edit time, for the same
* reason the markdown pipeline runs here — a page's HTML is made once, by the browser that changed it,
* and the server post-processes what arrives. So a reader of a drawing downloads an SVG and none of
* this: not Excalidraw, not React, not the fonts beyond the glyphs actually lettered on it.
*/
// STORES
const collabStore = useCollabStore()
const commonStore = useCommonStore()
const editorStore = useEditorStore()
const pageStore = usePageStore()
const siteStore = useSiteStore()
const userStore = useUserStore()
// I18N
const { t } = useI18n()
// COMPOSABLES
const dark = useDark()
// DATA
const canvasEl = ref(null)
const state = reactive({
ready: false
})
/**
* The editor itself, and everything that follows it around.
*
* Outside Vue's reactivity on purpose: Excalidraw holds a scene graph and a React tree, and wrapping
* either in a proxy is both pointless and slow. The same reasoning as `composables/collab.js`.
*/
let handle = null
/** Watchers created after this component's first `await`, which Vue cannot bind to it. See below. */
const collabWatchers = []
/** Whether a session is live and this editor is bound to it. */
let collaborating = false
/** The elements as they were last published, which is what the next publish is diffed against. */
let published = new Map()
/**
* Set while a change that came from somebody else is being put on the canvas.
*
* Excalidraw calls `onChange` for that too, and without this the change would be published straight
* back to the room it arrived from.
*/
let applyingRemote = false
/**
* How many pasted images are on their way to being assets.
*
* Nothing is written to the page store while this is above zero — see `syncToStore`. An image arrives
* from Excalidraw as base64 and is turned into an upload a moment later, and a save that landed in
* between would put the whole picture in the page's source, which is the one thing this editor's
* handling of images exists to prevent.
*/
let converting = 0
/** File ids that have been through `adoptPastedImages` already, successfully or not. */
const adopting = new Set()
/** Bumped per sync, so a slow export cannot overwrite a newer one. */
let syncToken = 0
// COMPUTED
/**
* Whether this editor joins a room.
*
* The same four questions the other editors ask, and for the same reasons — see `EditorVisual.vue`.
* A page being created has no id to open a room for.
*/
const collabEnabled = computed(
() =>
siteStore.features.collaborativeEditing &&
userStore.authenticated &&
editorStore.mode === 'edit' &&
Boolean(pageStore.id)
)
// METHODS
/**
* The drawing into the page store: the source a save sends, and the SVG made from it.
*
* @param {boolean} touch Whether this counts as an EDIT. True for everything the author does, and
* false for the one write that happens before they have done anything — see `onMounted`. Opening an
* editor sets `lastChangeTimestamp` and `lastSaveTimestamp` to the same instant, which is how "no
* unsaved changes" is spelled, so a mount-time write that touched the first of them would offer to
* save a drawing nobody had drawn on and ask about discarding it on the way out.
*/
async function writeToStore({ touch }) {
if (!handle || converting > 0) {
return
}
const token = ++syncToken
const scene = handle.getScene()
const content = serializeScene(scene)
let render = ''
try {
render = await exportSceneSvg(scene)
} catch (err) {
/*
The source is still good, so the page is still saveable -- it is the picture a reader would be
served that is missing. Reported rather than swallowed, because the alternative is a page that
saves and then displays nothing, with nothing anywhere to say why.
*/
console.error('Failed to export the drawing as SVG', err)
notify({ type: 'negative', message: t('editor.excalidraw.exportFailed') })
return
}
// -> A newer change finished while this export was running; its answer is the current one
if (token !== syncToken) {
return
}
pageStore.$patch({
content,
// -> What the author has drawn IS the source, whatever the load did or did not deliver; see the
// guard in `pageSave`
contentLoaded: true
})
pageStore.setRender(render)
if (touch) {
editorStore.lastChangeTimestamp = Temporal.Now.instant()
}
}
/**
* The same, on the author's clock.
*
* Debounced, because Excalidraw reports every change as it happens — a line is a change per frame
* while it is being drawn — and both halves of `writeToStore` are whole-document work.
*
* It is also the only thing that sets `lastChangeTimestamp`, which is what makes the Save button
* possible. That ordering is the guarantee: the button cannot be pressed before this has run, so a
* save always sends a source and a render that go together. It is also why the pending-image guard is
* a reason to do nothing rather than to do half of it.
*/
const syncToStore = debounce(() => writeToStore({ touch: true }), 600)
/**
* One change from the canvas.
*
* Three separate jobs, on three different clocks: the room hears about it almost at once, the page
* store a moment later, and a pasted image is dealt with as soon as it is noticed.
*/
function onChange() {
if (applyingRemote) {
return
}
adoptPastedImages()
publish()
syncToStore()
}
/**
* This author's changes into the shared document.
*
* Throttled rather than debounced: the others should see a line being drawn as it is drawn, so this
* has to fire DURING a burst of changes and not after it. Trailing, so the last frame of a stroke is
* never the one that gets dropped.
*/
const publish = throttle(() => {
if (!collaborating || !handle) {
return
}
const handles = collabHandles()
if (handles) {
published = publishScene(handles.ydoc, handle.getScene(), published)
}
}, 100)
/** Where this author's pointer is, for everybody else's canvas. Throttled for the same reason. */
const publishPointer = throttle((payload) => {
const handles = collabHandles()
if (!collaborating || !handles) {
return
}
handles.awareness.setLocalStateField('pointer', payload?.pointer ?? null)
handles.awareness.setLocalStateField('button', payload?.button ?? 'up')
}, 80)
/**
* Turn an image Excalidraw has just taken in into one of the wiki's assets.
*
* Excalidraw stores a pasted or dropped picture as base64 on a `files` entry, which is what would end
* up in the page's source — a megabyte screenshot in a text column, in every version of the page for
* ever, and again in the SVG every reader is served. So it is registered as a pending asset the way
* the markdown editor registers a pasted one, and the file entry is repointed at the blob URL that
* comes back; the upload that runs just before a save turns that into a path.
*
* The entry is repointed by giving the image a NEW file id rather than by correcting the old one:
* `addFiles` deliberately leaves an id it already holds alone, so the only way to change what an image
* element refers to is to make it refer to something else. The abandoned entry is not cleaned up and
* does not need to be — `serializeScene` keeps only the files an element actually uses.
*/
function adoptPastedImages() {
for (const [id, file] of Object.entries(handle.getScene().files)) {
/*
`adopting` is what stops the same picture being uploaded several times over. This runs on every
change, and the base64 entry is still there for the whole of the round trip below -- so without
it, moving the image that was just dropped would start a second upload of it, and dragging it
across the canvas would start a dozen.
*/
if (!String(file?.dataURL ?? '').startsWith('data:') || adopting.has(id)) {
continue
}
adopting.add(id)
converting++
replaceWithAsset(id, file)
.catch((err) => {
// -> Left in `adopting`, so a failure is not retried on every mouse move for the rest of the
// session. The picture stays on the canvas as base64 and the save is what refuses it
console.error('Failed to take in an image dropped on the drawing', err)
notify({ type: 'negative', message: t('editor.excalidraw.imageFailed') })
})
.finally(() => {
converting--
// -> The store was held back while this ran, so it is asked again now that it may proceed
syncToStore()
})
}
}
/** Swap one base64 file entry for an upload, and point the image elements at the new one. */
async function replaceWithAsset(fileId, file) {
const blob = await (await fetch(file.dataURL)).blob()
const nextId = repointFile(fileId, { ...file, dataURL: editorStore.addPendingAsset(blob) })
adopting.add(nextId)
}
/**
* Give a file a new id carrying `next`, and move every element that used the old one onto it.
*
* A new id rather than a corrected entry because `addFiles` deliberately ignores an id it already
* holds, so the only way to change what an image refers to is to make it refer to something else. The
* entry left behind is not cleaned up and does not need to be: `serializeScene` keeps only the files
* an element actually uses.
*
* @returns {string} The new file id.
*/
function repointFile(fileId, next) {
const nextId = `${fileId}~${Date.now().toString(36)}`
handle.addFiles([{ ...next, id: nextId }])
handle.updateScene({
elements: handle
.getScene()
.elements.map((el) => (el.fileId === fileId ? newElementWith(el, { fileId: nextId }) : el))
})
return nextId
}
/**
* Rewrite the blob URLs of pending assets, once the upload has given them real paths.
*
* Runs from `UploadPendingAssetsDialog`, immediately before the page is saved, and everything it does
* has to be finished by the time it returns — the same requirement the markdown editor's copy of this
* documents, and the reason both of them work in place rather than waiting for a timer.
*
* The page's SOURCE is not touched here: the dialog has already rewritten it, which it can do because
* a stored path is exactly the string it put in. The render cannot be left to the dialog in the same
* way — the SVG refers to an image by the URL a browser fetches it from, not by the path a page stores
* — so it is rewritten here, as a string, which is what makes this synchronous. Re-exporting the SVG
* would be the obvious alternative and is the wrong one: it is asynchronous, and the save that follows
* would not wait for it.
*/
function reloadEditorContent({ replacements = [] } = {}) {
if (replacements.length === 0 || !handle) {
return
}
const urls = new Map(
replacements.map(({ from, to }) => [from, `${FILES_PREFIX}${String(to).replace(/^\//, '')}`])
)
// -> The canvas keeps drawing from the uploaded file rather than from a blob the dialog is about
// to revoke out from under it
for (const [id, file] of Object.entries(handle.getScene().files)) {
const url = urls.get(file?.dataURL)
if (url) {
adopting.add(repointFile(id, { ...file, dataURL: url }))
}
}
let render = pageStore.render
for (const [from, to] of urls) {
render = render.replaceAll(from, to)
}
pageStore.setRender(render)
}
/** Take the room's drawing onto this canvas. */
function applyRemote() {
const handles = collabHandles()
if (!handles || !handle) {
return
}
const { elements, files, appState } = readScene(handles.ydoc)
applyingRemote = true
try {
handle.addFiles(Object.values(files))
/*
`NEVER` keeps a change that arrived from somebody else off this author's undo stack, which would
otherwise offer to undo a shape they did not draw -- the same reason the Visual editor takes its
undo from Yjs rather than from ProseMirror.
*/
handle.updateScene({ elements, appState, captureUpdate: CaptureUpdateAction.NEVER })
published = new Map(elements.map((el) => [el.id, el]))
} finally {
applyingRemote = false
}
// -> The store follows what is now on the canvas, which is not what this browser loaded
syncToStore()
}
/** Everyone else's cursors. Excalidraw draws them itself, given the map. */
function refreshCollaborators() {
const handles = collabHandles()
if (handles && handle) {
handle.updateScene({ collaborators: collabParticipants(handles.awareness) })
}
}
/**
* Join the room, once the editor exists to be bound to it.
*
* The drawing the room holds REPLACES the one this browser loaded, including anything unsaved that
* somebody else has done to it, which is why `seedIfEmpty` only fills a room that is genuinely empty.
*/
function enableCollab() {
const handles = collabHandles()
if (!handles || !handle) {
return
}
seedIfEmpty(handles.ydoc, handle.getScene())
collaborating = true
const onDocChange = (events, transaction) => {
// -> Not this browser's own writes coming back round
if (!transaction.local) {
applyRemote()
}
}
collabElements(handles.ydoc).observeDeep(onDocChange)
collabFiles(handles.ydoc).observeDeep(onDocChange)
collabScene(handles.ydoc).observeDeep(onDocChange)
handles.awareness.on('change', refreshCollaborators)
applyRemote()
refreshCollaborators()
}
// LIFECYCLE
onMounted(async () => {
editorStore.$patch({ hideSideNav: true })
handle = await mountExcalidraw(canvasEl.value, {
initialScene: parseScene(pageStore.content),
theme: dark.isActive ? 'dark' : 'light',
langCode: resolveLangCode(commonStore.locale),
onChange,
onPointerUpdate: publishPointer
})
state.ready = true
EVENT_BUS.on('reloadEditorContent', reloadEditorContent)
/*
Live collaboration. Unlike the text editors there is nothing to lock while the room answers: a
drawing is not a document somebody is typing into the middle of, and a shape drawn a moment before
the room arrives is merged rather than overwritten -- it is one more element in a map of them.
*/
if (collabEnabled.value) {
startCollabSession({ siteId: siteStore.id, pageId: pageStore.id })
/*
Stopped by hand on the way out. This hook is `async`, so everything after its first `await` runs
with no component instance current and Vue has nothing to bind these to -- see the same note in
`EditorVisual.vue`, where leaving them running reached into the next session's editor.
*/
collabWatchers.push(
watch(
() => collabStore.status,
(status) => {
if (status === 'connected') {
enableCollab()
}
if (status === 'denied') {
notify({ type: 'warning', message: t('editor.collab.notAllowed') })
}
}
),
watch(
() => collabStore.lastSave,
(lastSave) => {
if (lastSave && lastSave.authorId !== userStore.id) {
notify({
type: 'positive',
message: t('editor.collab.savedBy', { name: lastSave.authorName })
})
}
}
)
)
}
/*
The store gets the source and the render before anything is drawn. A page opened and saved without
an edit must not go up with whatever was in the store before it, and a page being created has
neither until this runs. Not an edit, so it does not touch the change clock -- see `writeToStore`.
*/
await writeToStore({ touch: false })
})
/*
The canvas follows the reader's own theme. A drawing is stored once and drawn in both, so this is
the editor matching the app around it rather than anything about the page.
*/
watch(
() => dark.isActive,
(isDark) => handle?.setTheme(isDark ? 'dark' : 'light')
)
onBeforeUnmount(() => {
// -> Anything still pending is dropped: the editor is going, and the store must not be written to
// after the page has moved on
syncToStore.cancel()
publish.cancel()
publishPointer.cancel()
// -> First, because everything below is what they reach for
for (const stop of collabWatchers.splice(0)) {
stop()
}
// -> Before the editor goes: leaving the room is what takes this author's cursor off everyone
// else's canvas
stopCollabSession()
collaborating = false
EVENT_BUS.off('reloadEditorContent', reloadEditorContent)
handle?.destroy()
handle = null
})
</script>
<style lang="scss">
.editor-excalidraw {
position: relative;
display: flex;
height: 100%;
min-height: 0;
flex: 1 1 auto;
&-canvas {
flex: 1 1 auto;
min-height: 0;
}
/* -> Over the canvas rather than instead of it: Excalidraw is mounting behind this the whole time */
&-veil {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 1rem;
@at-root .body--light & {
background-color: #fff;
}
@at-root .body--dark & {
background-color: $dark-5;
}
}
}
</style>

@ -1,6 +1,5 @@
import { watch } from 'vue' import { watch } from 'vue'
import { MonacoBinding } from 'y-monaco'
import { WebsocketProvider } from 'y-websocket' import { WebsocketProvider } from 'y-websocket'
import * as Y from 'yjs' import * as Y from 'yjs'
@ -59,6 +58,10 @@ const USER_COLORS = [
let doc = null let doc = null
let provider = null let provider = null
/**
* The Monaco binding, once there is one. `pending` while it is being fetched, which is what keeps two
* calls arriving in the same tick from each making one — see `bindCollabEditor`.
*/
let binding = null let binding = null
let styleEl = null let styleEl = null
let syncTimer = null let syncTimer = null
@ -283,7 +286,7 @@ export function collabHandles() {
* Called once the document has synced, and not before: the binding starts by making the model say * Called once the document has synced, and not before: the binding starts by making the model say
* what the document says, and a document that has not synced yet says nothing at all. * what the document says, and a document that has not synced yet says nothing at all.
*/ */
export function bindCollabEditor(editor) { export async function bindCollabEditor(editor) {
if (!doc || binding) { if (!doc || binding) {
return return
} }
@ -291,6 +294,24 @@ export function bindCollabEditor(editor) {
if (!model) { if (!model) {
return return
} }
/*
Fetched here rather than imported at the top of this file, and the reason is nothing to do with
this function: `y-monaco` pulls in Monaco, and a static import would put the whole code editor
into the chunk of EVERY editor that collaborates. The Visual editor was downloading it to draw a
ProseMirror document, and the Excalidraw one would download it to draw a picture — some 650 kB
gzipped apiece, for a module neither of them has any use for.
Nothing is paid for it here: the two editors that call this are the two built ON Monaco, so by the
time they do, the module is loaded and this resolves from cache.
*/
binding = 'pending'
const { MonacoBinding } = await import('y-monaco')
// -> The session may have been closed while that was in the air, and `stopCollabSession` cannot
// destroy a binding that did not exist yet
if (binding !== 'pending' || !doc) {
binding = null
return
}
binding = new MonacoBinding(doc.getText('content'), model, new Set([editor]), provider.awareness) binding = new MonacoBinding(doc.getText('content'), model, new Set([editor]), provider.awareness)
} }
@ -307,7 +328,9 @@ export function stopCollabSession() {
} }
stopWatchers = [] stopWatchers = []
if (binding) { if (binding) {
binding.destroy() // -> A string here means the import above is still in flight; clearing it is what tells that call
// the session it was binding is gone
binding.destroy?.()
binding = null binding = null
} }
if (provider) { if (provider) {

@ -1869,6 +1869,35 @@
vertical-align: middle; vertical-align: middle;
} }
/*
A drawing, which is a whole page rather than a picture in one -- the Excalidraw editor exports it
at save time and it is the entire render (`editor/excalidraw/index.js`).
The export carries no `width` or `height`, deliberately: Excalidraw writes the drawing's own
dimensions onto the element, which in an article is a picture that refuses to shrink on a narrow
screen. The `viewBox` it also writes is what carries the shape, so giving the width to CSS scales
the drawing rather than cropping it. Centred, because a drawing that is narrower than the column
reads as a figure and not as a paragraph that stops early.
*/
svg.excalidraw-render {
display: block;
width: 100%;
max-width: 100%;
height: auto;
margin-inline: auto;
/*
Dark mode, the way Excalidraw itself does it: the picture is inverted rather than recoloured,
because a drawing is strokes and fills an author chose and there is no way to know which of them
meant "ink" and which meant "a red box". This is `THEME_FILTER` from Excalidraw, and it is here
rather than in the export because there is ONE stored render and two themes to show it in -- see
`exportSceneSvg`, which is deliberately always light and must stay that way for this to be right.
*/
@at-root .body--dark & {
filter: invert(93%) hue-rotate(180deg);
}
}
/* /*
An image in a list item stays in the sentence. Tailwind's preflight declares `img { display: An image in a list item stays in the sentence. Tailwind's preflight declares `img { display:
block }`, so without this the image takes a line of its own and the item's own words go under it, block }`, so without this the image takes a line of its own and the item's own words go under it,

@ -0,0 +1,14 @@
/**
* Where Excalidraw fetches its own assets from, declared before Excalidraw itself is loaded.
*
* A side-effect module with one statement in it, rather than a line at the top of `index.js`, because
* a static import is evaluated before the body of the module that writes it. ES modules evaluate in
* the order they are imported, so naming this one first is what makes it run first.
*
* Without it Excalidraw fetches every font it draws with from a CDN. Note it appends that CDN as a
* fallback even WITH this set, so this only moves the first attempt — what keeps a reader's browser
* from reaching the internet is the build shipping the complete set of fonts, which is
* `excalidrawAssets` in `vite.config.js`. The two have to name the same path, and this is one half of
* it (`EXCALIDRAW_ROUTE` is the other).
*/
window.EXCALIDRAW_ASSET_PATH = '/_assets/excalidraw/'

@ -0,0 +1,228 @@
import * as Y from 'yjs'
import { toLiveUrl, toStoredPath } from './index.js'
/**
* Collaborative editing for the Excalidraw editor.
*
* Read `core/collab.ts` on the server and `editor/visual/collab.js` beside this first — between them
* they set out what a room is, why a seed has to be deterministic, and why two editors can share one
* room through different shared types. This is the third such type and the second one the server
* cannot build.
*
* ## The shape it is shared in
*
* A `Y.Map` of elements keyed by their id, not a `Y.Array` of them. A drawing is a SET of shapes whose
* order is carried by each shape's own fractional `index` — that is how Excalidraw itself stores z
* order — so there is no array position for two authors to disagree about. Dragging a shape forward is
* a change to that one shape, and two people reordering different shapes at once merge instead of
* fighting over the same slot, which is exactly the case a `Y.Array` handles worst.
*
* Each element is written as ONE value rather than a nested `Y.Map` of its fields. Two people dragging
* the same shape should not produce a shape with one person's x and the other's y; last writer wins,
* per shape, is both the honest answer and the one Excalidraw already gives itself. Elements are also
* small and rewritten wholesale by the editor on every change, so there is nothing finer to preserve.
*
* A delete is not a removal. Excalidraw tombstones what it deletes (`isDeleted`), which is what lets a
* delete travel and be undone; the tombstone is dropped when the page is SAVED, by `serializeScene`.
*
* ## Awareness
*
* Excalidraw draws other people's cursors itself, given a `collaborators` map — so unlike the two text
* editors there is no cursor styling to inject, and `collabParticipants` below is only a translation
* between the wiki's awareness states and the shape Excalidraw wants.
*/
/** The shared types this editor uses, named so the room can also hold the other editors'. */
const ELEMENTS = 'excalidrawElements'
const FILES = 'excalidrawFiles'
const SCENE = 'excalidrawScene'
/**
* The document-level parts of `appState`, which are the only parts that are shared.
*
* Everything else in there is one person's view of the drawing — where they have scrolled, how far
* they have zoomed, what they have selected, which tool they are holding — and sharing any of it would
* mean dragging everyone's canvas about as one person works.
*/
const SHARED_APP_STATE = ['viewBackgroundColor', 'gridSize']
/**
* The client id this seed is written under.
*
* Pinned so that two browsers opening the same empty room in the same instant produce byte-identical
* operations and the room ends up with one drawing rather than two — the reasoning is
* `editor/visual/collab.js`'s, in full.
*
* And a THIRD distinct value, for the reason that file gives for needing a second: an update is
* identified by its client id and a clock counting from there, so a seed reusing an id the document
* has already seen is discarded in silence. The server's is 0, the Visual editor's is 1. A page has
* one editor so these can never meet in one room, but they are one constant apiece and getting it
* wrong looks like a page that opens blank and then saves over itself.
*/
const SEED_CLIENT_ID = 2
export function collabElements(ydoc) {
return ydoc.getMap(ELEMENTS)
}
export function collabFiles(ydoc) {
return ydoc.getMap(FILES)
}
export function collabScene(ydoc) {
return ydoc.getMap(SCENE)
}
/**
* Fill the room from this browser's copy of the drawing, if nobody has filled it yet.
*
* Asked once the document has synced, so "empty" means the room genuinely holds nothing rather than
* that this browser has not heard about it yet. A room somebody is already in is left alone: what it
* holds is the drawing as it stands, unsaved changes and all, and it outranks what this browser loaded
* from the API.
*
* @returns {boolean} Whether this call was the one that seeded it.
*/
export function seedIfEmpty(ydoc, scene) {
if (collabElements(ydoc).size > 0 || collabScene(ydoc).size > 0) {
return false
}
const scratch = new Y.Doc()
scratch.clientID = SEED_CLIENT_ID
scratch.transact(() => {
const elements = scratch.getMap(ELEMENTS)
for (const element of scene.elements ?? []) {
elements.set(element.id, element)
}
const files = scratch.getMap(FILES)
for (const [id, file] of Object.entries(scene.files ?? {})) {
files.set(id, { ...file, dataURL: toStoredPath(file?.dataURL) })
}
const state = scratch.getMap(SCENE)
for (const key of SHARED_APP_STATE) {
state.set(key, scene.appState?.[key] ?? null)
}
})
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(scratch))
scratch.destroy()
return true
}
/**
* Write what this author has just done into the shared document.
*
* Diffed against the last state this function was given rather than sent wholesale: Excalidraw calls
* `onChange` for everything, a pointer moving included, and re-setting two hundred unchanged elements
* on every frame would put all of them through the sync and the postgres relay. `version` is
* Excalidraw's own counter and is bumped on every change to an element, so comparing it is both
* cheaper and more accurate than comparing the objects.
*
* One transaction, so the whole of a change reaches the others as one update.
*
* @param {object} previous The elements by id as they were last time, which this returns the next of.
* @returns {Map<string, object>} What to pass as `previous` next time.
*/
export function publishScene(ydoc, scene, previous) {
const next = new Map()
for (const element of scene.elements ?? []) {
next.set(element.id, element)
}
const yElements = collabElements(ydoc)
const yFiles = collabFiles(ydoc)
const yScene = collabScene(ydoc)
ydoc.transact(() => {
for (const [id, element] of next) {
const before = previous.get(id)
if (!before || before.version !== element.version) {
yElements.set(id, element)
}
}
/*
An element this author no longer has is REMOVED from the map, which is not the same thing as
deleting a shape -- a delete arrives as a tombstone above, and is a change like any other. This
is the case where an element left the scene without one, which is what an undo of its creation
does, and leaving it in the map would put it back on everybody else's canvas.
*/
for (const id of previous.keys()) {
if (!next.has(id)) {
yElements.delete(id)
}
}
for (const [id, file] of Object.entries(scene.files ?? {})) {
if (!yFiles.has(id)) {
yFiles.set(id, { ...file, dataURL: toStoredPath(file?.dataURL) })
}
}
for (const key of SHARED_APP_STATE) {
const value = scene.appState?.[key] ?? null
if (yScene.get(key) !== value) {
yScene.set(key, value)
}
}
})
return next
}
/**
* The drawing as the shared document has it.
*
* Sorted by each element's fractional index, which is what z order means here. An element with none is
* one Excalidraw has not indexed yet; those keep the order the map gives them and are put at the back,
* where the editor assigns them an index of their own on the next change.
*/
export function readScene(ydoc) {
const elements = [...collabElements(ydoc).values()].sort((a, b) => {
if (a.index == null || b.index == null) {
return a.index == null ? (b.index == null ? 0 : 1) : -1
}
return a.index < b.index ? -1 : a.index > b.index ? 1 : 0
})
const files = Object.fromEntries(
[...collabFiles(ydoc).entries()].map(([id, file]) => [
id,
{ ...file, dataURL: toLiveUrl(file?.dataURL) }
])
)
const appState = {}
for (const key of SHARED_APP_STATE) {
const value = collabScene(ydoc).get(key)
if (value !== undefined) {
appState[key] = value
}
}
return { elements, files, appState }
}
/**
* Everyone else in the room, in the shape Excalidraw draws cursors from.
*
* Keyed by the awareness client id turned into a string, which is what Excalidraw calls a socket id
* and only ever uses as a key. This author is left out: the editor knows where its own pointer is.
*
* @param {object} awareness The provider's, which carries the same `user` field the other editors set
* — see `composables/collab.js`, which is where the colour and the name come from.
*/
export function collabParticipants(awareness) {
const collaborators = new Map()
for (const [clientId, state] of awareness.getStates()) {
if (clientId === awareness.clientID || !state?.user) {
continue
}
collaborators.set(String(clientId), {
id: state.user.id,
username: state.user.name,
// -> The same picture the presence bar draws, so the face on the cursor and the face in the
// header are the same person. Absent where there is none: Excalidraw draws initials then,
// where an `<img>` at a URL answering 404 would draw a broken one
...(state.user.hasAvatar && { avatarUrl: `/_user/${state.user.id}/avatar` }),
color: { background: state.user.color, stroke: state.user.color },
...(state.pointer && { pointer: { ...state.pointer, tool: 'pointer' } }),
...(state.button && { button: state.button })
})
}
return collaborators
}

@ -0,0 +1,273 @@
// -> First, and deliberately: it has to run before Excalidraw does. See the file itself.
import './assetPath.js'
import { createElement } from 'react'
import { createRoot } from 'react-dom/client'
import { Excalidraw, exportToSvg, languages, serializeAsJSON } from '@excalidraw/excalidraw'
import '@excalidraw/excalidraw/index.css'
import { FILES_PREFIX } from '@/helpers/assets'
/**
* The Excalidraw drawing surface, as one function that puts it on the page and hands back a handle.
*
* Excalidraw is a React application and this wiki is a Vue one, so the whole of React lives behind
* this module and nothing outside it imports either. `EditorExcalidraw.vue` owns a `<div>` and calls
* `mountExcalidraw` on it; what comes back is a plain object of methods. There is no JSX anywhere —
* `createElement` is what one component needs and it costs no build step to call it directly.
*
* Nothing here is in the app's bundle. The Vue component that reaches this is loaded by
* `defineAsyncComponent`, so React, Excalidraw and their stylesheet are fetched the first time
* somebody opens a drawing, and never at all on a wiki that has none. A READER of a drawing does not
* come through here either: what a page stores is the SVG exported at save time, which is ordinary
* markup that needs none of this to display.
*
* ## What a drawing is stored as
*
* `.excalidraw`, the format Excalidraw itself writes — `serializeAsJSON`, unchanged — so a page's
* source opens in any Excalidraw and a file exported from one opens here. See `PAGE_FILE_EXTENSIONS`
* on the server for the extension that follows from it.
*
* ## What happens to an image inside a drawing
*
* Excalidraw keeps images in a `files` map beside the elements, each holding a `dataURL` it hands
* straight to an `Image`. Left alone that is a base64 copy of every picture inside the page's own
* source, which would put a megabyte screenshot in the `content` column and in every version of the
* page for ever after.
*
* So it holds a URL instead, which works because nothing about that field requires a `data:` — the
* editor assigns it to `img.src` and the SVG export writes it as an `href`. A picture is uploaded to
* the wiki's asset store like any other, and the two sides of that are the two functions at the bottom
* of this file: `toStoredPath` writes the site-relative path the rest of the wiki stores (see
* `helpers/assets.js` for why a path and not a URL), and `toLiveUrl` turns it back into something a
* browser can fetch. An image the author has just pasted is a `blob:` URL under both, until the upload
* that runs just before a save replaces it.
*/
/** The padding around a drawing in the exported SVG, in Excalidraw's own units. */
const EXPORT_PADDING = 8
/**
* The scene an empty drawing starts from.
*
* `null` rather than a number for `gridSize`: that is how Excalidraw spells "no grid", and a zero
* there draws a grid with no spacing.
*/
const EMPTY_SCENE = {
elements: [],
appState: { viewBackgroundColor: '#ffffff', gridSize: null },
files: {}
}
/**
* Excalidraw's UI language for one of this wiki's locale codes.
*
* Exact match first, then the language without its region — the wiki says `fr` where Excalidraw says
* `fr-FR`, and meeting halfway is better than English for both. English for anything else, which is
* also what Excalidraw falls back to on a code it does not know.
*/
export function resolveLangCode(locale) {
const code = String(locale || '').trim()
if (!code) {
return 'en'
}
const codes = languages.map((lang) => lang.code)
if (codes.includes(code)) {
return code
}
const base = code.split('-')[0].toLowerCase()
return codes.find((c) => c.toLowerCase().split('-')[0] === base) ?? 'en'
}
/**
* A stored drawing, as the three things Excalidraw is given.
*
* Anything that will not parse comes back as an empty scene rather than throwing. A page whose source
* is damaged should open on a blank canvas the author can draw on, not an editor that refuses to
* appear — and the source is still in the page's history either way.
*/
export function parseScene(content) {
if (!content || !content.trim()) {
return structuredClone(EMPTY_SCENE)
}
let parsed = null
try {
parsed = JSON.parse(content)
} catch {
return structuredClone(EMPTY_SCENE)
}
return {
elements: Array.isArray(parsed?.elements) ? parsed.elements : [],
appState: { ...EMPTY_SCENE.appState, ...parsed?.appState },
files: filesWith(parsed?.files ?? {}, toLiveUrl)
}
}
/**
* The source a save sends, in Excalidraw's own format.
*
* `serializeAsJSON` is what drops the elements a delete left behind and the parts of `appState` that
* belong to whoever was looking at it — the scroll position, the zoom, what was selected — so what is
* stored is the drawing rather than one author's view of it.
*/
export function serializeScene({ elements, appState, files }) {
return serializeAsJSON(elements, appState, filesWith(files ?? {}, toStoredPath), 'local')
}
/**
* A drawing as an SVG, which is what the page stores as its render and every reader is served.
*
* `skipInliningFonts` matters twice over. It keeps a base64 copy of a subsetted font out of every
* drawing on the wiki — the same font, again, in every page that uses it — and it is what keeps the
* fonts the app already ships as the ones a drawing is lettered in. It also avoids loading the font
* subsetter, which is a megabyte of WebAssembly-in-JavaScript that nothing else here has any use for.
*
* The empty `<style>` Excalidraw leaves behind when it inlines nothing goes too: the sanitizer would
* strip it from any author without `write:styles` anyway, and a page's stored HTML should not carry an
* element that says nothing.
*
* **The drawing is exported LIGHT, whatever theme it was drawn in.** Excalidraw does dark mode by
* inverting the finished picture rather than by recolouring what is in it, and on an export that means
* a `filter` baked onto the root element. Stored that way, a drawing made at night would be inverted
* for every reader for ever — right on a dark page, and a photographic negative on a light one. There
* is one stored render and two themes to serve it in, so it is stored as the artwork and the page
* applies the same inversion in CSS when the reader is in the dark: `svg.excalidraw-render` in
* `_page-contents.scss` carries Excalidraw's own filter, and the two have to stay in step.
*/
export async function exportSceneSvg({ elements, appState, files }) {
const svg = await exportToSvg({
elements: (elements ?? []).filter((el) => !el.isDeleted),
appState: { ...appState, exportBackground: false, exportWithDarkMode: false, theme: 'light' },
files: files ?? {},
exportPadding: EXPORT_PADDING,
skipInliningFonts: true
})
for (const style of svg.querySelectorAll('style')) {
if (!style.textContent.trim()) {
style.remove()
}
}
/*
Sized by the drawing and laid out by the page. Excalidraw writes the drawing's own dimensions onto
the element, which in an article is a picture that refuses to shrink on a narrow screen; the
viewBox it also writes is what actually carries the shape, so handing the width over to CSS scales
it instead of cropping it. Kept in `.page-contents`, with the rest of the content typography.
*/
svg.removeAttribute('width')
svg.removeAttribute('height')
svg.setAttribute('class', 'excalidraw-render')
return svg.outerHTML
}
/**
* Put the editor on the page.
*
* @param {HTMLElement} container An element with a size — Excalidraw fills it and measures itself
* against it, so a box that collapses to nothing draws a canvas of nothing.
* @param {object} options
* @param {object} options.initialScene From `parseScene`.
* @param {string} options.theme `light` or `dark`.
* @param {string} options.langCode From `resolveLangCode`.
* @param {Function} options.onChange `(elements, appState, files)`, on every change including ones
* this wiki made itself — see the guard in `EditorExcalidraw.vue`.
* @param {Function} [options.onPointerUpdate] `({ pointer, button })`, for a collaborative session's
* cursors. Called on every mouse move, so whatever it does has to be cheap.
* @returns {object} The handle: `api` is Excalidraw's own, and the rest is what this wiki needs of it.
*/
export function mountExcalidraw(
container,
{ initialScene, theme, langCode, onChange, onPointerUpdate }
) {
const root = createRoot(container)
let api = null
return new Promise((resolve) => {
root.render(
createElement(Excalidraw, {
initialData: {
...initialScene,
// -> The canvas fits the drawing when it opens, which for a page somebody is coming back
// to is the whole of it rather than wherever the last author happened to be looking
scrollToContent: true
},
theme,
langCode,
onChange,
onPointerUpdate,
excalidrawAPI: (instance) => {
api = instance
resolve(handle)
},
UIOptions: {
canvasActions: {
// -> The page is saved by the wiki's own header, and the rest of this menu offers ways to
// put a drawing somewhere that is not this page
saveToActiveFile: false,
loadScene: false,
export: false,
saveAsImage: true,
toggleTheme: false
}
}
})
)
const handle = {
get api() {
return api
},
/** The scene as it stands, which is what a save and an export are both taken from. */
getScene() {
return {
elements: api?.getSceneElementsIncludingDeleted() ?? [],
appState: api?.getAppState() ?? {},
files: api?.getFiles() ?? {}
}
},
updateScene(scene) {
api?.updateScene(scene)
},
addFiles(files) {
api?.addFiles(files)
},
/** Redraw in the other theme, for a reader who switches while the editor is open. */
setTheme(next) {
api?.updateScene({ appState: { theme: next } })
},
destroy() {
// -> Asynchronously, because React refuses to unmount a root from inside a render or an effect
// it is currently running, which is exactly where a Vue component being torn down can be
setTimeout(() => root.unmount(), 0)
}
}
})
}
// ----------------------------------------
// Where an image in a drawing actually lives
// ----------------------------------------
/** Run every file's `dataURL` through `map`, leaving the rest of each entry alone. */
function filesWith(files, map) {
return Object.fromEntries(
Object.entries(files ?? {}).map(([id, file]) => [id, { ...file, dataURL: map(file?.dataURL) }])
)
}
/**
* What a page stores for an image: the site-relative path, as everything else in this wiki stores one.
*
* A `blob:` URL is left as it is. That is an image pasted in this session and not yet uploaded, and
* rewriting it is the upload's job — see `UploadPendingAssetsDialog`, which does the same to the
* source of a markdown page.
*/
export function toStoredPath(url) {
const value = String(url ?? '')
return value.startsWith(FILES_PREFIX) ? `/${value.slice(FILES_PREFIX.length)}` : value
}
/** And back: what the browser actually fetches the image from. */
export function toLiveUrl(path) {
const value = String(path ?? '')
return value.startsWith('/') ? `${FILES_PREFIX}${value.slice(1)}` : value
}

@ -20,6 +20,7 @@ export const EDITOR_ICONS = {
markdown: 'markdown', markdown: 'markdown',
visual: 'google-presentation', visual: 'google-presentation',
asciidoc: 'asciidoc', asciidoc: 'asciidoc',
excalidraw: 'draw',
channel: 'chat', channel: 'chat',
blog: 'typewriter-with-paper', blog: 'typewriter-with-paper',
api: 'api', api: 'api',

@ -19,6 +19,13 @@ import { MarkdownRenderer } from '@/renderers/markdown'
const FILE_TYPES = { const FILE_TYPES = {
markdown: { ext: 'md', mime: 'text/markdown' }, markdown: { ext: 'md', mime: 'text/markdown' },
adoc: { ext: 'adoc', mime: 'text/asciidoc' }, adoc: { ext: 'adoc', mime: 'text/asciidoc' },
/*
A drawing is a JSON document, but `.excalidraw` is what names it everywhere outside this wiki --
it is what Excalidraw itself writes and what opens one again. The same reasoning as
`PAGE_FILE_EXTENSIONS` on the server, which is where a drawing lands when a storage target writes
the tree out as files.
*/
excalidraw: { ext: 'excalidraw', mime: 'application/json' },
html: { ext: 'html', mime: 'text/html' } html: { ext: 'html', mime: 'text/html' }
} }
@ -87,9 +94,9 @@ export async function saveVersionSource(version) {
* directory reaches for a store and this is not the file to start in. The caller has it already, and * directory reaches for a store and this is not the file to start in. The caller has it already, and
* has to make sure it is loaded (`editorStore.fetchConfigs()`) before asking. * has to make sure it is loaded (`editorStore.fetchConfigs()`) before asking.
* *
* Asynchronous because one of the two pipelines is: Asciidoctor's `convert` returns a promise, and it * Asynchronous because all but one of the pipelines is: Asciidoctor's `convert` returns a promise and
* is reached through a dynamic import so that a reader looking at the history of a markdown page never * so does drawing an Excalidraw scene, and both are reached through a dynamic import so that a reader
* downloads it. * looking at the history of a markdown page downloads neither.
* *
* @param {object} version A version WITH its `content`. * @param {object} version A version WITH its `content`.
* @param {object} options * @param {object} options
@ -111,6 +118,16 @@ export async function renderVersionSource(version, { markdownConfig, asciidocCon
const { AsciidocRenderer } = await import('@/renderers/asciidoc') const { AsciidocRenderer } = await import('@/renderers/asciidoc')
return new AsciidocRenderer(asciidocConfig ?? {}).render(content, { pagePath }) return new AsciidocRenderer(asciidocConfig ?? {}).render(content, { pagePath })
} }
case 'excalidraw': {
/*
The drawing as it was, rather than the JSON that describes it -- which is what the default
below would show, and is unreadable. Excalidraw is a large thing to fetch for a history
screen, so it is dynamically imported like the AsciiDoc pipeline above: a reader looking
through the history of a page that is not a drawing never downloads it.
*/
const { exportSceneSvg, parseScene } = await import('@/editor/excalidraw')
return exportSceneSvg(parseScene(content))
}
default: default:
return content return content
} }

@ -13,6 +13,14 @@ import { initializeHairlines } from './helpers/hairline'
import './css/tailwind.css' import './css/tailwind.css'
import './css/app.scss' import './css/app.scss'
/*
The `@font-face` rules for the fonts a drawing is lettered in, generated at build time from the
Excalidraw package — see `excalidrawAssets` in `vite.config.js`. Here rather than in the editor's own
chunk because a READER of a drawing never loads the editor: the page holds the SVG it exported, which
names its fonts and does not carry them. About a kilobyte, and it fetches nothing until a glyph on
the page actually calls for one.
*/
import 'virtual:excalidraw-fonts.css'
import RootApp from './App.vue' import RootApp from './App.vue'

@ -129,6 +129,7 @@ const state = reactive({
asciidoc: false, asciidoc: false,
blog: false, blog: false,
channel: false, channel: false,
excalidraw: false,
markdown: false, markdown: false,
redirect: false, redirect: false,
visual: false visual: false
@ -166,6 +167,15 @@ const editors = reactive([
isDisabled: true, isDisabled: true,
useRendering: false useRendering: false
}, },
{
id: 'excalidraw',
icon: 'draw',
/*
No configuration screen, and none to have: a drawing is drawn rather than written, so there is
no syntax to switch on or off and no pipeline to point at anything. See `EditorExcalidraw.vue`.
*/
useRendering: false
},
{ {
id: 'markdown', id: 'markdown',
icon: 'markdown', icon: 'markdown',
@ -208,6 +218,7 @@ async function load() {
const data = resp?.editors const data = resp?.editors
state.config.asciidoc = data?.asciidoc?.isActive ?? false state.config.asciidoc = data?.asciidoc?.isActive ?? false
state.config.blog = data?.blog?.isActive ?? false state.config.blog = data?.blog?.isActive ?? false
state.config.excalidraw = data?.excalidraw?.isActive ?? false
state.config.markdown = data?.markdown?.isActive ?? false state.config.markdown = data?.markdown?.isActive ?? false
state.config.redirect = data?.redirect?.isActive ?? false state.config.redirect = data?.redirect?.isActive ?? false
state.config.visual = data?.visual?.isActive ?? false state.config.visual = data?.visual?.isActive ?? false
@ -230,6 +241,7 @@ async function save() {
editors: { editors: {
asciidoc: { isActive: state.config.asciidoc }, asciidoc: { isActive: state.config.asciidoc },
blog: { isActive: state.config.blog }, blog: { isActive: state.config.blog },
excalidraw: { isActive: state.config.excalidraw },
markdown: { isActive: state.config.markdown }, markdown: { isActive: state.config.markdown },
redirect: { isActive: state.config.redirect }, redirect: { isActive: state.config.redirect },
visual: { isActive: state.config.visual } visual: { isActive: state.config.visual }
@ -246,6 +258,7 @@ async function save() {
editors: { editors: {
asciidoc: state.config.asciidoc, asciidoc: state.config.asciidoc,
blog: state.config.blog, blog: state.config.blog,
excalidraw: state.config.excalidraw,
markdown: state.config.markdown, markdown: state.config.markdown,
redirect: state.config.redirect, redirect: state.config.redirect,
visual: state.config.visual visual: state.config.visual

@ -524,6 +524,16 @@ const editorComponents = {
loader: () => import('../components/EditorAsciidoc.vue'), loader: () => import('../components/EditorAsciidoc.vue'),
loadingComponent: LoadingGeneric loadingComponent: LoadingGeneric
}), }),
/*
Its chunk carries React as well as Excalidraw, which is the largest thing this app loads on demand
-- so it is fetched the first time somebody opens a drawing and never at all on an instance that
has none. Nothing else in the app imports React, which is what keeps that true; a READER of a
drawing is served the SVG the editor exported at save time and comes nowhere near this.
*/
excalidraw: defineAsyncComponent({
loader: () => import('../components/EditorExcalidraw.vue'),
loadingComponent: LoadingGeneric
}),
visual: defineAsyncComponent({ visual: defineAsyncComponent({
loader: () => import('../components/EditorVisual.vue'), loader: () => import('../components/EditorVisual.vue'),
loadingComponent: LoadingGeneric loadingComponent: LoadingGeneric

@ -64,6 +64,14 @@ export const useEditorStore = defineStore('editor', {
originPageId: '' originPageId: ''
}) })
}, },
/**
* Take a file the author has just put into a page, to be uploaded when the page is saved.
*
* @param {File|Blob} data A `File` keeps the name it arrived with; a `Blob` is given one, since
* it has none and only its media type to name it by.
* @returns {string} A `blob:` URL to write into the content, which the upload rewrites to a
* stored path — see `UploadPendingAssetsDialog`.
*/
addPendingAsset(data) { addPendingAsset(data) {
const blobUrl = URL.createObjectURL(data) const blobUrl = URL.createObjectURL(data)
if (data instanceof File) { if (data instanceof File) {
@ -80,7 +88,11 @@ export const useEditorStore = defineStore('editor', {
this.pendingAssets.push({ this.pendingAssets.push({
id: fileId, id: fileId,
kind: 'blob', kind: 'blob',
file: new File(data, fileName, { type: data.type }), // -> `[data]`, because the constructor takes the PARTS of a file rather than a file: a bare
// blob is not iterable and throws. Nothing reached this branch until the drawing editor
// did -- a paste and a drop both arrive as a `File`, so every other caller takes the
// branch above, and an image out of an Excalidraw scene is the first that is only a blob
file: new File([data], fileName, { type: data.type }),
fileName, fileName,
blobUrl blobUrl
}) })

@ -153,6 +153,7 @@ export const useSiteStore = defineStore('site', {
editors: { editors: {
asciidoc: false, asciidoc: false,
blog: false, blog: false,
excalidraw: false,
markdown: false, markdown: false,
redirect: false, redirect: false,
visual: false visual: false
@ -281,8 +282,8 @@ export const useSiteStore = defineStore('site', {
* the editor turned on (`editors`, the admin area's Editors screen), and whether it is * the editor turned on (`editors`, the admin area's Editors screen), and whether it is
* implemented at all — `channel` and `api` are names with no editor behind them yet, so both are * implemented at all — `channel` and `api` are names with no editor behind them yet, so both are
* behind the experimental flag. On a wiki with the flag off that leaves Markdown, Visual, * behind the experimental flag. On a wiki with the flag off that leaves Markdown, Visual,
* AsciiDoc, Blog and Redirection, in that order: Markdown is what most pages are written with, so * AsciiDoc, Drawing, Blog and Redirection, in that order: Markdown is what most pages are written
* it is the one offered first. * with, so it is the one offered first.
* *
* Redirection is in the list on the same footing as the rest. It used to be unconditional, on the * Redirection is in the list on the same footing as the rest. It used to be unconditional, on the
* reasoning that a redirection authors nothing and so has nothing to turn off — but what the * reasoning that a redirection authors nothing and so has nothing to turn off — but what the
@ -303,6 +304,12 @@ export const useSiteStore = defineStore('site', {
...(this.editors.markdown ? ['markdown'] : []), ...(this.editors.markdown ? ['markdown'] : []),
...(this.editors.visual ? ['visual'] : []), ...(this.editors.visual ? ['visual'] : []),
...(this.editors.asciidoc ? ['asciidoc'] : []), ...(this.editors.asciidoc ? ['asciidoc'] : []),
/*
Last of the ones that write an article, because it writes a different KIND of one: the three
above are three syntaxes for a page of prose and a drawing is not prose at all, so it sits
beside them rather than among them.
*/
...(this.editors.excalidraw ? ['excalidraw'] : []),
/* /*
After the two that write pages and before the one that writes none: a blog's front page is After the two that write pages and before the one that writes none: a blog's front page is
a page somebody creates deliberately and rarely, so it does not belong at the top of the a page somebody creates deliberately and rarely, so it does not belong at the top of the
@ -447,6 +454,7 @@ export const useSiteStore = defineStore('site', {
editors: { editors: {
asciidoc: siteInfo.editors.asciidoc?.isActive ?? false, asciidoc: siteInfo.editors.asciidoc?.isActive ?? false,
blog: siteInfo.editors.blog?.isActive ?? false, blog: siteInfo.editors.blog?.isActive ?? false,
excalidraw: siteInfo.editors.excalidraw?.isActive ?? false,
markdown: siteInfo.editors.markdown?.isActive ?? false, markdown: siteInfo.editors.markdown?.isActive ?? false,
redirect: siteInfo.editors.redirect?.isActive ?? false, redirect: siteInfo.editors.redirect?.isActive ?? false,
visual: siteInfo.editors.visual?.isActive ?? false visual: siteInfo.editors.visual?.isActive ?? false

@ -11,6 +11,29 @@ import vueDevTools from 'vite-plugin-vue-devtools'
const TWEMOJI_ROUTE = '/_assets/svg/twemoji' const TWEMOJI_ROUTE = '/_assets/svg/twemoji'
/**
* Where the Excalidraw editor's own assets are served from.
*
* Handed to it as `window.EXCALIDRAW_ASSET_PATH` (`src/editor/excalidraw/index.js`), which is what it
* resolves every font it fetches at runtime against. Setting it matters beyond tidiness: left unset,
* Excalidraw falls back to a CDN of its own -- and it appends that fallback even when the variable IS
* set, so a font this build fails to ship does not break, it quietly fetches from a third party. That
* is exactly what `WIKI.config.offline` exists to prevent, and there is no switch in Excalidraw to
* turn it off, so shipping the complete set is the only thing that keeps the reader's browser at home.
*/
const EXCALIDRAW_ROUTE = '/_assets/excalidraw'
/**
* The drawing fonts NOT shipped with the build, by the directory they live in.
*
* Xiaolai is Excalidraw's CJK handwriting fallback and is 13 MB across a thousand subset files -- more
* than the rest of the wiki's assets put together, for a font most instances will never draw a glyph
* of. Left out, a drawing containing CJK text falls through to the CDN described above and needs the
* internet to come out right; everything else is local. Revisit if that trade stops being the right
* one -- it is one name in this set.
*/
const EXCALIDRAW_SKIPPED_FONTS = new Set(['Xiaolai'])
/** /**
* Fails the build unless every emoji a page can contain has an SVG in `svgDir`. * Fails the build unless every emoji a page can contain has an SVG in `svgDir`.
* *
@ -116,6 +139,152 @@ function twemojiAssets() {
} }
} }
/**
* Excalidraw's drawing fonts, as the package describes them to itself.
*
* Read out of the UNMINIFIED build in `dist/dev`, which carries the same registry the minified one
* runs and is the only copy with names left on it. Nothing is imported or executed: it is browser code
* that touches `window` as it loads, and all that is wanted from it is a table.
*
* Each family is `var <Group>FontFaces = [{ uri, descriptors: { unicodeRange } }]`, where `uri` names a
* `var <X>_default = "./fonts/..."` beside it, and `init("Family Name", ...<Group>FontFaces)` further
* down is what gives the family the name CSS has to match. A face whose `uri` resolves to no file is a
* system font (`LOCAL_FONT_PROTOCOL` -- Helvetica and the emoji fallback) and has nothing to serve.
*
* @throws When the shape has changed, which on an upgrade is the difference between noticing here and
* shipping a wiki whose drawings all render in the browser's default font.
*/
function readExcalidrawFonts(distDir) {
const devDir = path.join(distDir, 'dev')
const chunk = fs
.readdirSync(devDir)
.filter((name) => name.endsWith('.js'))
.map((name) => path.join(devDir, name))
.find((file) => fs.readFileSync(file, 'utf8').includes('FontFaces = ['))
if (!chunk) {
throw new Error(`excalidraw: no font registry found in ${devDir}`)
}
const src = fs.readFileSync(chunk, 'utf8')
const files = new Map()
for (const m of src.matchAll(/var (\w+_default) = "\.\/(fonts\/[^"]+)";/g)) {
files.set(m[1], m[2])
}
const names = new Map()
for (const m of src.matchAll(/init\(\s*"([^"]+)"\s*,\s*\.\.\.(\w+)FontFaces\s*\)/g)) {
names.set(m[2], m[1])
}
const families = []
for (const m of src.matchAll(/var (\w+)FontFaces = \[([\s\S]*?)\n\];/g)) {
const [, group, body] = m
const family = names.get(group)
if (!family || EXCALIDRAW_SKIPPED_FONTS.has(group)) {
continue
}
const faces = []
for (const face of body.matchAll(
/\{\s*uri:\s*(\w+)\s*(?:,\s*descriptors:\s*\{([\s\S]*?)\}\s*)?\}/g
)) {
const file = files.get(face[1])
if (file) {
faces.push({ file, unicodeRange: face[2]?.match(/unicodeRange:\s*"([^"]*)"/)?.[1] ?? '' })
}
}
if (faces.length > 0) {
families.push({ group, family, faces })
}
}
if (families.length === 0) {
throw new Error(
`excalidraw: the font registry in ${path.basename(chunk)} parsed to nothing. Its shape has changed — see readExcalidrawFonts.`
)
}
return families
}
/**
* The Excalidraw editor's fonts: served in dev, copied on build, and declared to CSS.
*
* Two halves, because two different things need them and only one of them loads Excalidraw.
*
* The EDITOR fetches them itself, by the URL above, and would do so from a CDN if they were not here.
*
* A READER never loads Excalidraw at all -- a drawing is stored as the SVG the editor exported at save
* time, and that SVG names its fonts and does not carry them. Excalidraw's own `@font-face` rules are
* registered from JavaScript, so there is nothing for a page without it to inherit; hence the
* generated stylesheet, which `main.js` imports so that every page has the declarations. It costs
* about a kilobyte and downloads no font until a glyph actually needs one, so a wiki with no drawings
* in it pays the kilobyte and nothing else.
*
* The files are neither committed nor imported, for the reason the twemoji assets above are not: they
* are a directory of hashed subsets that no source file names, so Vite cannot discover them.
*/
function excalidrawAssets() {
const distDir = path.resolve(
path.dirname(createRequire(import.meta.url).resolve('@excalidraw/excalidraw')),
'..'
)
const fontsDir = path.join(distDir, 'prod', 'fonts')
const VIRTUAL_CSS = 'virtual:excalidraw-fonts.css'
const RESOLVED_CSS = `\0${VIRTUAL_CSS}`
let outDir = null
return {
name: 'wiki-excalidraw-assets',
configResolved(config) {
outDir = path.resolve(config.root, config.build.outDir)
},
resolveId(id) {
return id === VIRTUAL_CSS ? RESOLVED_CSS : null
},
load(id) {
if (id !== RESOLVED_CSS) {
return null
}
return readExcalidrawFonts(distDir)
.flatMap(({ family, faces }) =>
faces.map(
({ file, unicodeRange }) =>
`@font-face{font-family:"${family}";font-style:normal;font-weight:400;font-display:swap;` +
`src:url("${EXCALIDRAW_ROUTE}/${file}") format("woff2")` +
`${unicodeRange ? `;unicode-range:${unicodeRange}` : ''}}`
)
)
.join('\n')
},
configureServer(server) {
// -> connect strips the prefix, so `req.url` starts at `/fonts/...` here
server.middlewares.use(EXCALIDRAW_ROUTE, (req, res, next) => {
const rel = path.normalize(req.url.split('?')[0]).replace(/^(\.\.[/\\])+/, '')
const file = path.join(distDir, 'prod', rel)
// -> Both a traversal guard and a cheap 404 for anything that is not one of these files
if (!file.startsWith(fontsDir + path.sep) || !file.endsWith('.woff2')) {
next()
return
}
fs.promises.readFile(file).then((font) => {
res.setHeader('Content-Type', 'font/woff2')
res.end(font)
}, next)
})
},
// -> Not `emitFile`: these need no processing, and their names already carry a content hash
async writeBundle() {
const target = path.join(outDir, EXCALIDRAW_ROUTE.slice(1), 'fonts')
await fs.promises.rm(target, { recursive: true, force: true })
for (const family of await fs.promises.readdir(fontsDir)) {
if (EXCALIDRAW_SKIPPED_FONTS.has(family)) {
continue
}
await fs.promises.cp(path.join(fontsDir, family), path.join(target, family), {
recursive: true
})
}
}
}
}
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
const userConfig = const userConfig =
@ -169,6 +338,7 @@ export default defineConfig(({ mode }) => {
}), }),
tailwindcss(), tailwindcss(),
twemojiAssets(), twemojiAssets(),
excalidrawAssets(),
vueDevTools() vueDevTools()
], ],
css: { css: {

Loading…
Cancel
Save