mirror of
https://github.com/requarks/wiki.git
synced 2026-10-12 05:54:49 -05:00
feat: add excalidraw editor + live collaboration
This commit is contained in:
25 files changed
+4329
-21
No files matched your search
@@ -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
|
||||
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
|
||||
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:
|
||||
@@ -634,6 +644,68 @@ Consequences worth knowing:
|
||||
[Utilities and dates](#utilities-and-dates); the `lodash-es` and `luxon` still present in older
|
||||
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
|
||||
|
||||
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: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
||||
@@ -180,6 +180,9 @@ editors:
|
||||
asciidoc:
|
||||
contentType: adoc
|
||||
config: {}
|
||||
excalidraw:
|
||||
contentType: excalidraw
|
||||
config: {}
|
||||
markdown:
|
||||
contentType: markdown
|
||||
config:
|
||||
|
||||
+21
-1
@@ -156,6 +156,23 @@ function toBytes(data: unknown): Uint8Array {
|
||||
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.
|
||||
*
|
||||
@@ -163,6 +180,7 @@ function toBytes(data: unknown): Uint8Array {
|
||||
* the page — see the note at the top of this file on why that matters.
|
||||
*/
|
||||
function buildSeed(page: {
|
||||
editor?: string | null
|
||||
content?: string | null
|
||||
title?: string | null
|
||||
description?: string | null
|
||||
@@ -171,7 +189,9 @@ function buildSeed(page: {
|
||||
const seed = new Y.Doc()
|
||||
seed.clientID = 0
|
||||
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')
|
||||
props.set('title', page.title ?? '')
|
||||
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.channelName": "Discussion Channels",
|
||||
"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.allowHTMLHint": "Allow HTML tags in content.",
|
||||
"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.markdownName": "Markdown Editor",
|
||||
"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.subtitle": "Manage editors and their configuration",
|
||||
"admin.editors.title": "Editors",
|
||||
@@ -1723,6 +1725,7 @@
|
||||
"common.createPage.asciidoc": "New AsciiDoc Page",
|
||||
"common.createPage.blog": "New Blog",
|
||||
"common.createPage.channel": "New Discussion Space",
|
||||
"common.createPage.excalidraw": "New Drawing Page",
|
||||
"common.createPage.markdown": "New Markdown Page",
|
||||
"common.createPage.redirect": "New Redirection",
|
||||
"common.createPage.uploadAsset": "Upload Media Asset",
|
||||
@@ -2034,6 +2037,9 @@
|
||||
"editor.emoji.smileysEmotion": "Smileys & Emotion",
|
||||
"editor.emoji.symbols": "Symbols",
|
||||
"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.checkFailed": "Could not check the existing relations of that page.",
|
||||
"editor.localeRel.clear": "Remove this relation",
|
||||
|
||||
@@ -19,6 +19,7 @@ const EDITOR_CONTENT_TYPES: Record<string, string> = {
|
||||
markdown: 'markdown',
|
||||
visual: 'markdown',
|
||||
asciidoc: 'adoc',
|
||||
excalidraw: 'excalidraw',
|
||||
redirect: 'redirect',
|
||||
blog: 'blog'
|
||||
}
|
||||
@@ -54,6 +55,14 @@ export const PAGE_FILE_EXTENSIONS: Record<string, string> = {
|
||||
markdown: 'md',
|
||||
html: 'html',
|
||||
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',
|
||||
blog: 'json'
|
||||
}
|
||||
@@ -87,6 +96,7 @@ export function pageFileExtension(contentType: string): string {
|
||||
const EDITOR_FOR_PAGE_EXTENSION: Record<string, string> = {
|
||||
md: 'markdown',
|
||||
adoc: 'asciidoc',
|
||||
excalidraw: 'excalidraw',
|
||||
json: 'redirect'
|
||||
}
|
||||
|
||||
|
||||
@@ -212,6 +212,17 @@ const BASE_ALLOWED_TAGS = [
|
||||
'desc',
|
||||
'ellipse',
|
||||
'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',
|
||||
'linearGradient',
|
||||
'marker',
|
||||
@@ -229,21 +240,44 @@ const BASE_ALLOWED_TAGS = [
|
||||
'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 = [
|
||||
'clip-path',
|
||||
'clip-rule',
|
||||
'color',
|
||||
'cx',
|
||||
'cy',
|
||||
'd',
|
||||
'direction',
|
||||
'dominant-baseline',
|
||||
'fill',
|
||||
'fill-opacity',
|
||||
'fill-rule',
|
||||
'font-family',
|
||||
'font-size',
|
||||
'font-style',
|
||||
'font-variant',
|
||||
'font-weight',
|
||||
'height',
|
||||
'href',
|
||||
'letter-spacing',
|
||||
'mask',
|
||||
'offset',
|
||||
'opacity',
|
||||
'paint-order',
|
||||
'points',
|
||||
'preserveAspectRatio',
|
||||
'r',
|
||||
@@ -253,13 +287,20 @@ const SVG_ATTRIBUTES = [
|
||||
'stop-opacity',
|
||||
'stroke',
|
||||
'stroke-dasharray',
|
||||
'stroke-dashoffset',
|
||||
'stroke-linecap',
|
||||
'stroke-linejoin',
|
||||
'stroke-miterlimit',
|
||||
'stroke-opacity',
|
||||
'stroke-width',
|
||||
'text-anchor',
|
||||
'text-decoration',
|
||||
'transform',
|
||||
'vector-effect',
|
||||
'viewBox',
|
||||
'width',
|
||||
'word-spacing',
|
||||
'writing-mode',
|
||||
'x',
|
||||
'x1',
|
||||
'x2',
|
||||
@@ -310,6 +351,7 @@ const BASE_ALLOWED_ATTRIBUTES: Record<string, string[]> = {
|
||||
defs: SVG_ATTRIBUTES,
|
||||
ellipse: SVG_ATTRIBUTES,
|
||||
g: SVG_ATTRIBUTES,
|
||||
image: SVG_ATTRIBUTES,
|
||||
line: SVG_ATTRIBUTES,
|
||||
linearGradient: [...SVG_ATTRIBUTES, 'gradientUnits', 'gradientTransform'],
|
||||
marker: [...SVG_ATTRIBUTES, 'markerWidth', 'markerHeight', 'orient', 'refX', 'refY'],
|
||||
@@ -322,7 +364,8 @@ const BASE_ALLOWED_ATTRIBUTES: Record<string, string[]> = {
|
||||
rect: SVG_ATTRIBUTES,
|
||||
stop: 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'],
|
||||
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
|
||||
* 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 {
|
||||
const $copy = cheerio.load($.html(), null, false)
|
||||
$copy('script, style').remove()
|
||||
$copy('svg text').after(' ')
|
||||
return $copy.root().text().replaceAll(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
|
||||
@@ -212,6 +212,15 @@ class Sites {
|
||||
isActive: true,
|
||||
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: {
|
||||
isActive: true,
|
||||
config: {
|
||||
@@ -498,6 +507,15 @@ class Sites {
|
||||
isActive: true,
|
||||
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: {
|
||||
isActive: true,
|
||||
config: {
|
||||
|
||||
Generated
+2759
-4
File diff suppressed because it is too large.
Load diff
@@ -1,10 +1,9 @@
|
||||
{
|
||||
"name": "wiki-ux",
|
||||
"version": "3.0.0",
|
||||
"description": "The most powerful and extensible open source Wiki software",
|
||||
"productName": "Wiki.js",
|
||||
"author": "Nicolas Giard <nick@requarks.io>",
|
||||
"private": true,
|
||||
"description": "The most powerful and extensible open source Wiki software",
|
||||
"author": "Nicolas Giard <nick@requarks.io>",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --force",
|
||||
@@ -19,6 +18,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@asciidoctor/core": "4.0.11",
|
||||
"@excalidraw/excalidraw": "0.18.1",
|
||||
"@simplewebauthn/browser": "13.3.0",
|
||||
"@tailwindcss/vite": "4.3.3",
|
||||
"@twemoji/api": "17.0.2",
|
||||
@@ -63,6 +63,8 @@
|
||||
"prosemirror-tables": "1.8.5",
|
||||
"prosemirror-transform": "1.12.1",
|
||||
"prosemirror-view": "1.42.3",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"semver": "7.8.5",
|
||||
"slugify": "1.6.9",
|
||||
"sortablejs": "1.15.7",
|
||||
@@ -106,5 +108,6 @@
|
||||
},
|
||||
"engines": {
|
||||
"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 { MonacoBinding } from 'y-monaco'
|
||||
import { WebsocketProvider } from 'y-websocket'
|
||||
import * as Y from 'yjs'
|
||||
|
||||
@@ -59,6 +58,10 @@ const USER_COLORS = [
|
||||
|
||||
let doc = 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 styleEl = 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
|
||||
* 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) {
|
||||
return
|
||||
}
|
||||
@@ -291,6 +294,24 @@ export function bindCollabEditor(editor) {
|
||||
if (!model) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -307,7 +328,9 @@ export function stopCollabSession() {
|
||||
}
|
||||
stopWatchers = []
|
||||
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
|
||||
}
|
||||
if (provider) {
|
||||
|
||||
@@ -1869,6 +1869,35 @@
|
||||
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:
|
||||
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',
|
||||
visual: 'google-presentation',
|
||||
asciidoc: 'asciidoc',
|
||||
excalidraw: 'draw',
|
||||
channel: 'chat',
|
||||
blog: 'typewriter-with-paper',
|
||||
api: 'api',
|
||||
|
||||
@@ -19,6 +19,13 @@ import { MarkdownRenderer } from '@/renderers/markdown'
|
||||
const FILE_TYPES = {
|
||||
markdown: { ext: 'md', mime: 'text/markdown' },
|
||||
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' }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* 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
|
||||
* is reached through a dynamic import so that a reader looking at the history of a markdown page never
|
||||
* downloads it.
|
||||
* Asynchronous because all but one of the pipelines is: Asciidoctor's `convert` returns a promise and
|
||||
* so does drawing an Excalidraw scene, and both are reached through a dynamic import so that a reader
|
||||
* looking at the history of a markdown page downloads neither.
|
||||
*
|
||||
* @param {object} version A version WITH its `content`.
|
||||
* @param {object} options
|
||||
@@ -111,6 +118,16 @@ export async function renderVersionSource(version, { markdownConfig, asciidocCon
|
||||
const { AsciidocRenderer } = await import('@/renderers/asciidoc')
|
||||
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:
|
||||
return content
|
||||
}
|
||||
|
||||
@@ -13,6 +13,14 @@ import { initializeHairlines } from './helpers/hairline'
|
||||
|
||||
import './css/tailwind.css'
|
||||
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'
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@ const state = reactive({
|
||||
asciidoc: false,
|
||||
blog: false,
|
||||
channel: false,
|
||||
excalidraw: false,
|
||||
markdown: false,
|
||||
redirect: false,
|
||||
visual: false
|
||||
@@ -166,6 +167,15 @@ const editors = reactive([
|
||||
isDisabled: true,
|
||||
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',
|
||||
icon: 'markdown',
|
||||
@@ -208,6 +218,7 @@ async function load() {
|
||||
const data = resp?.editors
|
||||
state.config.asciidoc = data?.asciidoc?.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.redirect = data?.redirect?.isActive ?? false
|
||||
state.config.visual = data?.visual?.isActive ?? false
|
||||
@@ -230,6 +241,7 @@ async function save() {
|
||||
editors: {
|
||||
asciidoc: { isActive: state.config.asciidoc },
|
||||
blog: { isActive: state.config.blog },
|
||||
excalidraw: { isActive: state.config.excalidraw },
|
||||
markdown: { isActive: state.config.markdown },
|
||||
redirect: { isActive: state.config.redirect },
|
||||
visual: { isActive: state.config.visual }
|
||||
@@ -246,6 +258,7 @@ async function save() {
|
||||
editors: {
|
||||
asciidoc: state.config.asciidoc,
|
||||
blog: state.config.blog,
|
||||
excalidraw: state.config.excalidraw,
|
||||
markdown: state.config.markdown,
|
||||
redirect: state.config.redirect,
|
||||
visual: state.config.visual
|
||||
|
||||
@@ -524,6 +524,16 @@ const editorComponents = {
|
||||
loader: () => import('../components/EditorAsciidoc.vue'),
|
||||
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({
|
||||
loader: () => import('../components/EditorVisual.vue'),
|
||||
loadingComponent: LoadingGeneric
|
||||
|
||||
@@ -64,6 +64,14 @@ export const useEditorStore = defineStore('editor', {
|
||||
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) {
|
||||
const blobUrl = URL.createObjectURL(data)
|
||||
if (data instanceof File) {
|
||||
@@ -80,7 +88,11 @@ export const useEditorStore = defineStore('editor', {
|
||||
this.pendingAssets.push({
|
||||
id: fileId,
|
||||
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,
|
||||
blobUrl
|
||||
})
|
||||
|
||||
@@ -153,6 +153,7 @@ export const useSiteStore = defineStore('site', {
|
||||
editors: {
|
||||
asciidoc: false,
|
||||
blog: false,
|
||||
excalidraw: false,
|
||||
markdown: false,
|
||||
redirect: 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
|
||||
* 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,
|
||||
* AsciiDoc, Blog and Redirection, in that order: Markdown is what most pages are written with, so
|
||||
* it is the one offered first.
|
||||
* AsciiDoc, Drawing, Blog and Redirection, in that order: Markdown is what most pages are written
|
||||
* 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
|
||||
* 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.visual ? ['visual'] : []),
|
||||
...(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
|
||||
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: {
|
||||
asciidoc: siteInfo.editors.asciidoc?.isActive ?? false,
|
||||
blog: siteInfo.editors.blog?.isActive ?? false,
|
||||
excalidraw: siteInfo.editors.excalidraw?.isActive ?? false,
|
||||
markdown: siteInfo.editors.markdown?.isActive ?? false,
|
||||
redirect: siteInfo.editors.redirect?.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'
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
*
|
||||
@@ -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/
|
||||
export default defineConfig(({ mode }) => {
|
||||
const userConfig =
|
||||
@@ -169,6 +338,7 @@ export default defineConfig(({ mode }) => {
|
||||
}),
|
||||
tailwindcss(),
|
||||
twemojiAssets(),
|
||||
excalidrawAssets(),
|
||||
vueDevTools()
|
||||
],
|
||||
css: {
|
||||
|
||||
Reference in new issue
Block a user