Fix hero-canvas navigation race; style the prosekit editor and add a toolbar
Deploy / deploy (push) Successful in 28s

- Hero: gate RasterizedYES construction on a NodeRef resolving, not
  just "the Effect ran". Root cause of the reported "loading..." stall
  + "RefCell already borrowed" panic - navigating back to / client-side
  could run the Effect before the new <canvas> was actually in the
  DOM, yes.js did an unchecked getElementById(...).getContext() on
  null and threw mid-reactive-update, corrupting wasm_bindgen_futures'
  executor badly enough to panic on the next tick. Same NodeRef-gating
  pattern the prosekit editor's own mount already used.
- style/main.css: .prosekit-wrap/.prosekit-toolbar/.prosekit-editor to
  match the existing input/textarea look (border, background, focus
  ring).
- prosekit-editor.js: a real toolbar (Bold, Italic, H1, H2, Link, and
  a dedicated Gitea-repo-embed button reusing the paste rule's own
  insert logic) with active-state highlighting via the standard
  ProseMirror markActive/nodeActive idiom.
This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-05 14:52:08 +02:00
parent 3242482c8e
commit 00d2a95625
3 changed files with 239 additions and 22 deletions
+165 -15
View File
@@ -33,9 +33,48 @@ import {
defineNodeSpec, defineNodeSpec,
definePasteHandler, definePasteHandler,
defineUpdateHandler, defineUpdateHandler,
toggleMark,
toggleNode,
union, union,
} from 'https://esm.sh/prosekit/core' } from 'https://esm.sh/prosekit/core'
// Bold/italic/link are marks (toggleMark); headings are nodes
// (toggleNode) - both confirmed exports of prosekit/core, matching the
// same shape as the `defineMyBold` example in prosekit's own "Custom
// extensions" guide (`toggleBold: () => toggleMark({ type: 'bold' })`).
// A command is `(state, dispatch, view) => boolean`; toolbar buttons
// just call one directly rather than going through editor.commands,
// since the exact convenience-command names prosekit/basic exposes
// under `commands` aren't documented anywhere as precisely as this.
const TOOLBAR_BUTTONS = [
{ text: 'B', title: 'Bold', markType: 'bold', run: () => toggleMark({ type: 'bold' }) },
{ text: 'I', title: 'Italic', markType: 'italic', run: () => toggleMark({ type: 'italic' }) },
{
text: 'H1',
title: 'Heading',
nodeType: 'heading',
nodeAttrs: { level: 1 },
run: () => toggleNode({ type: 'heading', attrs: { level: 1 }, toggleType: 'paragraph' }),
},
{
text: 'H2',
title: 'Subheading',
nodeType: 'heading',
nodeAttrs: { level: 2 },
run: () => toggleNode({ type: 'heading', attrs: { level: 2 }, toggleType: 'paragraph' }),
},
{
text: 'Link',
title: 'Link',
markType: 'link',
run: () => {
const href = window.prompt('Link URL:')
if (!href) return () => false
return toggleMark({ type: 'link', attrs: { href } })
},
},
]
// project.uhhm.no/<owner>/<repo>, no trailing path - a bare repo link, // project.uhhm.no/<owner>/<repo>, no trailing path - a bare repo link,
// not a file/issue/PR link within one (those aren't "the repo", and // not a file/issue/PR link within one (those aren't "the repo", and
// trying to card-ify them too isn't what was asked for). // trying to card-ify them too isn't what was asked for).
@@ -119,6 +158,24 @@ async function resolveGiteaRepo(owner, repo) {
return await res.json() return await res.json()
} }
// Shared by the paste rule and the toolbar button below - resolves
// owner/repo, then inserts the embed at `pos` (captured before the
// async fetch, since the selection may have moved by the time it
// resolves).
function insertGiteaRepoEmbed(view, owner, repo, pos) {
resolveGiteaRepo(owner, repo)
.then((info) => {
const nodeType = view.state.schema.nodes.giteaRepoEmbed
if (!nodeType) return
const node = nodeType.create(info)
const tr = view.state.tr.replaceRangeWith(pos, pos, node)
view.dispatch(tr)
})
.catch((err) => {
console.error('gitea repo embed failed', err)
})
}
function defineGiteaRepoEmbedPasteRule() { function defineGiteaRepoEmbedPasteRule() {
return definePasteHandler((view, event) => { return definePasteHandler((view, event) => {
const text = event.clipboardData?.getData('text/plain')?.trim() const text = event.clipboardData?.getData('text/plain')?.trim()
@@ -127,23 +184,104 @@ function defineGiteaRepoEmbedPasteRule() {
event.preventDefault() event.preventDefault()
const [, owner, repo] = match const [, owner, repo] = match
const pos = view.state.selection.from insertGiteaRepoEmbed(view, owner, repo, view.state.selection.from)
resolveGiteaRepo(owner, repo)
.then((info) => {
const nodeType = view.state.schema.nodes.giteaRepoEmbed
if (!nodeType) return
const node = nodeType.create(info)
const tr = view.state.tr.replaceRangeWith(pos, pos, node)
view.dispatch(tr)
})
.catch((err) => {
console.error('gitea repo embed failed', err)
})
return true return true
}) })
} }
// Standard ProseMirror active-state check (same idiom
// prosemirror-example-setup's own menu uses): with a collapsed
// selection, look at stored/marks-at-cursor; otherwise check the
// whole range actually carries the mark.
function markActive(state, typeName) {
const type = state.schema.marks[typeName]
if (!type) return false
const { from, to, empty } = state.selection
if (empty) {
return !!type.isInSet(state.storedMarks || state.selection.$from.marks())
}
return state.doc.rangeHasMark(from, to, type)
}
function nodeActive(state, typeName, attrs) {
const type = state.schema.nodes[typeName]
if (!type) return false
const { $from } = state.selection
for (let d = $from.depth; d >= 0; d--) {
const node = $from.node(d)
if (node.type === type) {
return !attrs || Object.entries(attrs).every(([k, v]) => node.attrs[k] === v)
}
}
return false
}
function buildToolbar(editor) {
const bar = document.createElement('div')
bar.className = 'prosekit-toolbar'
const buttons = TOOLBAR_BUTTONS.map((spec) => {
const button = document.createElement('button')
button.type = 'button'
button.textContent = spec.text
button.title = spec.title
// preventDefault on mousedown, not just handling click - a plain
// click already lost the editor's selection to a focus change by
// the time it fires, so a mark/node toggle would apply at the
// wrong (collapsed) position.
button.addEventListener('mousedown', (event) => {
event.preventDefault()
const { view } = editor
try {
spec.run()(view.state, view.dispatch, view)
} catch (err) {
console.error('toolbar command failed', spec.title, err)
}
view.focus()
})
bar.appendChild(button)
return { button, spec }
})
const updateActiveStates = () => {
const { state } = editor.view
for (const { button, spec } of buttons) {
const active = spec.markType
? markActive(state, spec.markType)
: nodeActive(state, spec.nodeType, spec.nodeAttrs)
button.classList.toggle('is-active', active)
}
}
// Same embed the paste rule creates, but explicit - accepts either a
// full project.uhhm.no URL or a bare "owner/repo" shorthand, so you
// don't need an actual link on the clipboard to use it.
const giteaButton = document.createElement('button')
giteaButton.type = 'button'
giteaButton.textContent = 'Repo'
giteaButton.title = 'Link a Gitea project'
giteaButton.addEventListener('mousedown', (event) => {
event.preventDefault()
const input = window.prompt('Gitea repo (owner/repo or a project.uhhm.no link):')
if (!input) return
const trimmed = input.trim()
const urlMatch = trimmed.match(GITEA_REPO_URL_RE)
const shorthandMatch = trimmed.match(/^([\w.-]+)\/([\w.-]+)$/)
const match = urlMatch || shorthandMatch
if (!match) {
window.alert('Not a recognized owner/repo or project.uhhm.no URL.')
return
}
const [, owner, repo] = match
const { view } = editor
insertGiteaRepoEmbed(view, owner, repo, view.state.selection.from)
view.focus()
})
bar.appendChild(giteaButton)
return { bar, updateActiveStates }
}
export function mountEditor(container, hiddenInput, initial) { export function mountEditor(container, hiddenInput, initial) {
ensureStylesheet('https://esm.sh/prosekit/basic/style.css') ensureStylesheet('https://esm.sh/prosekit/basic/style.css')
ensureStylesheet('https://esm.sh/prosekit/basic/typography.css') ensureStylesheet('https://esm.sh/prosekit/basic/typography.css')
@@ -162,6 +300,12 @@ export function mountEditor(container, hiddenInput, initial) {
defaultContent: initial || undefined, defaultContent: initial || undefined,
}) })
const { bar, updateActiveStates } = buildToolbar(editor)
const editorMount = document.createElement('div')
editorMount.className = 'prosekit-editor'
container.appendChild(bar)
container.appendChild(editorMount)
const sync = () => { const sync = () => {
const html = editor.view.dom.innerHTML const html = editor.view.dom.innerHTML
if (hiddenInput.value === html) return if (hiddenInput.value === html) return
@@ -169,7 +313,13 @@ export function mountEditor(container, hiddenInput, initial) {
hiddenInput.dispatchEvent(new Event('input', { bubbles: true })) hiddenInput.dispatchEvent(new Event('input', { bubbles: true }))
} }
editor.use(defineUpdateHandler(sync)) editor.use(
editor.mount(container) defineUpdateHandler(() => {
sync()
updateActiveStates()
}),
)
editor.mount(editorMount)
sync() sync()
updateActiveStates()
} }
+21 -7
View File
@@ -232,11 +232,11 @@ fn Hero(title: String, description: String, show_yes: bool) -> impl IntoView {
// Only the landing page gets the full interactive piece - it's the // Only the landing page gets the full interactive piece - it's the
// one page this is actually "the" hero for; other pages (thank-you // one page this is actually "the" hero for; other pages (thank-you
// pages, /review) get the plain dark header below. Starts the // pages, /review) get the plain dark header below. Starts the
// animation once the canvas elements exist (post-mount, via // animation once the canvas elements exist, and explicitly stops
// Effect), and explicitly stops the requestAnimationFrame loop on // the requestAnimationFrame loop on unmount - the original
// unmount - the original page-owning script never needed this since // page-owning script never needed this since navigating away meant
// navigating away meant a full document unload, which doesn't // a full document unload, which doesn't happen in an SPA.
// happen in an SPA. let raster_ref: NodeRef<leptos::html::Canvas> = NodeRef::new();
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
if show_yes { if show_yes {
// `on_cleanup` requires Send + Sync (even single-threaded, wasm), // `on_cleanup` requires Send + Sync (even single-threaded, wasm),
@@ -245,7 +245,21 @@ fn Hero(title: String, description: String, show_yes: bool) -> impl IntoView {
// cnats' WebRTC call state uses for its own non-Send peer map). // cnats' WebRTC call state uses for its own non-Send peer map).
let instance: StoredValue<Option<yes::RasterizedYes>, LocalStorage> = let instance: StoredValue<Option<yes::RasterizedYes>, LocalStorage> =
StoredValue::new_local(None); StoredValue::new_local(None);
// Gated on the canvas NodeRef resolving, not just "the Effect
// ran" - on a fresh page load the DOM is already there by the
// time this fires, but navigating back to `/` client-side hit a
// real race: the Effect ran before the new view's <canvas> was
// actually inserted, RasterizedYES::new() (yes.js) did an
// unchecked `document.getElementById(...).getContext(...)` on
// null and threw mid-reactive-update - which then corrupted
// wasm_bindgen_futures' single-threaded executor state badly
// enough to panic ("RefCell already borrowed") on the next
// tick. Waiting for the NodeRef itself guarantees DOM presence
// the same way the prosekit editor's own mount Effect does.
Effect::new(move |_| { Effect::new(move |_| {
if raster_ref.get().is_none() {
return;
}
instance.set_value(Some(yes::RasterizedYes::new())); instance.set_value(Some(yes::RasterizedYes::new()));
}); });
on_cleanup(move || { on_cleanup(move || {
@@ -264,7 +278,7 @@ fn Hero(title: String, description: String, show_yes: bool) -> impl IntoView {
view! { view! {
<div class="hero-canvas" aria-hidden="true"> <div class="hero-canvas" aria-hidden="true">
<canvas id="lineCanvas"></canvas> <canvas id="lineCanvas"></canvas>
<canvas id="rasterCanvas"></canvas> <canvas id="rasterCanvas" node_ref=raster_ref></canvas>
</div> </div>
} }
})} })}
@@ -502,7 +516,7 @@ fn AlternativeCard(
prop:value=move || sig.get() prop:value=move || sig.get()
on:input=move |ev| sig.set(event_target_value(&ev)) on:input=move |ev| sig.set(event_target_value(&ev))
/> />
<div class="prosekit-editor" node_ref=container_ref></div> <div class="prosekit-wrap" node_ref=container_ref></div>
</label> </label>
} }
.into_any(); .into_any();
+53
View File
@@ -218,6 +218,59 @@ input:focus, textarea:focus {
box-shadow: 0 0 0 3px var(--accent-soft); box-shadow: 0 0 0 3px var(--accent-soft);
} }
.prosekit-wrap {
background: var(--paper);
border: 1px solid var(--line);
border-radius: 0.6rem;
overflow: hidden;
}
.prosekit-toolbar {
display: flex;
gap: 0.2rem;
padding: 0.4rem;
border-bottom: 1px solid var(--line);
flex-wrap: wrap;
}
.prosekit-toolbar button {
font: inherit;
font-size: 0.9rem;
color: var(--ink-dim);
background: transparent;
border: 1px solid transparent;
border-radius: 0.4rem;
padding: 0.3rem 0.55rem;
cursor: pointer;
line-height: 1;
}
.prosekit-toolbar button:hover { color: var(--ink); border-color: var(--line); }
.prosekit-toolbar button.is-active {
color: var(--paper);
background: var(--accent);
}
.prosekit-editor {
font: inherit;
color: var(--ink);
padding: 0.65rem 0.8rem;
min-height: 5rem;
}
.prosekit-editor:focus {
outline: none;
}
.prosekit-wrap:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.prosekit-editor p { margin: 0 0 0.6em; }
.prosekit-editor p:last-child { margin-bottom: 0; }
.alt-submit { .alt-submit {
margin-top: 1.4rem; margin-top: 1.4rem;
font-family: var(--sans); font-family: var(--sans);