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
@@ -7,7 +7,7 @@ import { createNote, ws } from './workspace'
|
||||
<header class="rm-global-header">
|
||||
<button class="rm-wordmark" @click="ws.settings = null; ws.images = null; ws.view = 'notes'"><Icon name="edit" :size="22" /><span>RawMark</span></button>
|
||||
<span class="rm-edition">个人知识库</span>
|
||||
<nav class="rm-primary-nav" aria-label="工作区"><button :class="{ on: ws.view === 'notes' && ws.images === null }" @click="ws.view = 'notes'; ws.images = null"><Icon name="folder" />笔记</button><button :class="{ on: ws.view === 'graph' }" @click="ws.view = 'graph'; ws.images = null"><Icon name="graph" />图谱</button><button :class="{ on: ws.images !== null }" @click="ws.imagesAll = true; ws.images = ''"><Icon name="image" />图片库</button></nav>
|
||||
<nav class="rm-primary-nav" aria-label="工作区"><button :class="{ on: ws.view === 'notes' && ws.images === null }" @click="ws.view = 'notes'; ws.images = null"><Icon name="folder" />笔记</button><button :class="{ on: ws.view === 'graph' && ws.images === null }" @click="ws.view = 'graph'; ws.images = null"><Icon name="graph" />图谱</button><button :class="{ on: ws.images !== null }" @click="ws.imagesAll = true; ws.images = ''"><Icon name="image" />图片库</button></nav>
|
||||
<button class="rm-global-search" @click="ws.switcher = ''"><Icon name="search" /><span>全局检索知识库</span><kbd>⌘ / Ctrl P</kbd></button>
|
||||
<div class="rm-header-actions">
|
||||
<button class="rm-icon-btn" title="设置" @click="ws.settings = 'index'"><Icon name="settings" /></button>
|
||||
|
||||
@@ -75,6 +75,7 @@ async function reset() {
|
||||
<template>
|
||||
<p v-if="appearancePaused" class="rm-appearance-note">已临时停用主题和 CSS。可恢复默认外观后重新打开页面。</p>
|
||||
<div class="rm-theme-cards"><button v-for="mode in [{ id: 'light', title: '纯净浅色', subtitle: 'Serene · 日间写作' }, { id: 'dark', title: '沉静深色', subtitle: '沿用你的深色主题' }, { id: 'system', title: '跟随系统', subtitle: '随系统明暗切换' }]" :key="mode.id" :class="[mode.id, { on: (fixedMode ?? settings.colorMode) === mode.id }]" :disabled="busy" @click="change({ theme: 'default', colorMode: mode.id as Settings['colorMode'] })"><span class="rm-theme-sample"><i /><i /><i /></span><strong>{{ mode.title }}</strong><small>{{ mode.subtitle }}</small></button></div>
|
||||
<details class="rm-appearance-advanced"><summary>自定义主题与 CSS 片段</summary>
|
||||
<div class="rm-setting">
|
||||
<label for="rm-color-mode" class="rm-setting-label">明暗模式</label>
|
||||
<select id="rm-color-mode" :value="fixedMode ?? settings.colorMode" :disabled="busy || !!fixedMode" @change="change({ colorMode: ($event.target as HTMLSelectElement).value as Settings['colorMode'] })">
|
||||
@@ -105,4 +106,5 @@ async function reset() {
|
||||
</form>
|
||||
<div class="rm-setting"><span class="rm-setting-label">默认外观</span><button class="rm-btn" :disabled="busy" @click="reset">恢复默认外观</button></div>
|
||||
<p class="rm-appearance-note">若自定义样式导致界面无法操作,可在地址末尾加 <code>?reset-appearance</code>,临时停用后再恢复默认。</p>
|
||||
</details>
|
||||
</template>
|
||||
@@ -48,14 +48,17 @@ function show() {
|
||||
onMounted(() => {
|
||||
view = createEditorView(host.value!, EditorState.create())
|
||||
attachView(view)
|
||||
document.fonts.addEventListener('loadingdone', measure)
|
||||
show()
|
||||
})
|
||||
function measure() { view.requestMeasure() }
|
||||
onBeforeUnmount(() => {
|
||||
document.fonts.removeEventListener('loadingdone', measure)
|
||||
if (shown) storeViewState(shown.path, view.state)
|
||||
attachView(null)
|
||||
view.destroy()
|
||||
})
|
||||
watch(() => [ws.active, tab.value?.status], show, { flush: 'post' })
|
||||
watch(() => [ws.active, tab.value?.status, ws.view, ws.images], () => { show(); requestAnimationFrame(() => view.requestMeasure()) }, { flush: 'post' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -38,7 +38,7 @@ const references = computed(() => {
|
||||
})
|
||||
const certain = computed(() => metadata.ready && !metadata.error && !metadata.notes.some(note => note.warnings.length))
|
||||
function shared(path: string) {
|
||||
return shares.value.some(share => (!share.expiresAt || share.expiresAt * 1000 > Date.now()) && share.items.some(item => item.exists && (path === item.path || path.startsWith(item.path + '/') || item.path.endsWith('.md') && path.startsWith(item.path.slice(0,-3) + '.assets/'))))
|
||||
return shares.value.some(share => (!share.expiresAt || share.expiresAt > Date.now()) && share.items.some(item => item.exists && (path === item.path || path.startsWith(item.path + '/') || item.path.endsWith('.md') && path.startsWith(item.path.slice(0,-3) + '.assets/'))))
|
||||
}
|
||||
const images = computed(() => scopeImages.value.filter(entry => {
|
||||
const count = references.value.get(entry.path)?.length ?? 0
|
||||
@@ -88,7 +88,7 @@ async function clean() {
|
||||
</script>
|
||||
<template>
|
||||
<section class="rm-media-library" aria-label="图片库" @keydown.esc.stop="!busy && (ws.images = null)">
|
||||
<header class="rm-media-header"><div><small>ASSET LIBRARY</small><h1>图片与灵感资源</h1><p>{{ ws.imagesAll ? '整个知识库' : (dir || '根目录') + ' · 当前目录及直属附件' }} · {{ scopeImages.length }} 张图片 · {{ size(totalSize) }}</p></div><button class="rm-btn" :disabled="busy" @click="ws.images = null">返回笔记</button></header>
|
||||
<header class="rm-media-header"><div><small>ASSET LIBRARY</small><h1>图片与灵感资源</h1><p>{{ ws.imagesAll ? '整个知识库' : (dir || '根目录') + ' · 当前目录及直属附件' }} · {{ scopeImages.length }} 张图片 · {{ size(totalSize) }}</p></div><button class="rm-btn" :disabled="busy" @click="ws.images = null; ws.view = 'notes'">返回笔记</button></header>
|
||||
<div class="rm-media-mobile-filters"><select v-model="ws.imagesAll" aria-label="资源范围"><option :value="false">当前目录</option><option :value="true">整个知识库</option></select><select v-model="usage" aria-label="引用状态"><option value="all">全部图片</option><option value="used" :disabled="!certain">已被引用</option><option value="unused" :disabled="!certain">未被引用</option></select><button class="rm-btn" :disabled="busy || !certain || !sharesReady" @click="proposeCleanup">清理孤立图片</button></div>
|
||||
<div class="rm-media-workspace">
|
||||
<aside class="rm-media-filters"><span class="rm-media-section-label">资源范围</span><button :class="{ on: !ws.imagesAll }" @click="ws.imagesAll = false"><Icon name="folder" />{{ dir || '根目录' }}</button><button :class="{ on: ws.imagesAll }" @click="ws.imagesAll = true"><Icon name="image" />整个知识库</button><span class="rm-media-section-label">引用状态</span><button v-for="item in [{ id: 'all', label: '全部图片' }, { id: 'used', label: '已被引用' }, { id: 'unused', label: '未被引用' }]" :key="item.id" :class="{ on: usage === item.id }" :disabled="item.id !== 'all' && !certain" @click="usage = item.id">{{ item.label }}</button><span class="rm-media-section-label">整理资源</span><button :disabled="busy || !certain || !sharesReady" @click="proposeCleanup"><Icon name="trash" />清理孤立图片 <small>{{ orphans.length }}</small></button><p>清理前核对全库引用和有效分享,图片移入回收站,可恢复。</p></aside>
|
||||
|
||||
@@ -77,7 +77,7 @@ watch(() => ws.view, () => { query.value = ''; tag.value = '' })
|
||||
.rm-knowledge-panel { flex: 1; overflow: auto; padding: 36px 40px; }
|
||||
.rm-knowledge-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; } .rm-eyebrow { font-size: 9px; letter-spacing: .15em; color: var(--rm-accent); } h1 { font-family: var(--rm-font-heading); font-size: 28px; font-weight: 600; margin: 12px 0; } p { font-size: 12px; color: var(--rm-text-muted); }
|
||||
.rm-knowledge-tools { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 28px 0 20px; } label { display: flex; align-items: center; gap: 8px; font-size: 11px; } input[type=search], select { border: 1px solid var(--rm-divider); padding: 10px 12px; border-radius: 8px; color: var(--rm-text); background: var(--rm-active); font: inherit; font-size: 12px; }
|
||||
.rm-graph-canvas { position: relative; border: 1px solid var(--rm-divider); border-radius: 16px; background: radial-gradient(var(--rm-divider) 1px, transparent 1px) 0 0/20px 20px; overflow: hidden; } svg { width: 100%; height: min(55vh,600px); display: block; touch-action: none; cursor: grab; } line { stroke: var(--rm-divider); stroke-width: 1.5; } circle { fill: var(--rm-accent); opacity: .6; } .current circle { opacity: 1; stroke: var(--rm-selection); stroke-width: 10px; } g { cursor: pointer; } g:hover circle, g:focus circle { opacity: 1; } text { fill: var(--rm-text-muted); font-family: var(--rm-font-text); font-size: 10px; } .rm-graph-controls { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 10px; background: var(--rm-active); } .rm-graph-controls span { margin-right: auto; font-size: 10px; color: var(--rm-text-muted); } .rm-graph-empty { position: absolute; inset: 40% 0 auto; text-align: center; pointer-events: none; }
|
||||
.rm-graph-canvas { position: relative; border: 1px solid var(--rm-divider); border-radius: 16px; background: radial-gradient(var(--rm-divider) 1px, transparent 1px) 0 0/20px 20px; overflow: hidden; } .rm-graph-canvas > svg { width: 100%; height: min(55vh,600px); display: block; touch-action: none; cursor: grab; } line { stroke: var(--rm-divider); stroke-width: 1.5; } circle { fill: var(--rm-accent); opacity: .6; } .current circle { opacity: 1; stroke: var(--rm-selection); stroke-width: 10px; } g { cursor: pointer; } g:hover circle, g:focus circle { opacity: 1; } text { fill: var(--rm-text-muted); font-family: var(--rm-font-text); font-size: 10px; } .rm-graph-controls { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 10px; background: var(--rm-active); } .rm-graph-controls span { margin-right: auto; font-size: 10px; color: var(--rm-text-muted); } .rm-graph-empty { position: absolute; inset: 40% 0 auto; text-align: center; pointer-events: none; }
|
||||
.rm-missing-links { margin-top: 20px; font-size: 12px; color: var(--rm-text-muted); } .rm-missing-links button { display: block; padding: 8px; background: none; border: 0; cursor: pointer; text-align: left; }
|
||||
.rm-tags-cloud { display: flex; flex-wrap: wrap; gap: 10px; margin: 24px 0; } .rm-tags-cloud button { padding: 8px 12px; border: 1px solid var(--rm-divider); border-radius: 7px; background: var(--rm-active); font-size: 12px; cursor: pointer; } .rm-tags-cloud .on { background: var(--rm-selection); color: var(--rm-accent); } .rm-tag-results > button { display: flex; align-items: center; gap: 16px; width: 100%; padding: 18px 12px; border: 0; border-bottom: 1px solid var(--rm-divider); text-align: left; background: none; cursor: pointer; } strong { font-size: 13px; font-weight: 500; } small { display: block; margin-top: 5px; font-size: 10px; color: var(--rm-text-muted); } .rm-tag-results span { margin-left: auto; font-size: 10px; color: var(--rm-accent); } .rm-tag-results > button:hover { background: var(--rm-hover); }
|
||||
@media(max-width:767px) { .rm-knowledge-panel { padding: 24px 16px; } h1 { font-size: 23px; } .rm-knowledge-heading { align-items: flex-start; } .rm-knowledge-heading .rm-btn { font-size: 10px; } .rm-knowledge-tools input[type=search] { width: 140px; } .rm-tag-results span { display: none; } }
|
||||
|
||||
@@ -16,7 +16,7 @@ import { toast, ws, type SettingsSection } from './workspace'
|
||||
|
||||
const SECTIONS: { id: SettingsSection; title: string; icon: IconName }[] = [
|
||||
{ id: 'appearance', title: '常规与外观', icon: 'palette' },
|
||||
{ id: 'editor', title: '编辑与阅读排版', icon: 'edit' },
|
||||
{ id: 'editor', title: '编辑器与模板', icon: 'edit' },
|
||||
{ id: 'plugins', title: '插件', icon: 'command' },
|
||||
{ id: 'account', title: '账户', icon: 'user' },
|
||||
{ id: 'shortcuts', title: '快捷键', icon: 'command' },
|
||||
@@ -172,7 +172,7 @@ async function changePassword() {
|
||||
<div class="rm-setting"><label for="rm-daily-folder" class="rm-setting-label">日记目录</label><input id="rm-daily-folder" :value="settings.dailyFolder" placeholder="根目录" @change="changeFolder('dailyFolder', $event)" /></div>
|
||||
<div class="rm-setting"><label for="rm-templates-folder" class="rm-setting-label">模板目录</label><input id="rm-templates-folder" :value="settings.templatesFolder" @change="changeFolder('templatesFolder', $event)" /></div>
|
||||
<div class="rm-setting"><label for="rm-daily-template" class="rm-setting-label">日记模板</label><select id="rm-daily-template" :value="settings.dailyTemplate" @change="change({ dailyTemplate: ($event.target as HTMLSelectElement).value })"><option value="">空白笔记</option><option v-for="entry in dailyTemplates" :key="entry.path" :value="entry.path">{{ entry.path }}</option><option v-if="missingDailyTemplate" :value="settings.dailyTemplate">{{ settings.dailyTemplate }}(不存在)</option></select></div>
|
||||
<p class="rm-share-note">日记使用本地日期命名,已有日记直接打开。模板支持 {{ '{{date}}' }}、{{ '{{time}}' }}、{{ '{{title}}' }}。</p>
|
||||
<p class="rm-share-note">日记使用本地日期命名,已有日记直接打开。模板支持 <code v-pre>{{date}}</code>、<code v-pre>{{time}}</code>、<code v-pre>{{title}}</code>。</p>
|
||||
<p class="rm-share-note">设置保存在服务器上,所有设备共用。</p>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { copyText } from '../editor/widgets'
|
||||
import { copyText } from '../clipboard'
|
||||
import { api, ApiError, type Share } from './api'
|
||||
import Icon from './Icon.vue'
|
||||
import { toast } from './workspace'
|
||||
|
||||
@@ -20,7 +20,7 @@ async function create() {
|
||||
<div class="rm-dialog-backdrop" @click.self="!busy && (ws.templateOpen = false)" @keydown.esc.stop="!busy && (ws.templateOpen = false)">
|
||||
<section class="rm-dialog rm-template-dialog" role="dialog" aria-label="从模板新建">
|
||||
<div class="rm-dialog-head"><h2>从模板开始</h2><button class="rm-icon-btn" title="关闭" :disabled="busy" @click="ws.templateOpen = false"><Icon name="close" /></button></div>
|
||||
<p class="rm-share-note">模板是 {{ settings.templatesFolder }}/ 下的 Markdown 文件。支持 {{ '{{date}}' }}、{{ '{{time}}' }} 和 {{ '{{title}}' }},图片沿用原模板资源。</p>
|
||||
<p class="rm-share-note">模板是 {{ settings.templatesFolder }}/ 下的 Markdown 文件。支持 <code v-pre>{{date}}</code>、<code v-pre>{{time}}</code> 和 <code v-pre>{{title}}</code>,图片沿用原模板资源。</p>
|
||||
<form @submit.prevent="create">
|
||||
<label>选择模板<select v-model="selected"><option value="">空白笔记</option><option v-for="template in templates" :key="template.path" :value="template.path">{{ template.path }}</option></select></label>
|
||||
<label>笔记路径<input v-model="path" aria-label="笔记路径" required /></label>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { copyText } from '../editor/widgets'
|
||||
import { copyText } from '../clipboard'
|
||||
import { api, ApiError, type ApiToken } from './api'
|
||||
import Icon from './Icon.vue'
|
||||
import { toast } from './workspace'
|
||||
|
||||
+11
-1
@@ -585,7 +585,7 @@ button {
|
||||
.rm-outline-head { padding: 18px 14px; font-size: 11px; }
|
||||
.rm-outline-list { padding: 8px 12px; }
|
||||
.rm-outline-list li { padding-top: 9px; padding-bottom: 9px; font-size: 11px; }
|
||||
.rm-outline-list li.active { box-shadow: inset 3px 0 var(--rm-accent); }
|
||||
.rm-outline-list li.on { box-shadow: inset 3px 0 var(--rm-accent); }
|
||||
.rm-tabs { min-height: 40px; padding: 5px 12px 0; background: color-mix(in srgb, var(--rm-sidebar-bg) 45%, transparent); }
|
||||
.rm-tab { height: 34px; max-width: 240px; font-size: 11px; border: 1px solid transparent; border-radius: 7px 7px 0 0; }
|
||||
.rm-tab.active { background: var(--rm-active); color: var(--rm-accent-strong); border-color: var(--rm-divider); box-shadow: 0 2px 5px rgba(19,27,46,.03); }
|
||||
@@ -667,3 +667,13 @@ button {
|
||||
@media(max-width:767px) { .rm-format-bar { padding-bottom: env(safe-area-inset-bottom); } }
|
||||
|
||||
.rm-typography-preview { font-family: var(--rm-font-editor, var(--rm-font-text)); }
|
||||
.rm-shell { position: relative; }
|
||||
.rm-global-header { box-sizing: border-box; }
|
||||
.rm-dialog-backdrop:has(.rm-settings) { position: absolute; }
|
||||
.rm-settings-heading { flex: 1; }
|
||||
.rm-appearance-advanced { margin: 4px 0 16px; }
|
||||
.rm-appearance-advanced > summary { margin: 0 20px 12px; padding: 10px 0; cursor: pointer; font-size: 11px; color: var(--rm-text-muted); }
|
||||
@media(min-width:768px) { .rm-shell:has(.rm-media-library) .rm-sidebar { display: none; } }
|
||||
@media(min-width:768px) and (max-width:1000px) { .rm-global-search { display: none; } .rm-header-actions { margin-left: auto; } .rm-primary-nav { margin-left: auto; } }
|
||||
|
||||
.rm-setting select { height: auto; min-height: 36px; line-height: 1.4; }
|
||||
@@ -7,7 +7,9 @@ import { createNoteAt, openNote, toast, ws } from './workspace'
|
||||
export async function createFromTemplate(path: string, template: string) {
|
||||
if (ws.entries.some(entry => entry.path === path)) { toast('目标笔记已存在'); return false }
|
||||
const content = template ? renderTemplate((await api.readFile(template)).content, template, path) : ''
|
||||
return createNoteAt(path, content)
|
||||
const created = await createNoteAt(path, content)
|
||||
if (created) ws.sidebarOpen = false
|
||||
return created
|
||||
}
|
||||
|
||||
export async function openDailyNote() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 工作区状态与操作:目录树、标签页、保存队列、冲突处理、实时同步。
|
||||
// 文件操作一律先更新界面再请求服务器,失败时回滚,保证"操作即时生效"。
|
||||
import { decodeLinkDestination } from '../markdown/links'
|
||||
import { Annotation, EditorState, Transaction } from '@codemirror/state'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { nextTick, reactive, watch } from 'vue'
|
||||
@@ -1139,7 +1140,7 @@ export function stopWorkspace() {
|
||||
|
||||
|
||||
async function openMarkdownLink(note: string, href: string) {
|
||||
const decoded = href.replace(/\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/g, '$1')
|
||||
const decoded = decodeLinkDestination(href)
|
||||
let url: URL
|
||||
try { url = new URL(decoded, 'https://rawmark.local/' + encodePath(note)) }
|
||||
catch { toast('链接格式无效'); return }
|
||||
@@ -1148,7 +1149,9 @@ async function openMarkdownLink(note: string, href: string) {
|
||||
else toast('不支持的链接协议')
|
||||
return
|
||||
}
|
||||
const target = decodeURIComponent(url.pathname.slice(1))
|
||||
let target: string
|
||||
try { target = decodeURIComponent(url.pathname.slice(1)); decodeURIComponent(url.hash.slice(1)) }
|
||||
catch { toast('链接编码无效'); return }
|
||||
if (!ws.entries.some(e => e.path === target && !e.dir)) { toast('链接目标不存在:' + target); return }
|
||||
if (!target.endsWith('.md')) { window.open('/api/v1/raw/' + encodePath(target), '_blank', 'noopener'); return }
|
||||
await openNote(target)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// 写入剪贴板;Clipboard API 只在 HTTPS/localhost 可用,局域网 HTTP 访问时退回 execCommand
|
||||
export async function copyText(text: string): Promise<boolean> {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
// 权限被拒时继续尝试旧方法
|
||||
}
|
||||
}
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0'
|
||||
document.body.append(ta)
|
||||
ta.select()
|
||||
const ok = document.execCommand('copy')
|
||||
ta.remove()
|
||||
return ok
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { copyText } from '../clipboard'
|
||||
import { EditorView, WidgetType } from '@codemirror/view'
|
||||
import { emojiFor, renderMath } from '../markdown/render'
|
||||
import { mountContent } from '../plugins/mount'
|
||||
@@ -53,26 +54,6 @@ export class HorizontalRuleWidget extends WidgetType {
|
||||
}
|
||||
}
|
||||
|
||||
// 写入剪贴板;Clipboard API 只在 HTTPS/localhost 可用,局域网 HTTP 访问时退回 execCommand
|
||||
export async function copyText(text: string): Promise<boolean> {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
// 权限被拒时继续尝试旧方法
|
||||
}
|
||||
}
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0'
|
||||
document.body.append(ta)
|
||||
ta.select()
|
||||
const ok = document.execCommand('copy')
|
||||
ta.remove()
|
||||
return ok
|
||||
}
|
||||
|
||||
const COPY_ICON =
|
||||
'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'
|
||||
const CHECK_ICON =
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// 浏览器在呈现链接属性时会解码实体;源码操作也须按相同规则解析目标。
|
||||
export function decodeLinkDestination(raw: string): string {
|
||||
const field = document.createElement('textarea')
|
||||
return raw.replace(/\\([!-/:-@[-`{-~])/g, '$1').replace(/&(?:#\d{1,7}|#[xX][\da-fA-F]{1,6}|[A-Za-z][A-Za-z\d]{1,31});/g, entity => {
|
||||
field.innerHTML = entity
|
||||
return field.value
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { localDate, renderTemplate } from './template'
|
||||
|
||||
@@ -12,4 +13,9 @@ describe('Markdown 模板', () => {
|
||||
expect(result).toContain('[ref]: ../docs/guide.md#%E6%A0%87%E9%A2%98')
|
||||
expect(result).toContain('``\n```\n\n```')
|
||||
})
|
||||
it('链接实体和 Markdown 转义按真实文件名重定位', () => {
|
||||
const result = renderTemplate('[文档](a&b.md) [括号](a\\(1\\).md)', 'Templates/日记.md', 'Daily/今天.md')
|
||||
expect(result).toContain('[文档](../Templates/a%26b.md)')
|
||||
expect(result).toContain('[括号](../Templates/a(1).md)')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { markdownParser } from './html'
|
||||
import { decodeLinkDestination } from './links'
|
||||
|
||||
export function localDate(now = new Date()): string {
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
@@ -8,9 +9,9 @@ export function renderTemplate(content: string, template: string, target: string
|
||||
const changes: { from: number; to: number; value: string }[] = []
|
||||
markdownParser.parse(content).iterate({ enter(node) {
|
||||
if (node.name !== 'URL' || !['Link', 'Image', 'LinkReference'].includes(node.node.parent?.name ?? '')) return
|
||||
const raw = content.slice(node.from, node.to).replace(/^<|>$/g, '')
|
||||
const raw = decodeLinkDestination(content.slice(node.from, node.to).replace(/^<|>$/g, ''))
|
||||
if (/^(?:[a-z][a-z\d+.-]*:|\/|#)/i.test(raw)) return
|
||||
const url = new URL(raw.replace(/\\([!-/:-@[-`{-~])/g, '$1'), 'https://rawmark.local/' + template.split('/').map(encodeURIComponent).join('/'))
|
||||
const url = new URL(raw, 'https://rawmark.local/' + template.split('/').map(encodeURIComponent).join('/'))
|
||||
const parts = decodeURIComponent(url.pathname.slice(1)).split('/')
|
||||
const base = target.slice(0, Math.max(0, target.lastIndexOf('/'))).split('/').filter(Boolean)
|
||||
while (base.length && base[0] === parts[0]) { base.shift(); parts.shift() }
|
||||
|
||||
+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()
|
||||
@@ -114,3 +114,42 @@
|
||||
.rm-share { padding: 16px 16px 60px; }
|
||||
.rm-share-time { display: none; }
|
||||
}
|
||||
|
||||
/* 公开阅读工作台:展示当前分享范围,不加载编辑功能。 */
|
||||
html { scroll-padding-top: 88px; }
|
||||
body { background: radial-gradient(ellipse at 25% 0,var(--rm-selection),transparent 55%), var(--rm-bg); }
|
||||
.rm-share { max-width: none; padding: 100px 32px 72px; font-family: var(--rm-font-editor, var(--rm-font-text)); }
|
||||
.rm-public-header { position: fixed; inset: 0 0 auto; height: 56px; display: flex; align-items: center; gap: 20px; padding: 0 28px; z-index: 40; border-bottom: 1px solid var(--rm-divider); background: color-mix(in srgb,var(--rm-bg) calc(var(--rm-glass-opacity) * 100%),transparent); backdrop-filter: blur(20px); font-family: var(--rm-font-text); }
|
||||
.rm-public-brand { color: var(--rm-accent-strong); text-decoration: none; font: 600 24px var(--rm-font-heading); }
|
||||
.rm-public-badge { font-size: 10px; padding: 4px 8px; border: 1px solid var(--rm-divider); border-radius: 30px; color: var(--rm-text-muted); white-space: nowrap; }
|
||||
.rm-public-current { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; color: var(--rm-text-muted); }
|
||||
.rm-public-actions { display: flex; gap: 8px; margin-left: auto; }
|
||||
.rm-public-actions button { border: 1px solid transparent; border-radius: 7px; background: none; color: var(--rm-text-muted); padding: 7px 10px; font: 11px var(--rm-font-text); cursor: pointer; white-space: nowrap; }
|
||||
.rm-public-actions button:hover, .rm-public-actions button[aria-pressed=true] { background: var(--rm-hover); color: var(--rm-accent); } .rm-public-actions button:nth-last-child(2) { border-color: var(--rm-divider); }
|
||||
.rm-share > .rm-share-nav { max-width: 1100px; margin: -16px auto 24px; font: 10px var(--rm-font-text); }
|
||||
.rm-reading-layout { display: flex; align-items: flex-start; justify-content: center; gap: 32px; max-width: 1140px; margin: auto; }
|
||||
.rm-share-sidebar { position: sticky; top: 88px; width: 240px; flex: none; max-height: calc(100dvh - 120px); overflow: auto; font-family: var(--rm-font-text); }
|
||||
.rm-public-toc, .rm-public-file-nav { padding: 20px 14px; border: 1px solid var(--rm-divider); border-radius: 13px; background: color-mix(in srgb,var(--rm-sidebar-bg) calc(var(--rm-glass-opacity) * 100%),transparent); backdrop-filter: blur(18px); margin-bottom: 16px; }
|
||||
.rm-share-sidebar h2 { font-size: 12px; margin: 0 10px 16px; color: var(--rm-accent); font-weight: 600; }
|
||||
.rm-share-sidebar a { display: block; padding: 8px 12px; text-decoration: none; color: var(--rm-text-muted); font-size: 11px; line-height: 1.65; border-radius: 6px; overflow-wrap: anywhere; }
|
||||
.rm-share-sidebar a:hover { background: var(--rm-hover); } .rm-share-sidebar a.on { color: var(--rm-accent); background: var(--rm-active); box-shadow: inset 2px 0 var(--rm-accent); }
|
||||
.rm-reading-progress-label { font-size: 10px; color: var(--rm-text-muted); border-top: 1px solid var(--rm-divider); padding-top: 18px; margin-top: 20px; } progress { display: block; width: 100%; height: 5px; margin-top: 9px; border: 0; border-radius: 6px; overflow: hidden; accent-color: var(--rm-accent); } progress::-webkit-progress-bar { background: var(--rm-divider); } progress::-webkit-progress-value { background: var(--rm-accent); } progress::-moz-progress-bar { background: var(--rm-accent); }
|
||||
.rm-reading-hint { border: 1px solid var(--rm-divider); border-radius: 12px; padding: 18px; font-size: 10px; line-height: 1.9; color: var(--rm-text-muted); background: var(--rm-active); }
|
||||
.rm-reading-card { flex: 1; min-width: 0; max-width: var(--rm-content-width); padding: 40px 44px; box-sizing: border-box; border: 1px solid var(--rm-divider); border-radius: 18px; background: color-mix(in srgb,var(--rm-bg) 90%,transparent); box-shadow: 0 8px 40px rgba(19,27,46,.03); }
|
||||
.rm-share-header { display: block; padding-bottom: 28px; margin-bottom: 28px; border-bottom: 1px solid var(--rm-divider); }
|
||||
.rm-share-kicker { display: inline-block; font: 9px var(--rm-font-text); letter-spacing: .1em; color: var(--rm-accent); background: var(--rm-selection); padding: 5px 8px; border-radius: 5px; }
|
||||
.rm-share-header h1 { font: 600 2.125em/1.5 var(--rm-font-heading); color: var(--rm-text); margin: 20px 0 18px; }
|
||||
.rm-share-time { display: block; font: 10px/1.8 var(--rm-font-text); color: var(--rm-text-muted); margin: 0; }
|
||||
.rm-share-content :is(h1,h2,h3,h4,h5,h6) { font-family: var(--rm-font-heading); font-weight: 600; scroll-margin-top: 80px; } .rm-share-content h2 { font-size: 1.5em; line-height: 1.5; margin-top: 1.5em; } .rm-share-content p { margin: 0 0 var(--rm-paragraph-spacing); } .rm-share-content blockquote { border-left-color: var(--rm-accent); background: color-mix(in srgb,var(--rm-accent) 5%,transparent); border-radius: 0 8px 8px 0; padding: 16px 20px; } .rm-share-content blockquote p:last-child { margin-bottom: 0; }
|
||||
.rm-share-content pre { position: relative; border: 1px solid var(--rm-divider); border-radius: 12px; padding: 42px 20px 20px; }
|
||||
.rm-public-code-tools { position: absolute; inset: 8px 12px auto; display: flex; justify-content: space-between; align-items: center; font: 10px var(--rm-font-mono); color: var(--rm-text-muted); user-select: none; }
|
||||
.rm-public-code-tools button { color: inherit; font: inherit; background: none; border: 0; padding: 4px 8px; border-radius: 5px; cursor: pointer; } .rm-public-code-tools button:hover { background: var(--rm-hover); color: var(--rm-accent); }
|
||||
.rm-reading-footer { margin-top: 40px; border-top: 1px solid var(--rm-divider); padding-top: 24px; color: var(--rm-text-muted); font: 10px var(--rm-font-text); text-align: center; }
|
||||
.rm-share-sidebar-backdrop { display: none; }
|
||||
.rm-share-focus .rm-share-sidebar { display: none; } .rm-share-focus .rm-reading-layout { max-width: 960px; } .rm-share-focus .rm-reading-card { box-shadow: none; }
|
||||
.rm-share-listing { max-width: 900px; margin: auto; border: 1px solid var(--rm-divider); border-radius: 18px; padding: 36px; background: var(--rm-active); } .rm-share-listing h1 { font: 600 28px var(--rm-font-heading); } .rm-share-listing .rm-share-nav { font-size: 11px; } .rm-share-files li { padding: 10px; border-bottom: 1px solid var(--rm-divider); font-size: 13px; } .rm-share-files a { text-decoration: none; } .rm-share:has(.rm-share-listing) .rm-reading-action { display: none; }
|
||||
.rm-share-password { max-width: 340px; padding: 32px; border: 1px solid var(--rm-divider); border-radius: 18px; background: var(--rm-active); box-shadow: var(--rm-shadow); font-family: var(--rm-font-text); }
|
||||
@media(min-width:1001px) { .rm-public-actions .rm-toc-toggle { display: none; } }
|
||||
@media(max-width:1000px) { .rm-share { padding: 84px 24px 48px; } .rm-share-sidebar { display: none; } .rm-public-current { display: none; } .toc-open .rm-share-sidebar { display: block; position: fixed; inset: 56px auto 0 0; width: 260px; max-height: none; z-index: 35; padding: 20px; background: var(--rm-bg); box-shadow: var(--rm-shadow); } .toc-open .rm-share-sidebar-backdrop { display: block; position: fixed; inset: 56px 0 0; border: 0; background: rgba(19,27,46,.2); z-index: 34; } }
|
||||
@media(max-width:600px) { .rm-share { padding: 76px 12px 40px; } .rm-public-header { height: 56px; padding: env(safe-area-inset-top) 12px 0; gap: 8px; } .rm-public-brand { font-size: 20px; } .rm-public-badge { display: none; } .rm-public-actions { gap: 0; } .rm-public-actions button { min-height: 44px; font-size: 10px; padding: 5px 7px; } .rm-reading-card { padding: 24px 20px; border-radius: 14px; } .rm-share-header h1 { font-size: 1.75em; } .rm-share-time { display: block; font-size: 9px; } .rm-share-content pre { padding: 42px 16px 16px; } .rm-public-code-tools { color: #b9c2d6; } .rm-share-listing { padding: 24px 16px; } }
|
||||
@media print { body { background: white; color: black; } .rm-share { padding: 0; } .rm-public-header, .rm-share-sidebar, .rm-share-nav, .rm-share-sidebar-backdrop, .rm-public-code-tools { display: none !important; } .rm-reading-layout { display: block; max-width: none; } .rm-reading-card, .rm-share-listing { border: 0; box-shadow: none; max-width: none; padding: 0; background: white; } .rm-share-content pre, .rm-share-content table { overflow: visible; } .rm-share-content pre { white-space: pre-wrap; padding: 16px; } a { color: inherit; } }
|
||||
Reference in new issue
Block a user