Files
portal/prosekit-editor.js
Bendik Aagaard LynghaugandClaude Fable 5 5b3ffa62e8
Deploy / deploy (push) Successful in 1m16s
Editor type scales for real: drop prosekit typography.css, fix selectors
The earlier rem override never matched: editor.mount() turns the
.prosekit-editor div itself into the .ProseMirror root, so
'.prosekit-editor .ProseMirror p' selected nothing and prosekit
typography.css's fixed 16px kept winning. Verified by mounting the
editor standalone and reading the mount div's classes.

Rather than out-specificity a stylesheet we don't control, stop
loading it: prosekit's typography.css is optional px-based sugar. Our
own rem scale (now selected as direct children of .prosekit-editor)
plus UA em defaults for lists/blockquote/code is the whole editor
typography, all tracking the responsive root.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 23:16:16 +02:00

331 lines
12 KiB
JavaScript

// 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 <input> 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 <link> 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/<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).
const GITEA_REPO_URL_RE = /^https:\/\/project\.uhhm\.no\/([\w.-]+)\/([\w.-]+)\/?$/
// Loads each stylesheet once regardless of how many editors this page
// mounts - <link> 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}`,
],
// Solid mid-gray, not opacity - dim on both the newsletter's
// light client background and this app's dark paper.
['div', { style: 'color:#9a9a9a;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) {
// Only the structural stylesheet (placeholder, lists, gap cursor).
// Deliberately NOT prosekit/basic/typography.css: it sizes content in
// fixed px, which fights the app's responsive root font-size. All
// editor typography lives in our own stylesheet, in rem.
ensureStylesheet('https://esm.sh/prosekit/basic/style.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()
}