Fix hero-canvas navigation race; style the prosekit editor and add a toolbar
Deploy / deploy (push) Successful in 28s
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:
+165
-15
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user