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:
eezd committed 2026-10-22 15:43:52 +08:00
1 parent 91a359c2b7
commit 4d1339851b
28 files changed
+472 -66

No files matched your search

+5 -2
View File
@@ -31,10 +31,13 @@ docker run -d --name rawmark \
- 首次打开直接设置登录密码,之后凭密码登录;请在将新实例开放给他人访问前完成初始化。 - 首次打开直接设置登录密码,之后凭密码登录;请在将新实例开放给他人访问前完成初始化。
- 笔记以普通 `.md` 文件保存在 `数据目录/vault/`,图片按 Typora 习惯存放在同级的 `笔记名.assets/`,可直接用 Typora 等编辑器打开;外部修改会实时同步到网页。 - 笔记以普通 `.md` 文件保存在 `数据目录/vault/`,图片按 Typora 习惯存放在同级的 `笔记名.assets/`,可直接用 Typora 等编辑器打开;外部修改会实时同步到网页。
- 图片管理:侧栏默认隐藏 `assets` 和 `笔记名.assets`;右键文件夹选择「图片管理」,查看当前目录及直属附件目录的图片,支持预览、重命名、移动、替换与移入回收站。根目录可在侧栏空白处右键或通过命令面板打开。图片改名/移动会更新笔记中的本地引用;替换保留路径,选择相同扩展名的图片(最大 50 MB),旧图片不进回收站。 - 知识工具:标准 Markdown 链接构成图谱和反向链接,支持正文 `#标签` 与 YAML `tags`。笔记或文件夹在应用内改名、移动时维护入链和相对出链;公式和代码示例不参与关系提取。
- 日记与模板:默认日记路径为 `Daily/YYYY-MM-DD.md`(本地日期),已有日记直接打开;模板为 `Templates/` 下的普通 Markdown,支持 `{{date}}`、`{{time}}`、`{{title}}`,资源链接按新笔记位置调整。可在设置中修改目录和日记模板。
- 外观:浅色玻璃工作台与深色模式,字体资源随应用本地提供;排版、玻璃透明度、选中文本工具栏均可在设置中调整并即时保存。
- 图片管理:侧栏默认隐藏 `assets` 和 `笔记名.assets`;右键文件夹选择「图片管理」,查看当前目录及直属附件目录的图片,支持检索、排序、网格/列表、批量上传、引用查看及预览、重命名、移动、替换。可切换整个知识库;清理孤立图片须确认候选,服务端再次核对全库引用与有效分享后移入回收站,可恢复。上传至所选目录的 `assets/`,相同内容自动去重。根目录可在侧栏空白处右键或通过命令面板打开。图片改名/移动会更新笔记中的本地引用;替换保留路径,选择相同扩展名的图片(最大 50 MB),旧图片不进回收站。
- `--user` 让容器以宿主机用户身份读写数据目录;不指定时容器以 UID 65532 运行,需要该用户对目录有写权限。 - `--user` 让容器以宿主机用户身份读写数据目录;不指定时容器以 UID 65532 运行,需要该用户对目录有写权限。
- 请通过 HTTPS 反向代理(Caddy、Nginx 等)访问,并转发 WebSocket(`/api/v1/events`)。 - 请通过 HTTPS 反向代理(Caddy、Nginx 等)访问,并转发 WebSocket(`/api/v1/events`)。
- 分享:文件或文件夹右键「分享」,也可通过命令面板(F1)分享当前笔记;多选生成一个合集链接。链接 `/s/…` 的标识完全随机,展示源文件最新内容并跟随分享主体移动;文件夹递归包含当前内容。可设密码与有效期,在设置中管理与撤销。 - 分享:文件或文件夹右键「分享」,也可通过命令面板(F1)分享当前笔记;多选生成一个合集链接。链接 `/s/…` 的标识完全随机,展示源文件最新内容并跟随分享主体移动;文件夹递归包含当前内容。可设密码与有效期,在设置中管理与撤销。公开阅读页仅提供分享范围内的导航、大纲、阅读进度、复制与打印,不加载编辑器。
- 多选与导出:Ctrl/Cmd 点击切换选择,Shift 点击选择可见范围;右键批量分享、删除、移动或导出 Markdown。单篇无附件直接下载 `.md`,其他情况下载包含 Markdown 与本地附件的 ZIP;重名目标不会被覆盖,批量操作会汇报单项失败。 - 多选与导出:Ctrl/Cmd 点击切换选择,Shift 点击选择可见范围;右键批量分享、删除、移动或导出 Markdown。单篇无附件直接下载 `.md`,其他情况下载包含 Markdown 与本地附件的 ZIP;重名目标不会被覆盖,批量操作会汇报单项失败。
- 快捷键:设置中支持添加、逐条移除和单项/全部重置,并拒绝冲突绑定。默认 Ctrl+Alt+1~6 设置对应级别标题,同级再次触发恢复正文;字号可直接输入。 - 快捷键:设置中支持添加、逐条移除和单项/全部重置,并拒绝冲突绑定。默认 Ctrl+Alt+1~6 设置对应级别标题,同级再次触发恢复正文;字号可直接输入。
- 导入:侧栏顶部或文件夹右键菜单选择「导入」,支持多选 UTF-8 `.md` 文件或单个 ZIP(总上传上限 100 MB)。ZIP 新建同名文件夹并保留层级,本地图片转换为笔记独立附件,原附件保留;同名内容不覆盖,缺失的本地链接会提示,外部图片不会自动下载。 - 导入:侧栏顶部或文件夹右键菜单选择「导入」,支持多选 UTF-8 `.md` 文件或单个 ZIP(总上传上限 100 MB)。ZIP 新建同名文件夹并保留层级,本地图片转换为笔记独立附件,原附件保留;同名内容不覆盖,缺失的本地链接会提示,外部图片不会自动下载。
+8 -1
View File
@@ -1,7 +1,9 @@
package api package api
import ( import (
"errors"
"net/http" "net/http"
"rawmark/internal/vault"
"time" "time"
) )
@@ -22,12 +24,17 @@ func (s *Server) cleanupImages(w http.ResponseWriter, r *http.Request) {
return nil, err return nil, err
} }
scopes := []string{} scopes := []string{}
now := time.Now().Unix() now := time.Now().UnixMilli()
for _, share := range shares { for _, share := range shares {
if share.ExpiresAt != 0 && share.ExpiresAt <= now { if share.ExpiresAt != 0 && share.ExpiresAt <= now {
continue continue
} }
for _, item := range share.Items { for _, item := range share.Items {
if _, err := s.vault.Stat(item.Path); errors.Is(err, vault.ErrNotFound) {
continue
} else if err != nil {
return nil, err
}
scopes = append(scopes, item.Path) scopes = append(scopes, item.Path)
} }
} }
+35
View File
@@ -0,0 +1,35 @@
package api
import (
"net/http"
"rawmark/internal/store"
"rawmark/internal/vault"
"testing"
"time"
)
func TestImageCleanupHonorsShareExpiryAndReferences(t *testing.T) {
f := newFixture(t)
f.write("assets/active.png", "active")
f.write("assets/expired.png", "expired")
f.write("assets/used.png", "used")
f.write("note.md", "[image](assets/used.png)")
for _, share := range []store.Share{
{Token: "active", Items: []store.ShareItem{{ID: 0, Path: "assets/active.png"}}, ExpiresAt: time.Now().Add(time.Hour).UnixMilli()},
{Token: "expired", Items: []store.ShareItem{{ID: 0, Path: "assets/expired.png"}}, ExpiresAt: time.Now().Add(-time.Hour).UnixMilli()},
} {
if err := f.store.CreateShare(share); err != nil {
t.Fatal(err)
}
}
if code := f.status(newClient(), "POST", "/api/v1/assets/cleanup", `{"paths":["assets/expired.png"]}`, nil); code != http.StatusUnauthorized {
t.Fatal(code)
}
result := decode[vault.CleanupResult](t, f.do(f.admin, "POST", "/api/v1/assets/cleanup", `{"paths":["assets/active.png","assets/expired.png","assets/used.png"]}`, nil))
if len(result.Deleted) != 1 || result.Deleted[0].Path != "assets/expired.png" || len(result.Skipped) != 2 {
t.Fatalf("引用或分享保护不符:%+v", result)
}
if code := f.status(newClient(), "GET", "/api/v1/metadata", "", nil); code != http.StatusUnauthorized {
t.Fatal(code)
}
}
+3 -2
View File
@@ -732,7 +732,7 @@
{ {
"name": "note", "name": "note",
"in": "query", "in": "query",
"required": true, "required": false,
"description": "所属笔记路径", "description": "所属笔记路径",
"schema": { "schema": {
"type": "string" "type": "string"
@@ -792,7 +792,8 @@
} }
} }
} }
} },
"description": "note 与 folder 必须且只能指定一个;note 上传到笔记附件目录,folder 上传到目录的 assets 子目录。"
} }
}, },
"/assets/import": { "/assets/import": {
+11
View File
@@ -95,7 +95,18 @@ func movedPath(p, from, to string, directory bool) string {
func rewriteMovedLinks(content []byte, oldNote, newNote, from, to string, directory, ownAssets bool) ([]byte, error) { func rewriteMovedLinks(content []byte, oldNote, newNote, from, to string, directory, ownAssets bool) ([]byte, error) {
var out []byte var out []byte
last := 0 last := 0
excluded := nonProseRanges(parseMetadata(content))
for _, span := range importLinkSpans(content) { for _, span := range importLinkSpans(content) {
skip := false
for _, region := range excluded {
if span.start >= region[0] && span.start < region[1] {
skip = true
break
}
}
if skip {
continue
}
if span.start < last { if span.start < last {
continue continue
} }
+25 -4
View File
@@ -7,7 +7,7 @@ import (
) )
// RewriteAssetLinks 在笔记改名后,把正文中指向旧图片目录(oldBase.assets/)的链接改为新目录。 // RewriteAssetLinks 在笔记改名后,把正文中指向旧图片目录(oldBase.assets/)的链接改为新目录。
// 覆盖行内链接/图片 `](…)`、引用定义 `[x]: …` 与 HTML `src="…"`,跳过代码块和行内代码。 // 覆盖行内链接/图片 `](…)`、引用定义 `[x]: …` 与 HTML `src="…"`,跳过代码与公式。
func RewriteAssetLinks(content []byte, oldBase, newBase string) []byte { func RewriteAssetLinks(content []byte, oldBase, newBase string) []byte {
oldRaw := regexp.QuoteMeta(oldBase + AssetsExt + "/") oldRaw := regexp.QuoteMeta(oldBase + AssetsExt + "/")
oldEsc := regexp.QuoteMeta(EscapeLinkPath(oldBase+AssetsExt) + "/") oldEsc := regexp.QuoteMeta(EscapeLinkPath(oldBase+AssetsExt) + "/")
@@ -16,9 +16,30 @@ func RewriteAssetLinks(content []byte, oldBase, newBase string) []byte {
refDef := regexp.MustCompile(`^( {0,3}\[[^\]]+\]:\s*<?)` + target) refDef := regexp.MustCompile(`^( {0,3}\[[^\]]+\]:\s*<?)` + target)
repl := []byte("${1}${2}" + strings.ReplaceAll(EscapeLinkPath(newBase+AssetsExt), "$", "$$") + "/") repl := []byte("${1}${2}" + strings.ReplaceAll(EscapeLinkPath(newBase+AssetsExt), "$", "$$") + "/")
return transformMarkdownLines(content, refDef, func(line, _ []byte, _ int) []byte { excluded := nonProseRanges(parseMetadata(content))
line = refDef.ReplaceAll(line, repl) replace := func(pattern *regexp.Regexp, source []byte, offset int) []byte {
return transformOutsideCode(line, func(text []byte, _ int) []byte { return inline.ReplaceAll(text, repl) }) var out []byte
last := 0
for _, match := range pattern.FindAllSubmatchIndex(source, -1) {
skip := false
for _, region := range excluded {
if offset+match[0] >= region[0] && offset+match[0] < region[1] {
skip = true
break
}
}
if skip {
continue
}
out = append(out, source[last:match[0]]...)
out = pattern.Expand(out, repl, source, match)
last = match[1]
}
return append(out, source[last:]...)
}
return transformMarkdownLines(content, refDef, func(line, _ []byte, offset int) []byte {
line = transformOutsideCode(line, func(text []byte, start int) []byte { return replace(inline, text, offset+start) })
return replace(refDef, line, offset)
}) })
} }
+16 -6
View File
@@ -13,7 +13,6 @@ import (
"unicode/utf8" "unicode/utf8"
"github.com/yuin/goldmark/v2/ast" "github.com/yuin/goldmark/v2/ast"
"github.com/yuin/goldmark/v2/parser"
"go.yaml.in/yaml/v3" "go.yaml.in/yaml/v3"
) )
@@ -111,7 +110,7 @@ func Analyze(note string, content []byte) Metadata {
} }
} }
} }
root := parser.New().Parse(source) root := parseMetadata(source)
_ = ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) { _ = ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering { if !entering {
return ast.WalkContinue, nil return ast.WalkContinue, nil
@@ -129,16 +128,16 @@ func Analyze(note string, content []byte) Metadata {
} }
} }
switch node := n.(type) { switch node := n.(type) {
case *ast.CodeBlock, *ast.CodeSpan, *ast.HTMLBlock, *ast.RawHTML, *ast.LinkReferenceDefinition: case *ast.AutoLink, *ast.CodeBlock, *ast.CodeSpan, *ast.HTMLBlock, *ast.RawHTML, *ast.LinkReferenceDefinition:
return ast.WalkSkipChildren, nil return ast.WalkSkipChildren, nil
case *ast.Link: case *ast.Link:
addRef(node.Destination.Value(source), node.Pos()) addRef(node.Destination.Str(source), node.Pos())
return ast.WalkSkipChildren, nil return ast.WalkSkipChildren, nil
case *ast.Image: case *ast.Image:
addRef(node.Destination.Value(source), node.Pos()) addRef(node.Destination.Str(source), node.Pos())
return ast.WalkSkipChildren, nil return ast.WalkSkipChildren, nil
case *ast.Text: case *ast.Text:
value := node.Value.Value(source) value := node.Value.Str(source)
for _, loc := range inlineTag.FindAllStringIndex(value, -1) { for _, loc := range inlineTag.FindAllStringIndex(value, -1) {
if loc[0] > 0 { if loc[0] > 0 {
prev, _ := utf8.DecodeLastRuneInString(value[:loc[0]]) prev, _ := utf8.DecodeLastRuneInString(value[:loc[0]])
@@ -152,10 +151,21 @@ func Analyze(note string, content []byte) Metadata {
return ast.WalkContinue, nil return ast.WalkContinue, nil
}) })
// HTML 资源也可能被历史导入的笔记引用;清理不能漏掉它们。 // HTML 资源也可能被历史导入的笔记引用;清理不能漏掉它们。
excluded := nonProseRanges(root)
for _, span := range importLinkSpans(content) { for _, span := range importLinkSpans(content) {
if !span.html { if !span.html {
continue continue
} }
skip := false
for _, region := range excluded {
if span.start >= region[0] && span.start < region[1] {
skip = true
break
}
}
if skip {
continue
}
target, _, ok := LocalTarget(note, string(content[span.start:span.end])) target, _, ok := LocalTarget(note, string(content[span.start:span.end]))
if ok { if ok {
m.Assets = append(m.Assets, Reference{target, bytes.Count(content[:span.start], []byte("\n")) + 1}) m.Assets = append(m.Assets, Reference{target, bytes.Count(content[:span.start], []byte("\n")) + 1})
+104
View File
@@ -0,0 +1,104 @@
package vault
import (
"bytes"
"unicode"
"unicode/utf8"
"github.com/yuin/goldmark/v2/ast"
"github.com/yuin/goldmark/v2/extension"
"github.com/yuin/goldmark/v2/parser"
"github.com/yuin/goldmark/v2/text"
"github.com/yuin/goldmark/v2/util"
)
// 仅供关系提取:公式与代码同属非正文,不能把其中的 #1 或示例链接当成知识关系。
// 分隔规则与前端 Typora 扩展一致;此解析器不参与 Markdown 渲染。
type metadataMath struct{}
func (metadataMath) Trigger() []byte { return []byte{'$'} }
func (metadataMath) Open(parent ast.Node, reader text.Reader, _ parser.Context) (ast.Node, parser.State) {
line, _ := reader.PeekLine()
if parent.Kind() != ast.KindDocument || !bytes.Equal(bytes.TrimSpace(line), []byte("$$")) {
return nil, parser.NoChildren
}
return ast.NewCodeBlock(ast.CodeBlockKindFenced, text.Lines{}), parser.NoChildren
}
func (metadataMath) Continue(node ast.Node, reader text.Reader, _ parser.Context) parser.State {
line, segment := reader.PeekLine()
reader.AdvanceToEOL()
if bytes.Equal(bytes.TrimSpace(line), []byte("$$")) {
return parser.Close
}
node.(*ast.CodeBlock).Value.AppendSegment(segment)
return parser.Continue | parser.NoChildren
}
func (metadataMath) Close(ast.Node, text.Reader, parser.Context) {}
func (metadataMath) CanInterruptParagraph() bool { return true }
func (metadataMath) CanAcceptIndentedLine() bool { return false }
func (metadataMath) Parse(_ ast.Node, reader text.Reader, _ parser.Context) ast.Node {
line, first := reader.PeekLine()
if len(line) < 2 || line[1] == '$' {
return nil
}
next, _ := utf8.DecodeRune(line[1:])
if unicode.IsSpace(next) {
return nil
}
savedLine, savedPosition := reader.Position()
reader.Advance(1)
for {
line, segment := reader.PeekLine()
if line == nil {
reader.SetPosition(savedLine, savedPosition)
return nil
}
for i := 0; i < len(line); i++ {
if line[i] == '\\' {
i++
continue
}
if line[i] != '$' || i == 0 {
continue
}
prev, _ := utf8.DecodeLastRune(line[:i])
if unicode.IsSpace(prev) {
continue
}
if i+1 < len(line) && line[i+1] >= '0' && line[i+1] <= '9' {
continue
}
reader.Advance(i + 1)
return ast.NewCodeSpan(text.NewSingleLineValueFromIndex(text.NewIndex(first.Start, segment.Start+i+1), nil))
}
reader.AdvanceLine()
}
}
func parseMetadata(source []byte) ast.Node {
math := metadataMath{}
return parser.New(parser.WithExtensions(extension.GFMParser, extension.FootnoteParser), parser.WithBlockParsers(util.Prioritized[parser.BlockParser](math, 650)), parser.WithInlineParsers(util.Prioritized[parser.InlineParser](math, 150))).Parse(source)
}
// 返回非正文的字节范围,供无损链接改写器跳过公式中的示例语法。
func nonProseRanges(root ast.Node) [][2]int {
var ranges [][2]int
_ = ast.Walk(root, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch node := node.(type) {
case *ast.CodeSpan:
for _, index := range node.Value.Indices() {
ranges = append(ranges, [2]int{index.Start, index.Stop})
}
return ast.WalkSkipChildren, nil
case *ast.CodeBlock:
for _, index := range node.Value.Segments() {
ranges = append(ranges, [2]int{index.Start, index.Stop})
}
return ast.WalkSkipChildren, nil
}
return ast.WalkContinue, nil
})
return ranges
}
+20
View File
@@ -73,3 +73,23 @@ func TestMoveMaintainsIncomingAndOutgoingLinks(t *testing.T) {
t.Fatal("移动主体未回滚", err) t.Fatal("移动主体未回滚", err)
} }
} }
func TestMetadataSkipsMathAndResolvesFootnotes(t *testing.T) {
content := "#正文\n\n公式 $x + #1 + [fake](bad.md)$\n\n$$\n#2 [fake](bad2.md) <img src=\"assets/fake.png\">\n$$\n\n脚注[^a]\n\n[^a]: ![图片](assets/foot.png)\n\n\\#escaped https://example.com/?x=#url\n"
m := Analyze("a.md", []byte(content))
if !reflect.DeepEqual(m.Tags, []string{"正文"}) || len(m.Links) != 0 || len(m.Assets) != 1 || m.Assets[0].Target != "assets/foot.png" {
t.Fatalf("扩展语法误识别:%+v", m)
}
rewritten, err := rewriteMovedLinks([]byte(content), "a.md", "dir/a.md", "a.md", "dir/a.md", false, false)
if err != nil || !strings.Contains(string(rewritten), "[fake](bad.md)") || !strings.Contains(string(rewritten), "[fake](bad2.md)") || !strings.Contains(string(rewritten), "../assets/foot.png") {
t.Fatalf("公式或脚注改写不符:%s %v", rewritten, err)
}
}
func TestRenamedAssetsPreserveMathExamples(t *testing.T) {
content := "公式 $x + ![](a.assets/fake.png)$\n\n$$\n<img src=\"a.assets/fake.png\">\n$$\n\n![](a.assets/real.png)\n"
got := string(RewriteAssetLinks([]byte(content), "a", "b"))
if !strings.Contains(got, "$x + ![](a.assets/fake.png)$") || !strings.Contains(got, "src=\"a.assets/fake.png\"") || !strings.Contains(got, "![](b.assets/real.png)") {
t.Fatalf("公式示例被修改:%s", got)
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { createNote, ws } from './workspace'
<header class="rm-global-header"> <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> <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> <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> <button class="rm-global-search" @click="ws.switcher = ''"><Icon name="search" /><span>全局检索知识库</span><kbd>⌘ / Ctrl P</kbd></button>
<div class="rm-header-actions"> <div class="rm-header-actions">
<button class="rm-icon-btn" title="设置" @click="ws.settings = 'index'"><Icon name="settings" /></button> <button class="rm-icon-btn" title="设置" @click="ws.settings = 'index'"><Icon name="settings" /></button>
+2
View File
@@ -75,6 +75,7 @@ async function reset() {
<template> <template>
<p v-if="appearancePaused" class="rm-appearance-note">已临时停用主题和 CSS。可恢复默认外观后重新打开页面。</p> <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> <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"> <div class="rm-setting">
<label for="rm-color-mode" class="rm-setting-label">明暗模式</label> <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'] })"> <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> </form>
<div class="rm-setting"><span class="rm-setting-label">默认外观</span><button class="rm-btn" :disabled="busy" @click="reset">恢复默认外观</button></div> <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> <p class="rm-appearance-note">若自定义样式导致界面无法操作,可在地址末尾加 <code>?reset-appearance</code>,临时停用后再恢复默认。</p>
</details>
</template> </template>
+4 -1
View File
@@ -48,14 +48,17 @@ function show() {
onMounted(() => { onMounted(() => {
view = createEditorView(host.value!, EditorState.create()) view = createEditorView(host.value!, EditorState.create())
attachView(view) attachView(view)
document.fonts.addEventListener('loadingdone', measure)
show() show()
}) })
function measure() { view.requestMeasure() }
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.fonts.removeEventListener('loadingdone', measure)
if (shown) storeViewState(shown.path, view.state) if (shown) storeViewState(shown.path, view.state)
attachView(null) attachView(null)
view.destroy() 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> </script>
<template> <template>
+2 -2
View File
@@ -38,7 +38,7 @@ const references = computed(() => {
}) })
const certain = computed(() => metadata.ready && !metadata.error && !metadata.notes.some(note => note.warnings.length)) const certain = computed(() => metadata.ready && !metadata.error && !metadata.notes.some(note => note.warnings.length))
function shared(path: string) { 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 images = computed(() => scopeImages.value.filter(entry => {
const count = references.value.get(entry.path)?.length ?? 0 const count = references.value.get(entry.path)?.length ?? 0
@@ -88,7 +88,7 @@ async function clean() {
</script> </script>
<template> <template>
<section class="rm-media-library" aria-label="图片库" @keydown.esc.stop="!busy && (ws.images = null)"> <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-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"> <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> <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>
+1 -1
View File
@@ -77,7 +77,7 @@ watch(() => ws.view, () => { query.value = ''; tag.value = '' })
.rm-knowledge-panel { flex: 1; overflow: auto; padding: 36px 40px; } .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-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-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-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); } .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; } } @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; } }
+2 -2
View File
@@ -16,7 +16,7 @@ import { toast, ws, type SettingsSection } from './workspace'
const SECTIONS: { id: SettingsSection; title: string; icon: IconName }[] = [ const SECTIONS: { id: SettingsSection; title: string; icon: IconName }[] = [
{ id: 'appearance', title: '常规与外观', icon: 'palette' }, { id: 'appearance', title: '常规与外观', icon: 'palette' },
{ id: 'editor', title: '编辑与阅读排版', icon: 'edit' }, { id: 'editor', title: '编辑器与模板', icon: 'edit' },
{ id: 'plugins', title: '插件', icon: 'command' }, { id: 'plugins', title: '插件', icon: 'command' },
{ id: 'account', title: '账户', icon: 'user' }, { id: 'account', title: '账户', icon: 'user' },
{ id: 'shortcuts', title: '快捷键', icon: 'command' }, { 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-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-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> <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> <p class="rm-share-note">设置保存在服务器上,所有设备共用。</p>
</template> </template>
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { copyText } from '../editor/widgets' import { copyText } from '../clipboard'
import { api, ApiError, type Share } from './api' import { api, ApiError, type Share } from './api'
import Icon from './Icon.vue' import Icon from './Icon.vue'
import { toast } from './workspace' import { toast } from './workspace'
+1 -1
View File
@@ -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)"> <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="从模板新建"> <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> <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"> <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>选择模板<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> <label>笔记路径<input v-model="path" aria-label="笔记路径" required /></label>
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { copyText } from '../editor/widgets' import { copyText } from '../clipboard'
import { api, ApiError, type ApiToken } from './api' import { api, ApiError, type ApiToken } from './api'
import Icon from './Icon.vue' import Icon from './Icon.vue'
import { toast } from './workspace' import { toast } from './workspace'
+11 -1
View File
@@ -585,7 +585,7 @@ button {
.rm-outline-head { padding: 18px 14px; font-size: 11px; } .rm-outline-head { padding: 18px 14px; font-size: 11px; }
.rm-outline-list { padding: 8px 12px; } .rm-outline-list { padding: 8px 12px; }
.rm-outline-list li { padding-top: 9px; padding-bottom: 9px; font-size: 11px; } .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-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 { 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); } .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); } } @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-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; }
+3 -1
View File
@@ -7,7 +7,9 @@ import { createNoteAt, openNote, toast, ws } from './workspace'
export async function createFromTemplate(path: string, template: string) { export async function createFromTemplate(path: string, template: string) {
if (ws.entries.some(entry => entry.path === path)) { toast('目标笔记已存在'); return false } if (ws.entries.some(entry => entry.path === path)) { toast('目标笔记已存在'); return false }
const content = template ? renderTemplate((await api.readFile(template)).content, template, path) : '' 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() { export async function openDailyNote() {
+5 -2
View File
@@ -1,5 +1,6 @@
// 工作区状态与操作:目录树、标签页、保存队列、冲突处理、实时同步。 // 工作区状态与操作:目录树、标签页、保存队列、冲突处理、实时同步。
// 文件操作一律先更新界面再请求服务器,失败时回滚,保证"操作即时生效"。 // 文件操作一律先更新界面再请求服务器,失败时回滚,保证"操作即时生效"。
import { decodeLinkDestination } from '../markdown/links'
import { Annotation, EditorState, Transaction } from '@codemirror/state' import { Annotation, EditorState, Transaction } from '@codemirror/state'
import { EditorView } from '@codemirror/view' import { EditorView } from '@codemirror/view'
import { nextTick, reactive, watch } from 'vue' import { nextTick, reactive, watch } from 'vue'
@@ -1139,7 +1140,7 @@ export function stopWorkspace() {
async function openMarkdownLink(note: string, href: string) { async function openMarkdownLink(note: string, href: string) {
const decoded = href.replace(/\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/g, '$1') const decoded = decodeLinkDestination(href)
let url: URL let url: URL
try { url = new URL(decoded, 'https://rawmark.local/' + encodePath(note)) } try { url = new URL(decoded, 'https://rawmark.local/' + encodePath(note)) }
catch { toast('链接格式无效'); return } catch { toast('链接格式无效'); return }
@@ -1148,7 +1149,9 @@ async function openMarkdownLink(note: string, href: string) {
else toast('不支持的链接协议') else toast('不支持的链接协议')
return 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 (!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 } if (!target.endsWith('.md')) { window.open('/api/v1/raw/' + encodePath(target), '_blank', 'noopener'); return }
await openNote(target) await openNote(target)
+19
View File
@@ -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 -20
View File
@@ -1,3 +1,4 @@
import { copyText } from '../clipboard'
import { EditorView, WidgetType } from '@codemirror/view' import { EditorView, WidgetType } from '@codemirror/view'
import { emojiFor, renderMath } from '../markdown/render' import { emojiFor, renderMath } from '../markdown/render'
import { mountContent } from '../plugins/mount' 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 = 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>' '<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 = const CHECK_ICON =
+8
View File
@@ -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
})
}
+6
View File
@@ -1,3 +1,4 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { localDate, renderTemplate } from './template' 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('[ref]: ../docs/guide.md#%E6%A0%87%E9%A2%98')
expect(result).toContain('`![](assets/a.png)`\n```\n![](assets/a.png)\n```') expect(result).toContain('`![](assets/a.png)`\n```\n![](assets/a.png)\n```')
}) })
it('链接实体和 Markdown 转义按真实文件名重定位', () => {
const result = renderTemplate('[文档](a&amp;b.md) [括号](a\\(1\\).md)', 'Templates/日记.md', 'Daily/今天.md')
expect(result).toContain('[文档](../Templates/a%26b.md)')
expect(result).toContain('[括号](../Templates/a(1).md)')
})
}) })
+3 -2
View File
@@ -1,4 +1,5 @@
import { markdownParser } from './html' import { markdownParser } from './html'
import { decodeLinkDestination } from './links'
export function localDate(now = new Date()): string { export function localDate(now = new Date()): string {
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` 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 }[] = [] const changes: { from: number; to: number; value: string }[] = []
markdownParser.parse(content).iterate({ enter(node) { markdownParser.parse(content).iterate({ enter(node) {
if (node.name !== 'URL' || !['Link', 'Image', 'LinkReference'].includes(node.node.parent?.name ?? '')) return 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 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 parts = decodeURIComponent(url.pathname.slice(1)).split('/')
const base = target.slice(0, Math.max(0, target.lastIndexOf('/'))).split('/').filter(Boolean) const base = target.slice(0, Math.max(0, target.lastIndexOf('/'))).split('/').filter(Boolean)
while (base.length && base[0] === parts[0]) { base.shift(); parts.shift() } while (base.length && base[0] === parts[0]) { base.shift(); parts.shift() }
+135 -15
View File
@@ -1,4 +1,7 @@
// 只读分享页(/s/{token}):取回笔记内容,用与编辑器同一套解析器渲染为 HTML。不加载编辑器 // 只读分享页(/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 { requestTokens, type TokenRun } from '../highlight/client'
import { renderMarkdown } from '../markdown/html' import { renderMarkdown } from '../markdown/html'
import { hydrate, renderMermaid } from '../markdown/render' import { hydrate, renderMermaid } from '../markdown/render'
@@ -71,9 +74,7 @@ async function load() {
const response = await fetch(api + '/appearance', { cache: 'no-store' }) const response = await fetch(api + '/appearance', { cache: 'no-store' })
if (!response.ok) throw new Error('读取外观失败') if (!response.ok) throw new Error('读取外观失败')
const appearance = await response.json() as Record<string, unknown> const appearance = await response.json() as Record<string, unknown>
const rootStyle = document.documentElement.style applyDisplaySettings(sanitizeDisplaySettings(appearance))
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')
applyAppearance(sanitizeAppearance(appearance), typeof appearance.themeCSS === 'string' ? appearance.themeCSS : '') applyAppearance(sanitizeAppearance(appearance), typeof appearance.themeCSS === 'string' ? appearance.themeCSS : '')
} catch { } catch {
appearanceError = true 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 ? '/' : '') })) li.append(Object.assign(document.createElement('a'), { href: pageUrl(path), textContent: entry.path.split('/').at(-1) + (entry.dir ? '/' : '') }))
list.append(li) 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: '此文件夹为空' })) 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) { function render(note: SharedNote) {
stopReading?.()
document.title = note.title document.title = note.title
const aside = document.createElement('aside')
aside.className = 'rm-share-sidebar'
const header = document.createElement('header') const header = document.createElement('header')
header.className = 'rm-share-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') const article = document.createElement('article')
article.className = 'rm-share-content' article.className = 'rm-share-content'
// 安全模式:原始 HTML 被转义,危险协议的链接被过滤 // 安全模式:原始 HTML 被转义,危险协议的链接被过滤。
article.innerHTML = renderMarkdown(note.content) 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) resolveImages(article)
resolveLinks(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) 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 = '代码高亮失败,已保留源码' code.title = '代码高亮失败,已保留源码'
console.error('分享页代码高亮失败', error) 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 h.id = id
} }
const navs = article.querySelectorAll('nav.toc') const navs = article.querySelectorAll('nav.toc')
if (!navs.length) return
const top = Math.min(...headings.map((h) => Number(h.tagName[1]))) const top = Math.min(...headings.map((h) => Number(h.tagName[1])))
const list = document.createElement('ul') const list = document.createElement('ul')
for (const h of headings) { for (const h of headings) {
@@ -257,11 +369,11 @@ function buildToc(article: HTMLElement) {
list.append(li) list.append(li)
} }
for (const nav of navs) nav.replaceChildren(list.cloneNode(true)) for (const nav of navs) nav.replaceChildren(list.cloneNode(true))
return headings
} }
async function decorateCode(code: HTMLElement) { async function decorateCode(code: HTMLElement) {
const lang = /language-(\S+)/.exec(code.className)?.[1]?.toLowerCase() const lang = /language-(\S+)/.exec(code.className)?.[1]?.toLowerCase()
if (!lang) return
const text = (code.textContent ?? '').replace(/\n$/, '') const text = (code.textContent ?? '').replace(/\n$/, '')
if (lang === 'mermaid') { if (lang === 'mermaid') {
const figure = document.createElement('div') const figure = document.createElement('div')
@@ -275,6 +387,12 @@ async function decorateCode(code: HTMLElement) {
} }
return 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) const run = await requestTokens(lang, text)
if (!run) return if (!run) return
const out = document.createDocumentFragment() 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() void load()
+39
View File
@@ -114,3 +114,42 @@
.rm-share { padding: 16px 16px 60px; } .rm-share { padding: 16px 16px 60px; }
.rm-share-time { display: none; } .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; } }