feat(ui): redesign public reading and complete glass workspace
Add scoped public navigation, reading progress, focus and print controls without loading the editor. Polish responsive settings, graph and media layouts. Correct share expiry units, preserve math and code during link maintenance, and normalize Markdown destinations for templates and note navigation. Verify browser flows and document the new tools.
This commit is contained in:
1 parent
91a359c2b7
commit
4d1339851b
28 files changed
+472
-66
No files matched your search
+135
-15
@@ -1,4 +1,7 @@
|
||||
// 只读分享页(/s/{token}):取回笔记内容,用与编辑器同一套解析器渲染为 HTML。不加载编辑器
|
||||
import { copyText } from '../clipboard'
|
||||
import { readingStats } from '../app/reading'
|
||||
import { applyDisplaySettings, sanitizeDisplaySettings } from '../themes/typography'
|
||||
import { requestTokens, type TokenRun } from '../highlight/client'
|
||||
import { renderMarkdown } from '../markdown/html'
|
||||
import { hydrate, renderMermaid } from '../markdown/render'
|
||||
@@ -71,9 +74,7 @@ async function load() {
|
||||
const response = await fetch(api + '/appearance', { cache: 'no-store' })
|
||||
if (!response.ok) throw new Error('读取外观失败')
|
||||
const appearance = await response.json() as Record<string, unknown>
|
||||
const rootStyle = document.documentElement.style
|
||||
if (typeof appearance.fontSize === 'number') rootStyle.setProperty('--rm-font-size', appearance.fontSize + 'px')
|
||||
if (typeof appearance.contentWidth === 'number') rootStyle.setProperty('--rm-content-width', appearance.contentWidth ? appearance.contentWidth + 'px' : 'none')
|
||||
applyDisplaySettings(sanitizeDisplaySettings(appearance))
|
||||
applyAppearance(sanitizeAppearance(appearance), typeof appearance.themeCSS === 'string' ? appearance.themeCSS : '')
|
||||
} catch {
|
||||
appearanceError = true
|
||||
@@ -169,32 +170,144 @@ function renderDirectory(directory: SharedDirectory) {
|
||||
li.append(Object.assign(document.createElement('a'), { href: pageUrl(path), textContent: entry.path.split('/').at(-1) + (entry.dir ? '/' : '') }))
|
||||
list.append(li)
|
||||
}
|
||||
root.replaceChildren(navigation(), heading, list)
|
||||
const listing = document.createElement('section')
|
||||
listing.className = 'rm-share-listing'
|
||||
listing.append(navigation(), heading, list)
|
||||
root.replaceChildren(publicHeader(document.title), listing)
|
||||
if (!directory.entries.length) root.append(Object.assign(document.createElement('p'), { textContent: '此文件夹为空' }))
|
||||
}
|
||||
|
||||
function action(text: string, title: string, run: () => void) {
|
||||
const button = Object.assign(document.createElement('button'), { type: 'button', textContent: text, title })
|
||||
button.addEventListener('click', run)
|
||||
return button
|
||||
}
|
||||
|
||||
function publicHeader(title: string) {
|
||||
const bar = document.createElement('header')
|
||||
bar.className = 'rm-public-header'
|
||||
const brand = Object.assign(document.createElement('a'), { className: 'rm-public-brand', href: pageUrl('', null), textContent: 'RawMark' })
|
||||
const badge = Object.assign(document.createElement('span'), { className: 'rm-public-badge', textContent: '只读知识库' })
|
||||
const current = Object.assign(document.createElement('span'), { className: 'rm-public-current', textContent: title })
|
||||
const controls = document.createElement('div')
|
||||
controls.className = 'rm-public-actions'
|
||||
const toc = action('大纲', '显示 / 隐藏文章大纲', () => { const open = root.classList.toggle('toc-open'); toc.setAttribute('aria-expanded', String(open)) })
|
||||
toc.className = 'rm-reading-action rm-toc-toggle'
|
||||
toc.setAttribute('aria-expanded', 'false')
|
||||
const focus = action('专注', '专注阅读', () => { const on = root.classList.toggle('rm-share-focus'); focus.setAttribute('aria-pressed', String(on)); focus.textContent = on ? '退出专注' : '专注' })
|
||||
focus.className = 'rm-reading-action'
|
||||
focus.setAttribute('aria-pressed', 'false')
|
||||
const copy = action('复制链接', '复制当前分享链接', () => { void copyText(location.href).then(ok => { copy.textContent = ok ? '已复制' : '复制失败' }).catch(() => { copy.textContent = '复制失败' }) })
|
||||
controls.append(toc, focus, copy, action('打印', '打印文章', () => window.print()))
|
||||
bar.append(brand, badge, current, controls)
|
||||
return bar
|
||||
}
|
||||
|
||||
let stopReading: (() => void) | undefined
|
||||
function render(note: SharedNote) {
|
||||
stopReading?.()
|
||||
document.title = note.title
|
||||
const aside = document.createElement('aside')
|
||||
aside.className = 'rm-share-sidebar'
|
||||
const header = document.createElement('header')
|
||||
header.className = 'rm-share-header'
|
||||
header.append(
|
||||
Object.assign(document.createElement('span'), { className: 'rm-share-title', textContent: note.title }),
|
||||
Object.assign(document.createElement('span'), { className: 'rm-share-time', textContent: '更新于 ' + dateFormat.format(note.updatedAt) }),
|
||||
)
|
||||
const article = document.createElement('article')
|
||||
article.className = 'rm-share-content'
|
||||
// 安全模式:原始 HTML 被转义,危险协议的链接被过滤
|
||||
// 安全模式:原始 HTML 被转义,危险协议的链接被过滤。
|
||||
article.innerHTML = renderMarkdown(note.content)
|
||||
root.replaceChildren(...(selectedPath || selectedItem !== null ? [navigation()] : []), header, article)
|
||||
|
||||
const headings = buildToc(article)
|
||||
const first = article.firstElementChild
|
||||
const title = first?.tagName === 'H1' ? first.textContent ?? note.title : note.title.replace(/\.md$/, '')
|
||||
if (first?.tagName === 'H1') first.remove()
|
||||
const stats = readingStats(article.textContent ?? '')
|
||||
header.append(
|
||||
Object.assign(document.createElement('span'), { className: 'rm-share-kicker', textContent: 'PUBLIC NOTE · 公开分享' }),
|
||||
first?.tagName === 'H1' ? first : Object.assign(document.createElement('h1'), { textContent: title }),
|
||||
Object.assign(document.createElement('p'), { className: 'rm-share-time', textContent: `更新于 ${dateFormat.format(note.updatedAt)} · ${stats.words.toLocaleString('zh-CN')} 字词 · 预计阅读 ${stats.minutes} 分钟` }),
|
||||
)
|
||||
const surface = document.createElement('div')
|
||||
surface.className = 'rm-reading-card'
|
||||
const footer = Object.assign(document.createElement('footer'), { className: 'rm-reading-footer', textContent: '由 RawMark 分享 · 内容来自源文件的最新版本' })
|
||||
surface.append(header, article, footer)
|
||||
const layout = document.createElement('div')
|
||||
layout.className = 'rm-reading-layout'
|
||||
layout.append(aside, surface)
|
||||
root.replaceChildren(publicHeader(title), ...(selectedPath || selectedItem !== null ? [navigation()] : []), layout)
|
||||
const backdrop = action('', '关闭文章大纲', () => root.classList.remove('toc-open'))
|
||||
backdrop.className = 'rm-share-sidebar-backdrop'
|
||||
root.append(backdrop)
|
||||
resolveImages(article)
|
||||
resolveLinks(article)
|
||||
buildToc(article)
|
||||
resolveLinks(header)
|
||||
const toc = document.createElement('nav')
|
||||
toc.className = 'rm-public-toc'
|
||||
toc.setAttribute('aria-label', '文章大纲')
|
||||
toc.append(Object.assign(document.createElement('h2'), { textContent: '文章大纲' }))
|
||||
const min = Math.min(...headings.map(h => Number(h.tagName[1])))
|
||||
for (const heading of headings) {
|
||||
const link = Object.assign(document.createElement('a'), { href: '#' + heading.id, textContent: heading.textContent })
|
||||
link.style.paddingLeft = (Number(heading.tagName[1]) - min) * 14 + 12 + 'px'
|
||||
link.addEventListener('click', () => root.classList.remove('toc-open'))
|
||||
toc.append(link)
|
||||
}
|
||||
if (!headings.length) toc.append(Object.assign(document.createElement('p'), { textContent: '此文章没有小标题' }))
|
||||
const progressLabel = Object.assign(document.createElement('div'), { className: 'rm-reading-progress-label' })
|
||||
const progress = document.createElement('progress'); progress.max = 100; progress.value = 0
|
||||
progress.setAttribute('aria-label', '阅读进度')
|
||||
toc.append(progressLabel, progress)
|
||||
aside.append(toc)
|
||||
const hint = Object.assign(document.createElement('p'), { className: 'rm-reading-hint', textContent: '当前内容为只读分享,修改由知识库作者在源文件中完成。' })
|
||||
aside.append(hint)
|
||||
void sharedNavigation(aside)
|
||||
hydrate(article)
|
||||
for (const code of article.querySelectorAll<HTMLElement>('pre > code')) void decorateCode(code).catch((error) => {
|
||||
for (const code of article.querySelectorAll<HTMLElement>('pre > code')) void decorateCode(code).catch(error => {
|
||||
code.title = '代码高亮失败,已保留源码'
|
||||
console.error('分享页代码高亮失败', error)
|
||||
})
|
||||
let frame = 0
|
||||
function update() {
|
||||
frame = 0
|
||||
const rect = surface.getBoundingClientRect()
|
||||
const value = Math.round(Math.max(0, Math.min(100, (innerHeight - rect.top) / Math.max(1, rect.height) * 100)))
|
||||
progress.value = value
|
||||
progressLabel.textContent = '阅读进度 · ' + value + '%'
|
||||
let active = headings[0]
|
||||
for (const heading of headings) { if (heading.getBoundingClientRect().top < 160) active = heading }
|
||||
for (const link of toc.querySelectorAll('a')) { const on = link.getAttribute('href') === '#' + active?.id; link.classList.toggle('on', on); if (on) link.setAttribute('aria-current', 'location'); else link.removeAttribute('aria-current') }
|
||||
}
|
||||
const schedule = () => { if (!frame) frame = requestAnimationFrame(update) }
|
||||
window.addEventListener('scroll', schedule, { passive: true })
|
||||
window.addEventListener('resize', schedule)
|
||||
const observer = new ResizeObserver(schedule)
|
||||
observer.observe(surface)
|
||||
stopReading = () => { observer.disconnect(); window.removeEventListener('scroll', schedule); window.removeEventListener('resize', schedule); cancelAnimationFrame(frame) }
|
||||
schedule()
|
||||
if (location.hash) {
|
||||
try { const id = decodeURIComponent(location.hash.slice(1)); requestAnimationFrame(() => document.getElementById(id)?.scrollIntoView()) }
|
||||
catch { root.append(Object.assign(document.createElement('p'), { className: 'rm-share-error', textContent: '链接锚点编码无效' })) }
|
||||
}
|
||||
}
|
||||
|
||||
// 目录导航只读取当前分享主体,不请求登录后的仓库元数据。
|
||||
async function sharedNavigation(aside: HTMLElement) {
|
||||
if (!selectedPath) return
|
||||
try {
|
||||
const response = await fetch(api + (selectedItem !== null ? '?item=' + encodeURIComponent(selectedItem) : ''), { cache: 'no-store' })
|
||||
if (!response.ok) throw new Error(`读取分享目录失败(${response.status})`)
|
||||
const directory = await response.json() as SharedNote | SharedDirectory
|
||||
if (!directory.dir) return
|
||||
const nav = document.createElement('nav')
|
||||
nav.className = 'rm-public-file-nav'
|
||||
nav.setAttribute('aria-label', '分享目录')
|
||||
nav.append(Object.assign(document.createElement('h2'), { textContent: directory.title }))
|
||||
for (const entry of directory.entries) {
|
||||
if (entry.path.split('/').some(part => part === 'assets' || part.endsWith('.assets')) || !entry.dir && !entry.path.endsWith('.md')) continue
|
||||
const link = Object.assign(document.createElement('a'), { href: pageUrl(entry.path), textContent: entry.path.split('/').at(-1) + (entry.dir ? '/' : '') })
|
||||
if (entry.path === selectedPath) link.className = 'on'
|
||||
nav.append(link)
|
||||
}
|
||||
aside.prepend(nav)
|
||||
} catch (error) { aside.append(Object.assign(document.createElement('p'), { className: 'rm-share-error', textContent: error instanceof Error ? error.message : '分享目录读取失败' })) }
|
||||
}
|
||||
|
||||
// 相对引用始终经当前分享读取,范围由服务器按分享主体校验。
|
||||
@@ -247,7 +360,6 @@ function buildToc(article: HTMLElement) {
|
||||
h.id = id
|
||||
}
|
||||
const navs = article.querySelectorAll('nav.toc')
|
||||
if (!navs.length) return
|
||||
const top = Math.min(...headings.map((h) => Number(h.tagName[1])))
|
||||
const list = document.createElement('ul')
|
||||
for (const h of headings) {
|
||||
@@ -257,11 +369,11 @@ function buildToc(article: HTMLElement) {
|
||||
list.append(li)
|
||||
}
|
||||
for (const nav of navs) nav.replaceChildren(list.cloneNode(true))
|
||||
return headings
|
||||
}
|
||||
|
||||
async function decorateCode(code: HTMLElement) {
|
||||
const lang = /language-(\S+)/.exec(code.className)?.[1]?.toLowerCase()
|
||||
if (!lang) return
|
||||
const text = (code.textContent ?? '').replace(/\n$/, '')
|
||||
if (lang === 'mermaid') {
|
||||
const figure = document.createElement('div')
|
||||
@@ -275,6 +387,12 @@ async function decorateCode(code: HTMLElement) {
|
||||
}
|
||||
return
|
||||
}
|
||||
const pre = code.parentElement!
|
||||
const tools = document.createElement('div'); tools.className = 'rm-public-code-tools'
|
||||
const copy = action('复制', '复制代码', () => { void copyText(text).then(ok => { copy.textContent = ok ? '已复制' : '复制失败' }).catch(() => { copy.textContent = '复制失败' }) })
|
||||
tools.append(Object.assign(document.createElement('span'), { textContent: lang || 'text' }), copy)
|
||||
pre.prepend(tools)
|
||||
if (!lang) return
|
||||
const run = await requestTokens(lang, text)
|
||||
if (!run) return
|
||||
const out = document.createDocumentFragment()
|
||||
@@ -300,4 +418,6 @@ window.addEventListener('rawmark-appearance', () => {
|
||||
}
|
||||
})
|
||||
|
||||
window.addEventListener('keydown', event => { if (event.key === 'Escape') root.classList.remove('toc-open') })
|
||||
|
||||
void load()
|
||||
Reference in new issue
Block a user