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,
definePasteHandler,
defineUpdateHandler,
toggleMark,
toggleNode,
union,
} 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,
// 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).
@@ -119,6 +158,24 @@ async function resolveGiteaRepo(owner, repo) {
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() {
return definePasteHandler((view, event) => {
const text = event.clipboardData?.getData('text/plain')?.trim()
@@ -127,23 +184,104 @@ function defineGiteaRepoEmbedPasteRule() {
event.preventDefault()
const [, owner, repo] = match
const pos = 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)
})
insertGiteaRepoEmbed(view, owner, repo, view.state.selection.from)
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) {
ensureStylesheet('https://esm.sh/prosekit/basic/style.css')
ensureStylesheet('https://esm.sh/prosekit/basic/typography.css')
@@ -162,6 +300,12 @@ export function mountEditor(container, hiddenInput, initial) {
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 html = editor.view.dom.innerHTML
if (hiddenInput.value === html) return
@@ -169,7 +313,13 @@ export function mountEditor(container, hiddenInput, initial) {
hiddenInput.dispatchEvent(new Event('input', { bubbles: true }))
}
editor.use(defineUpdateHandler(sync))
editor.mount(container)
editor.use(
defineUpdateHandler(() => {
sync()
updateActiveStates()
}),
)
editor.mount(editorMount)
sync()
updateActiveStates()
}