Deploy / deploy (push) Successful in 1m15s
Only the :root font-size definition itself stays absolute - it is the base everything derives from. Embed-card inline styles in the editor follow the same rule; already-stored embeds keep their old px inline styles until re-edited. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
326 lines
12 KiB
JavaScript
326 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}`,
|
|
],
|
|
['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()
|
|
}
|