170 lines
5.9 KiB
JavaScript
170 lines
5.9 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.
|
||
|
|
|
||
|
|
import 'https://esm.sh/prosekit/basic/style.css'
|
||
|
|
import 'https://esm.sh/prosekit/basic/typography.css'
|
||
|
|
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()
|
||
|
|
}
|