mirror of https://github.com/requarks/wiki
parent
45af5eb7fb
commit
b87c236d4a
File diff suppressed because it is too large
Load Diff
|
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>
|
||||
@ -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
|
||||
}
|
||||
Loading…
Reference in new issue