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

+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) {
var out []byte
last := 0
excluded := nonProseRanges(parseMetadata(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 {
continue
}
+25 -4
View File
@@ -7,7 +7,7 @@ import (
)
// RewriteAssetLinks 在笔记改名后,把正文中指向旧图片目录(oldBase.assets/)的链接改为新目录。
// 覆盖行内链接/图片 `](…)`、引用定义 `[x]: …` 与 HTML `src="…"`,跳过代码块和行内代码。
// 覆盖行内链接/图片 `](…)`、引用定义 `[x]: …` 与 HTML `src="…"`,跳过代码与公式。
func RewriteAssetLinks(content []byte, oldBase, newBase string) []byte {
oldRaw := regexp.QuoteMeta(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)
repl := []byte("${1}${2}" + strings.ReplaceAll(EscapeLinkPath(newBase+AssetsExt), "$", "$$") + "/")
return transformMarkdownLines(content, refDef, func(line, _ []byte, _ int) []byte {
line = refDef.ReplaceAll(line, repl)
return transformOutsideCode(line, func(text []byte, _ int) []byte { return inline.ReplaceAll(text, repl) })
excluded := nonProseRanges(parseMetadata(content))
replace := func(pattern *regexp.Regexp, source []byte, offset int) []byte {
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"
"github.com/yuin/goldmark/v2/ast"
"github.com/yuin/goldmark/v2/parser"
"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) {
if !entering {
return ast.WalkContinue, nil
@@ -129,16 +128,16 @@ func Analyze(note string, content []byte) Metadata {
}
}
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
case *ast.Link:
addRef(node.Destination.Value(source), node.Pos())
addRef(node.Destination.Str(source), node.Pos())
return ast.WalkSkipChildren, nil
case *ast.Image:
addRef(node.Destination.Value(source), node.Pos())
addRef(node.Destination.Str(source), node.Pos())
return ast.WalkSkipChildren, nil
case *ast.Text:
value := node.Value.Value(source)
value := node.Value.Str(source)
for _, loc := range inlineTag.FindAllStringIndex(value, -1) {
if loc[0] > 0 {
prev, _ := utf8.DecodeLastRuneInString(value[:loc[0]])
@@ -152,10 +151,21 @@ func Analyze(note string, content []byte) Metadata {
return ast.WalkContinue, nil
})
// HTML 资源也可能被历史导入的笔记引用;清理不能漏掉它们。
excluded := nonProseRanges(root)
for _, span := range importLinkSpans(content) {
if !span.html {
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]))
if ok {
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)
}
}
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)
}
}