文章排版四件套(参考 xLog):盘古之白 + 表格圆角斑马纹 + 代码块封顶与复制按钮 + 标题悬停锚点
- PanguHTML:CJK 与拉丁/数字间补空格,标签原样保留、code/pre 内文本不动;详情/时间线短文/RSS 描述三个出口生效(带单测) - 表格改圆角容器 + tbody 斑马纹 + 纵向大留白(classic/vivid 双主题) - pre max-height 30.5em 封顶内部滚动;enhanceProse 注入悬停复制按钮(App 全局委托),按下有 2px 物理感 - 带 id 的 h2-h4 悬停浮现 # 锚点,点击跳转 - 中文字体栈此前已配(PingFang SC 等),无需改动
This commit is contained in:
+26
-1
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
import { computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import TopBar from './components/TopBar.vue'
|
||||
import ThemeSwitcher from './components/ThemeSwitcher.vue'
|
||||
@@ -9,6 +9,31 @@ import YohakuFoot from './ui/yohaku/YohakuFoot.vue'
|
||||
import { site, setAdminScope } from './site'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// 正文代码块复制按钮(事件委托:复制按钮是 enhanceProse 注入的静态 HTML)
|
||||
function onCopyClick(e) {
|
||||
const btn = e.target.closest?.('.pre-copy')
|
||||
if (!btn) return
|
||||
const pre = btn.closest('.pre-wrap')?.querySelector('pre')
|
||||
if (!pre) return
|
||||
navigator.clipboard
|
||||
.writeText(pre.innerText.replace(/\n$/, ''))
|
||||
.then(() => {
|
||||
btn.textContent = '已复制'
|
||||
btn.classList.add('done')
|
||||
setTimeout(() => {
|
||||
btn.textContent = '复制'
|
||||
btn.classList.remove('done')
|
||||
}, 1600)
|
||||
})
|
||||
.catch(() => {
|
||||
btn.textContent = '复制失败'
|
||||
setTimeout(() => (btn.textContent = '复制'), 1600)
|
||||
})
|
||||
}
|
||||
onMounted(() => document.addEventListener('click', onCopyClick))
|
||||
onBeforeUnmount(() => document.removeEventListener('click', onCopyClick))
|
||||
|
||||
const isAdmin = computed(() => route.path.startsWith('/admin'))
|
||||
// vivid 只在公开前台生效:后台永远走 classic 那套(--admin-* token + 原 data-ui/data-theme),
|
||||
// 不受 ui_id 影响。vivid.css 会命中 .btn / .input 这类全局类,后台表单正在用,
|
||||
|
||||
+48
-9
@@ -254,6 +254,17 @@ html.topbar-hidden {--topbar-h: 0px;}
|
||||
margin-top: 8px;
|
||||
background: var(--accent);}
|
||||
.prose h4 {font-size: 16.5px;}
|
||||
/* 悬停浮现的标题锚点(enhanceProse 注入,指向 heading id) */
|
||||
.prose .h-anchor {opacity: 0;
|
||||
margin-left: 8px;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72em;
|
||||
color: var(--faint);
|
||||
text-decoration: none;
|
||||
border-bottom: 0;
|
||||
transition: opacity .2s;}
|
||||
.prose h2:hover .h-anchor, .prose h3:hover .h-anchor, .prose h4:hover .h-anchor {opacity: 1;}
|
||||
.prose .h-anchor:hover {color: var(--accent);}
|
||||
.prose blockquote {margin: 1.4em 0;
|
||||
padding: 2px 0 2px 18px;
|
||||
border-left: 2px solid var(--accent-line);
|
||||
@@ -273,12 +284,32 @@ html.topbar-hidden {--topbar-h: 0px;}
|
||||
background: var(--paper-sunken);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;}
|
||||
.prose .pre-wrap {position: relative;}
|
||||
.pre-copy {position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
padding: 3px 10px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
color: inherit;
|
||||
background: color-mix(in srgb, var(--ink) 14%, transparent);
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
transition: opacity .15s, transform .08s;}
|
||||
.pre-copy:hover {opacity: 1;}
|
||||
.pre-wrap:hover .pre-copy {opacity: 0.85;}
|
||||
.pre-copy:active {transform: translateY(2px);}
|
||||
.pre-copy.done {opacity: 1;
|
||||
color: var(--accent);}
|
||||
.prose pre {background: var(--paper-sunken);
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 4px;
|
||||
padding: 14px 16px;
|
||||
overflow-x: auto;
|
||||
line-height: 1.7;}
|
||||
border-radius: 10px;
|
||||
padding: 16px 18px;
|
||||
overflow: auto;
|
||||
line-height: 1.7;
|
||||
max-height: 30.5em;}
|
||||
.prose pre code {background: none;
|
||||
padding: 0;
|
||||
font-size: 13.5px;}
|
||||
@@ -289,14 +320,22 @@ html.topbar-hidden {--topbar-h: 0px;}
|
||||
background: var(--line);
|
||||
margin: 2em 0;}
|
||||
.prose table {width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1.4em 0;
|
||||
font-size: 15px;}
|
||||
.prose th, .prose td {border: 1px solid var(--line);
|
||||
padding: 7px 10px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
margin: 1.6em 0;
|
||||
font-size: 14.5px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;}
|
||||
.prose th, .prose td {border: 0;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
padding: 11px 16px;
|
||||
text-align: left;}
|
||||
.prose tr > * + * {border-left: 1px solid var(--line-soft);}
|
||||
.prose tbody tr:last-child td {border-bottom: 0;}
|
||||
.prose th {background: var(--paper-sunken);
|
||||
font-weight: 600;}
|
||||
.prose tbody tr:nth-child(even) {background: color-mix(in srgb, var(--paper-sunken) 55%, transparent);}
|
||||
.prose-short {font-size: 20px;
|
||||
line-height: 1.9;}
|
||||
.prose-short > p:first-of-type::first-letter {float: none;
|
||||
|
||||
@@ -96,8 +96,12 @@ html[data-ui='vivid'] .prose code {font-family:var(--mono);font-size:.88em;
|
||||
padding:2px 7px;border-radius:6px;}
|
||||
html[data-ui='vivid'] .prose pre {background:color-mix(in srgb,var(--ink) 92%,var(--accent));
|
||||
color:#e8e8f0;padding:18px 20px;border-radius:12px;
|
||||
overflow-x:auto;font-size:13.5px;line-height:1.7;
|
||||
box-shadow:var(--shadow-lg);}
|
||||
overflow:auto;font-size:13.5px;line-height:1.7;
|
||||
box-shadow:var(--shadow-lg);
|
||||
max-height:30.5em;}
|
||||
/* 复制按钮(vivid 配色:浅字浮在深底上) */
|
||||
html[data-ui='vivid'] .pre-copy {color:#e8e8f0;
|
||||
background:rgba(255,255,255,.14);}
|
||||
/* 暗色下「92% ink」混出的是浅底,浅底配浅字没法读;暗端换深底微混强调色 */
|
||||
html[data-ui='vivid'][data-theme='vivid-dark'] .prose pre {
|
||||
background:color-mix(in srgb,var(--paper) 86%,var(--accent));
|
||||
@@ -215,3 +219,17 @@ html[data-ui='vivid'] .sr-only {position: absolute;
|
||||
white-space: nowrap;
|
||||
border: 0;}
|
||||
html[data-ui='vivid'] .toc a:active { color: var(--accent); }
|
||||
|
||||
/* ---- 表格(xLog 式:圆角容器 + 斑马纹,替代满格线) ---- */
|
||||
html[data-ui='vivid'] .prose table {width:100%;
|
||||
border-collapse:separate;border-spacing:0;
|
||||
margin:1.8em 0;font-size:14px;
|
||||
border:1px solid var(--line);border-radius:12px;overflow:hidden;}
|
||||
html[data-ui='vivid'] .prose th, html[data-ui='vivid'] .prose td {border:0;
|
||||
border-bottom:1px solid var(--line-soft);
|
||||
padding:12px 18px;text-align:left;}
|
||||
html[data-ui='vivid'] .prose tr > * + * {border-left:1px solid var(--line-soft);}
|
||||
html[data-ui='vivid'] .prose tbody tr:last-child td {border-bottom:0;}
|
||||
html[data-ui='vivid'] .prose th {background:var(--accent-soft);font-weight:600;}
|
||||
html[data-ui='vivid'] .prose tbody tr:nth-child(even) {background:var(--card);}
|
||||
/* 标题锚点 + 复制按钮沿用 classic 的 .h-anchor/.pre-copy 基础样式 */
|
||||
|
||||
+21
-1
@@ -3,7 +3,7 @@ import DOMPurify from 'dompurify'
|
||||
// 后端 goldmark 已转义原始 HTML,这里再过一道 DOMPurify 作纵深防御,
|
||||
// 所有 v-html 出口必须经过它。
|
||||
export function sanitizeHtml(html) {
|
||||
return DOMPurify.sanitize(html || '')
|
||||
return enhanceProse(DOMPurify.sanitize(html || ''))
|
||||
}
|
||||
|
||||
// ---------- 缩略图 ----------
|
||||
@@ -28,6 +28,26 @@ export function thumbURL(u, w = 480) {
|
||||
return '/uploads/thumb/' + key + '?w=' + w
|
||||
}
|
||||
|
||||
// ---------- xLog 式排版增强 ----------
|
||||
// 对消毒后的正文 HTML 做三件展示层增强(markdown/库里内容不动):
|
||||
// 1) pre 包一层容器并注入悬停复制按钮;
|
||||
// 2) 带 id 的标题(withHeadingIds 生成)尾部加 # 锚点;
|
||||
// 3) (盘古之白在后端渲染出口做,见 backend/internal/render/pangu.go)
|
||||
export function enhanceProse(html) {
|
||||
if (!html) return html
|
||||
// 复制按钮:包在 .pre-wrap 里,按钮悬浮于 pre 右上
|
||||
html = html
|
||||
.replace(/<pre\b/g, '<div class="pre-wrap"><button type="button" class="pre-copy" aria-label="复制代码">复制</button><pre')
|
||||
.replace(/<\/pre>/g, '</pre></div>')
|
||||
// 标题锚点:h2-h4 且有 id 的,末尾补一个 # 链接(悬停浮现)
|
||||
html = html.replace(
|
||||
/<h([2-4])([^>]*\bid="([^"]+)"[^>]*)>([\s\S]*?)<\/h\1>/g,
|
||||
(m, lvl, attrs, id, inner) =>
|
||||
`<h${lvl}${attrs}>${inner}<a class="h-anchor" href="#${id}" aria-label="标题链接">#</a></h${lvl}>`
|
||||
)
|
||||
return html
|
||||
}
|
||||
|
||||
// 编辑器拖拽缩放的比例展示:Milkdown 图片块把高度比例写进 markdown 的 alt
|
||||
// (),渲染出的 <img alt="0.6"> 在这里还原成对应的宽度并居中。
|
||||
// 纯数字 alt 才生效(>=1 的当 100% 处理),正常图片的描述性 alt 不受影响。
|
||||
|
||||
Reference in New Issue
Block a user