diff --git a/prosekit-editor.js b/prosekit-editor.js index 11662ca..22f710b 100644 --- a/prosekit-editor.js +++ b/prosekit-editor.js @@ -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//, 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() } diff --git a/src/app.rs b/src/app.rs index 0e57b2c..c0f27e8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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 // one page this is actually "the" hero for; other pages (thank-you // pages, /review) get the plain dark header below. Starts the - // animation once the canvas elements exist (post-mount, via - // Effect), and explicitly stops the requestAnimationFrame loop on - // unmount - the original page-owning script never needed this since - // navigating away meant a full document unload, which doesn't - // happen in an SPA. + // animation once the canvas elements exist, and explicitly stops + // the requestAnimationFrame loop on unmount - the original + // page-owning script never needed this since navigating away meant + // a full document unload, which doesn't happen in an SPA. + let raster_ref: NodeRef = NodeRef::new(); #[cfg(feature = "hydrate")] if show_yes { // `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). let instance: StoredValue, LocalStorage> = 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 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 |_| { + if raster_ref.get().is_none() { + return; + } instance.set_value(Some(yes::RasterizedYes::new())); }); on_cleanup(move || { @@ -264,7 +278,7 @@ fn Hero(title: String, description: String, show_yes: bool) -> impl IntoView { view! { } })} @@ -502,7 +516,7 @@ fn AlternativeCard( prop:value=move || sig.get() on:input=move |ev| sig.set(event_target_value(&ev)) /> -
+
} .into_any(); diff --git a/style/main.css b/style/main.css index 83cd9b0..7f9c929 100644 --- a/style/main.css +++ b/style/main.css @@ -218,6 +218,59 @@ input:focus, textarea:focus { 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 { margin-top: 1.4rem; font-family: var(--sans);