2026-08-05 13:45:56 +02:00
|
|
|
// 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.
|
|
|
|
|
|
2026-08-05 14:23:46 +02:00
|
|
|
// 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.
|
2026-08-05 13:45:56 +02:00
|
|
|
import { defineBasicExtension } from 'https://esm.sh/prosekit/basic'
|
|
|
|
|
import {
|
|
|
|
|
createEditor,
|
|
|
|
|
defineNodeSpec,
|
|
|
|
|
definePasteHandler,
|
|
|
|
|
defineUpdateHandler,
|
|
|
|
|
union,
|
|
|
|
|
} from 'https://esm.sh/prosekit/core'
|
|
|
|
|
|
|
|
|
|
// 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:1px solid #ddd;border-radius:8px;' +
|
|
|
|
|
'padding:12px 16px;margin:8px 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.9em;margin-top:4px;' }, 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()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
return true
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 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))
|
|
|
|
|
editor.mount(container)
|
|
|
|
|
sync()
|
|
|
|
|
}
|