// Glue for `type: prosekit` requirement fields (see AlternativeCard in // src/app.rs) - mounts a headless ProseKit (ProseMirror) rich-text // editor into a container element, then mirrors its rendered HTML into // a paired hidden on every change, firing a real `input` event // on it. That lets the Rust side reuse the exact same RwSignal/on:input // wiring every other requirement kind already goes through, instead of // needing its own submit-time collection path. // // Loaded straight from a CDN as an ES module (no bundler in this // project, same pattern as yes.js) - prosekit ships pre-bundled ESM, // and re-exports the ProseMirror packages it's built on under // `prosekit/pm/*`, so importing primitives (InputRule, etc.) from there // instead of a separate prosemirror-* package guarantees they're the // exact same module instance the editor itself uses. // // Least-verified part of this file (worth an actual hwatu pass after // deploy, not just trusting the docs): the exact shape `createEditor` // wants for `defaultContent` when seeding from an HTML string, and the // precise `definePasteHandler` callback signature. Everything else here // mirrors prosekit's own documented examples closely. // CSS is loaded via ensureStylesheet() (a tag) inside // mountEditor below, not a JS import - esm.sh serves these paths as a // redirect to a real text/css response, which a plain `import 'x.css'` // (no `with { type: 'css' }` assertion, which browsers don't uniformly // support yet) fails to load as a module and aborts this entire file's // evaluation. Confirmed the hard way: this is exactly what silently // broke the first deploy of this file - mountEditor never got defined, // so the visible editor container just stayed empty. import { defineBasicExtension } from 'https://esm.sh/prosekit/basic' import { createEditor, 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). const GITEA_REPO_URL_RE = /^https:\/\/project\.uhhm\.no\/([\w.-]+)\/([\w.-]+)\/?$/ // Loads each stylesheet once regardless of how many editors this page // mounts - tags, not a JS import, since plain `import 'x.css'` // isn't valid outside a bundler and esm.sh's CSS-as-JS support isn't // worth depending on here. function ensureStylesheet(href) { if (document.querySelector(`link[href="${href}"]`)) return const link = document.createElement('link') link.rel = 'stylesheet' link.href = href document.head.appendChild(link) } function defineGiteaRepoEmbed() { return defineNodeSpec({ name: 'giteaRepoEmbed', group: 'block', atom: true, selectable: true, attrs: { owner: { default: '' }, repo: { default: '' }, description: { default: '' }, url: { default: '' }, }, parseDOM: [ { tag: 'div[data-gitea-repo]', getAttrs: (dom) => ({ owner: dom.getAttribute('data-owner') || '', repo: dom.getAttribute('data-repo') || '', description: dom.getAttribute('data-description') || '', url: dom.getAttribute('data-url') || '', }), }, ], // Plain inline styles, not a class - this same markup is what ends // up as the newsletter's HTML body, read by an email client with no // access to this app's stylesheet. toDOM: (node) => { const { owner, repo, description, url } = node.attrs return [ 'div', { 'data-gitea-repo': 'true', 'data-owner': owner, 'data-repo': repo, 'data-description': description, 'data-url': url, style: 'display:block;border:0.06em solid #ddd;border-radius:0.5em;' + 'padding:0.75em 1em;margin:0.5em 0;font-family:inherit;', }, [ 'a', { href: url, style: 'font-weight:600;text-decoration:none;color:inherit;' }, `${owner}/${repo}`, ], ['div', { style: 'opacity:0.7;font-size:0.9rem;margin-top:0.25em;' }, description], ] }, }) } // Resolves owner/repo via portal's own /gitea-repo handler // (content::gitea_repo_handler in src/content.rs), not Gitea's API // directly - keeps this editor from needing a CORS allowance on // Gitea's side, and matches every other backing store in this app // being server-resolved only. async function resolveGiteaRepo(owner, repo) { const res = await fetch( `/gitea-repo?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`, ) if (!res.ok) { throw new Error(`resolve_gitea_repo failed with status ${res.status}`) } 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() const match = text && text.match(GITEA_REPO_URL_RE) if (!match) return false event.preventDefault() const [, owner, repo] = match 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') const extension = union( defineBasicExtension(), defineGiteaRepoEmbed(), defineGiteaRepoEmbedPasteRule(), ) const editor = createEditor({ extension, // Seeds from the hidden input's current value (only ever non-empty // when re-mounting content that already went through this same // editor, since that's the only place this HTML shape comes from). 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 hiddenInput.value = html hiddenInput.dispatchEvent(new Event('input', { bubbles: true })) } editor.use( defineUpdateHandler(() => { sync() updateActiveStates() }), ) editor.mount(editorMount) sync() updateActiveStates() }