From 596b894ca05c0d9154e1beee467da6ee7f4d068c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Oct 2025 08:31:57 +0800 Subject: [PATCH] chore: remove unused syntax - Removed the wikilink extension from markdown services in test and API service. - Deleted the DefaultLink and WikiLink components, simplifying link handling. - Updated ConditionalComponent to remove wikilink checks. - Adjusted MemoContent to exclude wikilink handling in markdown rendering. - Refined markdown styles for compact rendering, enhancing readability. - Added a Markdown Styling Guide to document the new compact styling approach. --- plugin/markdown/ast/wikilink.go | 32 --- plugin/markdown/extensions/wikilink.go | 24 -- plugin/markdown/markdown.go | 17 +- plugin/markdown/markdown_test.go | 25 +- plugin/markdown/parser/tag.go | 4 +- plugin/markdown/parser/tag_test.go | 30 +++ plugin/markdown/parser/wikilink.go | 104 -------- plugin/markdown/parser/wikilink_test.go | 252 ------------------ plugin/markdown/renderer/markdown_renderer.go | 9 - .../renderer/markdown_renderer_test.go | 12 +- server/router/api/v1/test/test_helper.go | 1 - server/router/api/v1/v1.go | 1 - web/MARKDOWN_STYLE_GUIDE.md | 153 +++++++++++ web/package.json | 1 - web/pnpm-lock.yaml | 110 -------- .../MemoContent/ConditionalComponent.tsx | 9 - .../components/MemoContent/DefaultLink.tsx | 30 --- .../MemoContent/MemoContentContext.tsx | 2 +- web/src/components/MemoContent/WikiLink.tsx | 44 --- web/src/components/MemoContent/index.tsx | 8 +- web/src/index.css | 251 +++++++++++------ web/src/utils/remark-plugins/remark-tag.ts | 7 +- 22 files changed, 366 insertions(+), 760 deletions(-) delete mode 100644 plugin/markdown/ast/wikilink.go delete mode 100644 plugin/markdown/extensions/wikilink.go delete mode 100644 plugin/markdown/parser/wikilink.go delete mode 100644 plugin/markdown/parser/wikilink_test.go create mode 100644 web/MARKDOWN_STYLE_GUIDE.md delete mode 100644 web/src/components/MemoContent/DefaultLink.tsx delete mode 100644 web/src/components/MemoContent/WikiLink.tsx diff --git a/plugin/markdown/ast/wikilink.go b/plugin/markdown/ast/wikilink.go deleted file mode 100644 index 3cbead1ec..000000000 --- a/plugin/markdown/ast/wikilink.go +++ /dev/null @@ -1,32 +0,0 @@ -package ast - -import ( - gast "github.com/yuin/goldmark/ast" -) - -// WikilinkNode represents [[target]] or [[target?params]] syntax. -type WikilinkNode struct { - gast.BaseInline - - // Target is the link destination (e.g., "memos/1", "Hello world", "resources/101") - Target []byte - - // Params are optional parameters (e.g., "align=center" from [[target?align=center]]) - Params []byte -} - -// KindWikilink is the NodeKind for WikilinkNode. -var KindWikilink = gast.NewNodeKind("Wikilink") - -// Kind returns KindWikilink. -func (*WikilinkNode) Kind() gast.NodeKind { - return KindWikilink -} - -// Dump implements Node.Dump for debugging. -func (n *WikilinkNode) Dump(source []byte, level int) { - gast.DumpHelper(n, source, level, map[string]string{ - "Target": string(n.Target), - "Params": string(n.Params), - }, nil) -} diff --git a/plugin/markdown/extensions/wikilink.go b/plugin/markdown/extensions/wikilink.go deleted file mode 100644 index a68e8e6ca..000000000 --- a/plugin/markdown/extensions/wikilink.go +++ /dev/null @@ -1,24 +0,0 @@ -package extensions - -import ( - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/parser" - "github.com/yuin/goldmark/util" - - mparser "github.com/usememos/memos/plugin/markdown/parser" -) - -type wikilinkExtension struct{} - -// WikilinkExtension is a goldmark extension for [[...]] wikilink syntax. -var WikilinkExtension = &wikilinkExtension{} - -// Extend extends the goldmark parser with wikilink support. -func (*wikilinkExtension) Extend(m goldmark.Markdown) { - m.Parser().AddOptions( - parser.WithInlineParsers( - // Priority 199 - run before standard link parser (500) but after tags (200) - util.Prioritized(mparser.NewWikilinkParser(), 199), - ), - ) -} diff --git a/plugin/markdown/markdown.go b/plugin/markdown/markdown.go index 364c1e0d6..c6498beb4 100644 --- a/plugin/markdown/markdown.go +++ b/plugin/markdown/markdown.go @@ -62,8 +62,7 @@ type service struct { type Option func(*config) type config struct { - enableTags bool - enableWikilink bool + enableTags bool } // WithTagExtension enables #tag parsing. @@ -73,13 +72,6 @@ func WithTagExtension() Option { } } -// WithWikilinkExtension enables [[wikilink]] parsing. -func WithWikilinkExtension() Option { - return func(c *config) { - c.enableWikilink = true - } -} - // NewService creates a new markdown service with the given options. func NewService(opts ...Option) Service { cfg := &config{} @@ -95,9 +87,6 @@ func NewService(opts ...Option) Service { if cfg.enableTags { exts = append(exts, extensions.TagExtension) } - if cfg.enableWikilink { - exts = append(exts, extensions.WikilinkExtension) - } md := goldmark.New( goldmark.WithExtensions(exts...), @@ -164,7 +153,7 @@ func (s *service) ExtractProperties(content []byte) (*storepb.MemoPayload_Proper } switch n.Kind() { - case gast.KindLink, mast.KindWikilink: + case gast.KindLink: prop.HasLink = true case gast.KindCodeBlock, gast.KindFencedCodeBlock, gast.KindCodeSpan: @@ -321,7 +310,7 @@ func (s *service) ExtractAll(content []byte) (*ExtractedData, error) { // Extract properties based on node kind switch n.Kind() { - case gast.KindLink, mast.KindWikilink: + case gast.KindLink: data.Property.HasLink = true case gast.KindCodeBlock, gast.KindFencedCodeBlock, gast.KindCodeSpan: diff --git a/plugin/markdown/markdown_test.go b/plugin/markdown/markdown_test.go index 92fb7dad8..628d96c4a 100644 --- a/plugin/markdown/markdown_test.go +++ b/plugin/markdown/markdown_test.go @@ -109,7 +109,6 @@ func TestExtractProperties(t *testing.T) { tests := []struct { name string content string - withExt bool hasLink bool hasCode bool hasTasks bool @@ -118,7 +117,6 @@ func TestExtractProperties(t *testing.T) { { name: "plain text", content: "Just plain text", - withExt: false, hasLink: false, hasCode: false, hasTasks: false, @@ -127,7 +125,6 @@ func TestExtractProperties(t *testing.T) { { name: "with link", content: "Check out [this link](https://example.com)", - withExt: false, hasLink: true, hasCode: false, hasTasks: false, @@ -136,7 +133,6 @@ func TestExtractProperties(t *testing.T) { { name: "with inline code", content: "Use `console.log()` to debug", - withExt: false, hasLink: false, hasCode: true, hasTasks: false, @@ -145,7 +141,6 @@ func TestExtractProperties(t *testing.T) { { name: "with code block", content: "```go\nfunc main() {}\n```", - withExt: false, hasLink: false, hasCode: true, hasTasks: false, @@ -154,7 +149,6 @@ func TestExtractProperties(t *testing.T) { { name: "with completed task", content: "- [x] Completed task", - withExt: false, hasLink: false, hasCode: false, hasTasks: true, @@ -163,7 +157,6 @@ func TestExtractProperties(t *testing.T) { { name: "with incomplete task", content: "- [ ] Todo item", - withExt: false, hasLink: false, hasCode: false, hasTasks: true, @@ -172,25 +165,14 @@ func TestExtractProperties(t *testing.T) { { name: "mixed tasks", content: "- [x] Done\n- [ ] Not done", - withExt: false, hasLink: false, hasCode: false, hasTasks: true, hasInc: true, }, - { - name: "with referenced content", - content: "See [[memos/1]] for details", - withExt: true, - hasLink: true, - hasCode: false, - hasTasks: false, - hasInc: false, - }, { name: "everything", content: "# Title\n\n[Link](url)\n\n`code`\n\n- [ ] Task", - withExt: false, hasLink: true, hasCode: true, hasTasks: true, @@ -200,12 +182,7 @@ func TestExtractProperties(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var svc Service - if tt.withExt { - svc = NewService(WithWikilinkExtension()) - } else { - svc = NewService() - } + svc := NewService() props, err := svc.ExtractProperties([]byte(tt.content)) require.NoError(t, err) diff --git a/plugin/markdown/parser/tag.go b/plugin/markdown/parser/tag.go index 348e3da11..26804e266 100644 --- a/plugin/markdown/parser/tag.go +++ b/plugin/markdown/parser/tag.go @@ -45,7 +45,7 @@ func (*tagParser) Parse(_ gast.Node, block text.Reader, _ parser.Context) gast.N } // Scan tag characters - // Valid: alphanumeric, dash, underscore + // Valid: alphanumeric, dash, underscore, forward slash tagEnd := 1 // Start after # for tagEnd < len(line) { c := line[tagEnd] @@ -53,7 +53,7 @@ func (*tagParser) Parse(_ gast.Node, block text.Reader, _ parser.Context) gast.N isValid := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || - c == '-' || c == '_' + c == '-' || c == '_' || c == '/' if !isValid { break diff --git a/plugin/markdown/parser/tag_test.go b/plugin/markdown/parser/tag_test.go index a08755076..d238b591f 100644 --- a/plugin/markdown/parser/tag_test.go +++ b/plugin/markdown/parser/tag_test.go @@ -96,6 +96,36 @@ func TestTagParser(t *testing.T) { expectedTag: "WorkNotes", shouldParse: true, }, + { + name: "hierarchical tag with slash", + input: "#tag1/subtag", + expectedTag: "tag1/subtag", + shouldParse: true, + }, + { + name: "hierarchical tag with multiple levels", + input: "#tag1/subtag/subtag2", + expectedTag: "tag1/subtag/subtag2", + shouldParse: true, + }, + { + name: "hierarchical tag followed by space", + input: "#work/notes ", + expectedTag: "work/notes", + shouldParse: true, + }, + { + name: "hierarchical tag followed by punctuation", + input: "#project/2024.", + expectedTag: "project/2024", + shouldParse: true, + }, + { + name: "hierarchical tag with numbers and dashes", + input: "#work-log/2024/q1", + expectedTag: "work-log/2024/q1", + shouldParse: true, + }, } for _, tt := range tests { diff --git a/plugin/markdown/parser/wikilink.go b/plugin/markdown/parser/wikilink.go deleted file mode 100644 index 492a1f370..000000000 --- a/plugin/markdown/parser/wikilink.go +++ /dev/null @@ -1,104 +0,0 @@ -package parser - -import ( - "bytes" - - gast "github.com/yuin/goldmark/ast" - "github.com/yuin/goldmark/parser" - "github.com/yuin/goldmark/text" - - mast "github.com/usememos/memos/plugin/markdown/ast" -) - -type wikilinkParser struct{} - -// NewWikilinkParser creates a new inline parser for [[...]] wikilink syntax. -func NewWikilinkParser() parser.InlineParser { - return &wikilinkParser{} -} - -// Trigger returns the characters that trigger this parser. -func (*wikilinkParser) Trigger() []byte { - return []byte{'['} -} - -// Parse parses [[target]] or [[target?params]] wikilink syntax. -func (*wikilinkParser) Parse(_ gast.Node, block text.Reader, _ parser.Context) gast.Node { - line, _ := block.PeekLine() - - // Must start with [[ - if len(line) < 2 || line[0] != '[' || line[1] != '[' { - return nil - } - - // Find closing ]] - closePos := findClosingBrackets(line[2:]) - if closePos == -1 { - return nil - } - - // Extract content between [[ and ]] - // closePos is relative to line[2:], so actual position is closePos + 2 - contentStart := 2 - contentEnd := contentStart + closePos - content := line[contentStart:contentEnd] - - // Empty content is not allowed - if len(bytes.TrimSpace(content)) == 0 { - return nil - } - - // Parse target and parameters - target, params := parseTargetAndParams(content) - - // Advance reader position - // +2 for [[, +len(content), +2 for ]] - block.Advance(contentEnd + 2) - - // Create AST node - node := &mast.WikilinkNode{ - Target: target, - Params: params, - } - - return node -} - -// findClosingBrackets finds the position of ]] in the byte slice. -// Returns -1 if not found. -func findClosingBrackets(data []byte) int { - for i := 0; i < len(data)-1; i++ { - if data[i] == ']' && data[i+1] == ']' { - return i - } - } - return -1 -} - -// parseTargetAndParams splits content on ? to extract target and parameters. -func parseTargetAndParams(content []byte) (target []byte, params []byte) { - // Find ? separator - idx := bytes.IndexByte(content, '?') - - if idx == -1 { - // No parameters - target = bytes.TrimSpace(content) - return target, nil - } - - // Split on ? - target = bytes.TrimSpace(content[:idx]) - params = content[idx+1:] // Keep params as-is (don't trim, might have meaningful spaces) - - // Make copies to avoid issues with slice sharing - targetCopy := make([]byte, len(target)) - copy(targetCopy, target) - - var paramsCopy []byte - if len(params) > 0 { - paramsCopy = make([]byte, len(params)) - copy(paramsCopy, params) - } - - return targetCopy, paramsCopy -} diff --git a/plugin/markdown/parser/wikilink_test.go b/plugin/markdown/parser/wikilink_test.go deleted file mode 100644 index 33a8876cc..000000000 --- a/plugin/markdown/parser/wikilink_test.go +++ /dev/null @@ -1,252 +0,0 @@ -package parser - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/yuin/goldmark/parser" - "github.com/yuin/goldmark/text" - - mast "github.com/usememos/memos/plugin/markdown/ast" -) - -func TestWikilinkParser(t *testing.T) { - tests := []struct { - name string - input string - expectedTarget string - expectedParams string - shouldParse bool - }{ - { - name: "basic wikilink", - input: "[[Hello world]]", - expectedTarget: "Hello world", - expectedParams: "", - shouldParse: true, - }, - { - name: "memo wikilink", - input: "[[memos/1]]", - expectedTarget: "memos/1", - expectedParams: "", - shouldParse: true, - }, - { - name: "resource wikilink", - input: "[[resources/101]]", - expectedTarget: "resources/101", - expectedParams: "", - shouldParse: true, - }, - { - name: "with parameters", - input: "[[resources/101?align=center]]", - expectedTarget: "resources/101", - expectedParams: "align=center", - shouldParse: true, - }, - { - name: "multiple parameters", - input: "[[resources/101?align=center&width=300]]", - expectedTarget: "resources/101", - expectedParams: "align=center&width=300", - shouldParse: true, - }, - { - name: "inline with text after", - input: "[[resources/101]]111", - expectedTarget: "resources/101", - expectedParams: "", - shouldParse: true, - }, - { - name: "whitespace trimmed", - input: "[[ Hello world ]]", - expectedTarget: "Hello world", - expectedParams: "", - shouldParse: true, - }, - { - name: "empty content", - input: "[[]]", - expectedTarget: "", - expectedParams: "", - shouldParse: false, - }, - { - name: "whitespace only", - input: "[[ ]]", - expectedTarget: "", - expectedParams: "", - shouldParse: false, - }, - { - name: "missing closing brackets", - input: "[[Hello world", - expectedTarget: "", - expectedParams: "", - shouldParse: false, - }, - { - name: "single bracket", - input: "[Hello]", - expectedTarget: "", - expectedParams: "", - shouldParse: false, - }, - { - name: "nested brackets", - input: "[[outer [[inner]] ]]", - expectedTarget: "outer [[inner", - expectedParams: "", - shouldParse: true, // Stops at first ]] - }, - { - name: "special characters", - input: "[[Project/2024/Notes]]", - expectedTarget: "Project/2024/Notes", - expectedParams: "", - shouldParse: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - p := NewWikilinkParser() - reader := text.NewReader([]byte(tt.input)) - ctx := parser.NewContext() - - node := p.Parse(nil, reader, ctx) - - if tt.shouldParse { - require.NotNil(t, node, "Expected wikilink to be parsed") - require.IsType(t, &mast.WikilinkNode{}, node) - - wikilinkNode, ok := node.(*mast.WikilinkNode) - require.True(t, ok, "Expected node to be *mast.WikilinkNode") - assert.Equal(t, tt.expectedTarget, string(wikilinkNode.Target)) - assert.Equal(t, tt.expectedParams, string(wikilinkNode.Params)) - } else { - assert.Nil(t, node, "Expected wikilink NOT to be parsed") - } - }) - } -} - -func TestWikilinkParser_Trigger(t *testing.T) { - p := NewWikilinkParser() - triggers := p.Trigger() - - assert.Equal(t, []byte{'['}, triggers) -} - -func TestFindClosingBrackets(t *testing.T) { - tests := []struct { - name string - input []byte - expected int - }{ - { - name: "simple case", - input: []byte("hello]]world"), - expected: 5, - }, - { - name: "not found", - input: []byte("hello world"), - expected: -1, - }, - { - name: "at start", - input: []byte("]]hello"), - expected: 0, - }, - { - name: "single bracket", - input: []byte("hello]world"), - expected: -1, - }, - { - name: "empty", - input: []byte(""), - expected: -1, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := findClosingBrackets(tt.input) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestParseTargetAndParams(t *testing.T) { - tests := []struct { - name string - input []byte - expectedTarget string - expectedParams string - }{ - { - name: "no params", - input: []byte("target"), - expectedTarget: "target", - expectedParams: "", - }, - { - name: "with params", - input: []byte("target?param=value"), - expectedTarget: "target", - expectedParams: "param=value", - }, - { - name: "multiple params", - input: []byte("target?a=1&b=2"), - expectedTarget: "target", - expectedParams: "a=1&b=2", - }, - { - name: "whitespace trimmed from target", - input: []byte(" target ?param=value"), - expectedTarget: "target", - expectedParams: "param=value", - }, - { - name: "empty params", - input: []byte("target?"), - expectedTarget: "target", - expectedParams: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - target, params := parseTargetAndParams(tt.input) - assert.Equal(t, tt.expectedTarget, string(target)) - assert.Equal(t, tt.expectedParams, string(params)) - }) - } -} - -func TestWikilinkNode_Kind(t *testing.T) { - node := &mast.WikilinkNode{ - Target: []byte("test"), - } - - assert.Equal(t, mast.KindWikilink, node.Kind()) -} - -func TestWikilinkNode_Dump(t *testing.T) { - node := &mast.WikilinkNode{ - Target: []byte("test"), - Params: []byte("param=value"), - } - - // Should not panic - assert.NotPanics(t, func() { - node.Dump([]byte("[[test?param=value]]"), 0) - }) -} diff --git a/plugin/markdown/renderer/markdown_renderer.go b/plugin/markdown/renderer/markdown_renderer.go index e9971d6be..9ba5215fb 100644 --- a/plugin/markdown/renderer/markdown_renderer.go +++ b/plugin/markdown/renderer/markdown_renderer.go @@ -156,15 +156,6 @@ func (r *MarkdownRenderer) renderNode(node gast.Node, source []byte, depth int) r.buf.WriteByte('#') r.buf.Write(n.Tag) - case *mast.WikilinkNode: - r.buf.WriteString("[[") - r.buf.Write(n.Target) - if len(n.Params) > 0 { - r.buf.WriteByte('?') - r.buf.Write(n.Params) - } - r.buf.WriteString("]]") - default: // For unknown nodes, try to render children r.renderChildren(n, source, depth) diff --git a/plugin/markdown/renderer/markdown_renderer_test.go b/plugin/markdown/renderer/markdown_renderer_test.go index a665eb426..17aac75af 100644 --- a/plugin/markdown/renderer/markdown_renderer_test.go +++ b/plugin/markdown/renderer/markdown_renderer_test.go @@ -19,7 +19,6 @@ func TestMarkdownRenderer(t *testing.T) { goldmark.WithExtensions( extension.GFM, extensions.TagExtension, - extensions.WikilinkExtension, ), goldmark.WithParserOptions( parser.WithAutoHeadingID(), @@ -111,15 +110,10 @@ func TestMarkdownRenderer(t *testing.T) { input: "#work #important meeting notes", expected: "#work #important meeting notes", }, - { - name: "referenced content (wikilink)", - input: "Check [[memos/42]] for details", - expected: "Check [[memos/42]] for details", - }, { name: "complex mixed content", - input: "# Meeting Notes\n\n**Date**: 2024-01-01\n\n## Attendees\n- Alice\n- Bob\n\n## Discussion\n\nWe discussed #project status.\n\nSee [[memos/1]] for background.\n\n```python\nprint('hello')\n```", - expected: "# Meeting Notes\n\n**Date**: 2024-01-01\n\n## Attendees\n\n- Alice\n- Bob\n\n## Discussion\n\nWe discussed #project status.\n\nSee [[memos/1]] for background.\n\n```python\nprint('hello')\n```", + input: "# Meeting Notes\n\n**Date**: 2024-01-01\n\n## Attendees\n- Alice\n- Bob\n\n## Discussion\n\nWe discussed #project status.\n\n```python\nprint('hello')\n```", + expected: "# Meeting Notes\n\n**Date**: 2024-01-01\n\n## Attendees\n\n- Alice\n- Bob\n\n## Discussion\n\nWe discussed #project status.\n\n```python\nprint('hello')\n```", }, } @@ -153,7 +147,6 @@ func TestMarkdownRendererPreservesStructure(t *testing.T) { goldmark.WithExtensions( extension.GFM, extensions.TagExtension, - extensions.WikilinkExtension, ), ) @@ -162,7 +155,6 @@ func TestMarkdownRendererPreservesStructure(t *testing.T) { "**Bold** and *italic*", "- List\n- Items", "#tag #another", - "[[wikilink]]", "> Quote", } diff --git a/server/router/api/v1/test/test_helper.go b/server/router/api/v1/test/test_helper.go index fa4a18b18..eb9ef93b2 100644 --- a/server/router/api/v1/test/test_helper.go +++ b/server/router/api/v1/test/test_helper.go @@ -39,7 +39,6 @@ func NewTestService(t *testing.T) *TestService { secret := "test-secret" markdownService := markdown.NewService( markdown.WithTagExtension(), - markdown.WithWikilinkExtension(), ) service := &apiv1.APIV1Service{ Secret: secret, diff --git a/server/router/api/v1/v1.go b/server/router/api/v1/v1.go index 24820d7bf..a8b7ecc57 100644 --- a/server/router/api/v1/v1.go +++ b/server/router/api/v1/v1.go @@ -45,7 +45,6 @@ func NewAPIV1Service(secret string, profile *profile.Profile, store *store.Store grpc.EnableTracing = true markdownService := markdown.NewService( markdown.WithTagExtension(), - markdown.WithWikilinkExtension(), ) apiv1Service := &APIV1Service{ Secret: secret, diff --git a/web/MARKDOWN_STYLE_GUIDE.md b/web/MARKDOWN_STYLE_GUIDE.md new file mode 100644 index 000000000..03a67d0d6 --- /dev/null +++ b/web/MARKDOWN_STYLE_GUIDE.md @@ -0,0 +1,153 @@ +# Markdown Styling Guide + +This document describes the compact markdown styling approach used in this codebase. + +## Design Principles + +Our markdown rendering uses compact spacing optimized for memos and notes: + +### 1. **Scoped Styles** +All markdown styles are scoped to `.markdown-content` to avoid global pollution: +```css +.markdown-content p { /* scoped */ } +``` + +### 2. **Compact Block Spacing** +All block elements use **8px (0.5rem)** bottom margin: +- Paragraphs +- Lists (ul, ol) +- Code blocks (pre) +- Blockquotes +- Tables +- Horizontal rules + +This is more compact than GitHub's standard (16px) but maintains readability for memo-style content. + +### 3. **First/Last Child Normalization** +```css +.markdown-content > :first-child { + margin-top: 0 !important; +} + +.markdown-content > :last-child { + margin-bottom: 0 !important; +} +``` + +This prevents double margins at container boundaries. + +### 4. **Nested Element Spacing** +Nested elements (lists within lists, paragraphs within lists) use **minimal spacing** (2px/0.125rem): +```css +.markdown-content li > ul { + margin-top: 0.125rem; + margin-bottom: 0.125rem; +} +``` + +### 5. **Heading Separation** +Headings have moderate top margins (12px/0.75rem) to create visual sections: +```css +.markdown-content h1, +.markdown-content h2 { + margin-top: 0.75rem; + margin-bottom: 0.5rem; +} +``` + +### 6. **No White-Space Preservation** +We do NOT use `white-space: pre-line`. Spacing is controlled entirely by CSS margins, matching how GitHub/ChatGPT/Claude work. + +## Component Architecture + +We use a **hybrid approach**: + +### CSS-Based (for standard elements) +```tsx +
+ + {content} + +
+``` + +Standard elements (p, ul, ol, h1-h6, etc.) are styled via CSS. + +### Component-Based (for custom elements) +```tsx + + {content} + +``` + +Custom elements use React components for interactivity. + +## Comparison with Industry Standards + +| Feature | GitHub | ChatGPT | Claude | Memos (ours) | +|---------|--------|---------|--------|--------------| +| Block margin | 16px | 16px | 16px | 8px (compact) ⚡ | +| Scoped styles | `.markdown-body` | `.prose` | Custom | `.markdown-content` ✅ | +| First/last normalization | ✅ | ✅ | ✅ | ✅ | +| Heading underlines (h1/h2) | ✅ | ❌ | ❌ | ✅ | +| Custom components | Few | Many | Many | Some ✅ | +| Line height | 1.6 | 1.6 | 1.6 | 1.5 (compact) ⚡ | +| List padding | 2em | 2em | 2em | 1.5em (compact) ⚡ | +| Code block padding | 16px | 16px | 16px | 8-12px (compact) ⚡ | + +**Note:** Our compact spacing is optimized for memo/note-taking apps where screen real estate is important. + +## Examples + +### Input +```markdown +1312 + +* 123123 +``` + +### Rendering +- Paragraph "1312" with `margin-bottom: 0.5rem` (8px) +- List with `margin-top: 0` (normalized) +- Result: Single 8px gap between paragraph and list ✅ + +### Before (with `white-space: pre-line`) +``` +1312 +[blank line from preserved \n\n] +[16px margin] +* 123123 +``` +Result: Double spacing ❌ + +### After (compact spacing, no white-space preservation) +``` +1312 +[8px margin only] +* 123123 +``` +Result: Clean, compact single spacing ✅ + +## Testing + +To verify correct rendering: + +1. **Text followed by list**: `"text\n\n* item"` → single 8px gap +2. **List followed by text**: `"* item\n\ntext"` → single 8px gap +3. **Nested lists**: Should have minimal spacing (2px) +4. **Headings**: Should have 12px top margin (except first child) +5. **Blockquotes**: Should handle nested content properly +6. **Code blocks**: Should have 8-12px padding (compact) +7. **Tables**: Should have compact cell padding (4px vertical, 8px horizontal) + +## References + +- [CommonMark Spec](https://spec.commonmark.org/) +- [GitHub Flavored Markdown Spec](https://github.github.com/gfm/) +- [GitHub Markdown CSS](https://github.com/sindresorhus/github-markdown-css) +- [Tailwind Typography](https://tailwindcss.com/docs/typography-plugin) diff --git a/web/package.json b/web/package.json index 5f5b99bbf..549463ae2 100644 --- a/web/package.json +++ b/web/package.json @@ -56,7 +56,6 @@ "react-use": "^17.6.0", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", - "remark-wiki-link": "^2.0.1", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.13", "textarea-caret": "^3.1.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index eb15fd8bf..b4f701002 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -152,9 +152,6 @@ importers: remark-gfm: specifier: ^4.0.1 version: 4.0.1 - remark-wiki-link: - specifier: ^2.0.1 - version: 2.0.1 tailwind-merge: specifier: ^3.3.1 version: 3.3.1 @@ -1748,21 +1745,12 @@ packages: character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} - character-entities-legacy@1.1.4: - resolution: {integrity: sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==} - character-entities-legacy@3.0.0: resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} - character-entities@1.2.4: - resolution: {integrity: sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==} - character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} - character-reference-invalid@1.1.4: - resolution: {integrity: sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==} - character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} @@ -2494,15 +2482,9 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} - is-alphabetical@1.0.4: - resolution: {integrity: sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==} - is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} - is-alphanumerical@1.0.4: - resolution: {integrity: sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==} - is-alphanumerical@2.0.1: resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} @@ -2541,9 +2523,6 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} - is-decimal@1.0.4: - resolution: {integrity: sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==} - is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} @@ -2563,9 +2542,6 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} - is-hexadecimal@1.0.4: - resolution: {integrity: sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==} - is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} @@ -2811,9 +2787,6 @@ packages: long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} - longest-streak@2.0.4: - resolution: {integrity: sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg==} - longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -2883,21 +2856,12 @@ packages: mdast-util-to-hast@13.2.0: resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==} - mdast-util-to-markdown@0.6.5: - resolution: {integrity: sha512-XeV9sDE7ZlOQvs45C9UKMtfTcctcaj/pGwH8YLbMHoMOXNNCn2LsqVQOqrF1+/NU8lKDAqozme9SCXWyo9oAcQ==} - mdast-util-to-markdown@2.1.2: resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} - mdast-util-to-string@2.0.0: - resolution: {integrity: sha512-AW4DRS3QbBayY/jJmD8437V1Gombjf8RSOUCMFBuo5iHi58AGEgVCKQ+ezHkZZDpAQS75hcBMpLqjpJTjtUL7w==} - mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} - mdast-util-wiki-link@0.1.2: - resolution: {integrity: sha512-DTcDyOxKDo3pB3fc0zQlD8myfQjYkW4hazUKI9PUyhtoj9JBeHC2eIdlVXmaT22bZkFAVU2d47B6y2jVKGoUQg==} - mdn-data@2.0.14: resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} @@ -2932,9 +2896,6 @@ packages: micromark-extension-gfm@3.0.0: resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} - micromark-extension-wiki-link@0.0.4: - resolution: {integrity: sha512-dJc8AfnoU8BHkN+7fWZvIS20SMsMS1ZlxQUn6We67MqeKbOiEDZV5eEvCpwqGBijbJbxX3Kxz879L4K9HIiOvw==} - micromark-factory-destination@2.0.1: resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} @@ -3120,9 +3081,6 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} - parse-entities@2.0.0: - resolution: {integrity: sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==} - parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -3372,13 +3330,6 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} - remark-wiki-link@2.0.1: - resolution: {integrity: sha512-F8Eut1E7GWfFm4ZDTI6/4ejeZEHZgnVk6E933Yqd/ssYsc4AyI32aGakxwsGcEzbbE7dkWi1EfLlGAdGgOZOsA==} - - repeat-string@1.6.1: - resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} - engines: {node: '>=0.10'} - resize-observer-polyfill@1.5.1: resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} @@ -3878,9 +3829,6 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zwitch@1.0.5: - resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==} - zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -5404,16 +5352,10 @@ snapshots: character-entities-html4@2.1.0: {} - character-entities-legacy@1.1.4: {} - character-entities-legacy@3.0.0: {} - character-entities@1.2.4: {} - character-entities@2.0.2: {} - character-reference-invalid@1.1.4: {} - character-reference-invalid@2.0.1: {} chevrotain-allstar@0.3.1(chevrotain@11.0.3): @@ -6345,15 +6287,8 @@ snapshots: internmap@2.0.3: {} - is-alphabetical@1.0.4: {} - is-alphabetical@2.0.1: {} - is-alphanumerical@1.0.4: - dependencies: - is-alphabetical: 1.0.4 - is-decimal: 1.0.4 - is-alphanumerical@2.0.1: dependencies: is-alphabetical: 2.0.1 @@ -6401,8 +6336,6 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 - is-decimal@1.0.4: {} - is-decimal@2.0.1: {} is-extglob@2.1.1: {} @@ -6422,8 +6355,6 @@ snapshots: dependencies: is-extglob: 2.1.1 - is-hexadecimal@1.0.4: {} - is-hexadecimal@2.0.1: {} is-map@2.0.3: {} @@ -6630,8 +6561,6 @@ snapshots: long@5.3.2: {} - longest-streak@2.0.4: {} - longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -6793,15 +6722,6 @@ snapshots: unist-util-visit: 5.0.0 vfile: 6.0.3 - mdast-util-to-markdown@0.6.5: - dependencies: - '@types/unist': 2.0.11 - longest-streak: 2.0.4 - mdast-util-to-string: 2.0.0 - parse-entities: 2.0.0 - repeat-string: 1.6.1 - zwitch: 1.0.5 - mdast-util-to-markdown@2.1.2: dependencies: '@types/mdast': 4.0.4 @@ -6814,17 +6734,10 @@ snapshots: unist-util-visit: 5.0.0 zwitch: 2.0.4 - mdast-util-to-string@2.0.0: {} - mdast-util-to-string@4.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-wiki-link@0.1.2: - dependencies: - '@babel/runtime': 7.28.4 - mdast-util-to-markdown: 0.6.5 - mdn-data@2.0.14: {} merge2@1.4.1: {} @@ -6931,10 +6844,6 @@ snapshots: micromark-util-combine-extensions: 2.0.1 micromark-util-types: 2.0.2 - micromark-extension-wiki-link@0.0.4: - dependencies: - '@babel/runtime': 7.28.4 - micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -7188,15 +7097,6 @@ snapshots: dependencies: callsites: 3.1.0 - parse-entities@2.0.0: - dependencies: - character-entities: 1.2.4 - character-entities-legacy: 1.1.4 - character-reference-invalid: 1.1.4 - is-alphanumerical: 1.0.4 - is-decimal: 1.0.4 - is-hexadecimal: 1.0.4 - parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -7487,14 +7387,6 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 - remark-wiki-link@2.0.1: - dependencies: - '@babel/runtime': 7.28.4 - mdast-util-wiki-link: 0.1.2 - micromark-extension-wiki-link: 0.0.4 - - repeat-string@1.6.1: {} - resize-observer-polyfill@1.5.1: {} resolve-from@4.0.0: {} @@ -8052,6 +7944,4 @@ snapshots: yocto-queue@0.1.0: {} - zwitch@1.0.5: {} - zwitch@2.0.4: {} diff --git a/web/src/components/MemoContent/ConditionalComponent.tsx b/web/src/components/MemoContent/ConditionalComponent.tsx index dd5bc5a3d..d1df56edb 100644 --- a/web/src/components/MemoContent/ConditionalComponent.tsx +++ b/web/src/components/MemoContent/ConditionalComponent.tsx @@ -39,15 +39,6 @@ export const createConditionalComponent =

>( * - First checks node.data.mdastType (preserved by remarkPreserveType plugin) * - Falls back to checking HAST properties/className for compatibility */ -export const isWikiLinkNode = (node: any): boolean => { - // Check preserved mdast type first - if (node?.data?.mdastType === "wikiLink") { - return true; - } - // Fallback: check hast properties - return node?.properties?.className?.includes?.("wikilink") || false; -}; - export const isTagNode = (node: any): boolean => { // Check preserved mdast type first if (node?.data?.mdastType === "tagNode") { diff --git a/web/src/components/MemoContent/DefaultLink.tsx b/web/src/components/MemoContent/DefaultLink.tsx deleted file mode 100644 index 21783b303..000000000 --- a/web/src/components/MemoContent/DefaultLink.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from "react"; - -/** - * Default link component for regular markdown links - * - * Handles external links with proper target and rel attributes. - */ - -interface DefaultLinkProps extends React.AnchorHTMLAttributes { - node?: any; // AST node from react-markdown - href?: string; - children?: React.ReactNode; -} - -export const DefaultLink: React.FC = ({ href, children, ...props }) => { - const isExternal = href?.startsWith("http://") || href?.startsWith("https://"); - - return ( - e.stopPropagation()} - > - {children} - - ); -}; diff --git a/web/src/components/MemoContent/MemoContentContext.tsx b/web/src/components/MemoContent/MemoContentContext.tsx index ce4a43a94..e2e36f943 100644 --- a/web/src/components/MemoContent/MemoContentContext.tsx +++ b/web/src/components/MemoContent/MemoContentContext.tsx @@ -4,7 +4,7 @@ import { createContext } from "react"; * Context for MemoContent rendering * * Provides memo metadata and configuration to child components - * Used by custom react-markdown components (TaskListItem, WikiLink, Tag, etc.) + * Used by custom react-markdown components (TaskListItem, Tag, etc.) */ export interface MemoContentContextType { diff --git a/web/src/components/MemoContent/WikiLink.tsx b/web/src/components/MemoContent/WikiLink.tsx deleted file mode 100644 index f979b604b..000000000 --- a/web/src/components/MemoContent/WikiLink.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from "react"; - -/** - * Custom link component for react-markdown wikilinks - * - * Handles [[wikilink]] rendering with custom styling and click behavior. - * The remark-wiki-link plugin converts [[target]] to anchor elements. - * - * Note: This component should only be used for wikilinks. - * Regular links are handled by the default anchor element. - */ - -interface WikiLinkProps extends React.AnchorHTMLAttributes { - node?: any; // AST node from react-markdown - href?: string; - children?: React.ReactNode; -} - -export const WikiLink: React.FC = ({ href, children, ...props }) => { - // Extract target from href - // remark-wiki-link creates hrefs like "#/wiki/target" - const target = href?.replace("#/wiki/", "") || ""; - - const handleClick = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - // TODO: Implement wikilink navigation - // This could navigate to memo detail, show preview, etc. - console.log("Wikilink clicked:", target); - }; - - return ( - - {children} - - ); -}; diff --git a/web/src/components/MemoContent/index.tsx b/web/src/components/MemoContent/index.tsx index c65e74010..508cb44a5 100644 --- a/web/src/components/MemoContent/index.tsx +++ b/web/src/components/MemoContent/index.tsx @@ -3,7 +3,6 @@ import { memo, useEffect, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; import rehypeRaw from "rehype-raw"; import remarkGfm from "remark-gfm"; -import remarkWikiLink from "remark-wiki-link"; import useCurrentUser from "@/hooks/useCurrentUser"; import { cn } from "@/lib/utils"; import { memoStore } from "@/store"; @@ -11,12 +10,10 @@ import { useTranslate } from "@/utils/i18n"; import { remarkPreserveType } from "@/utils/remark-plugins/remark-preserve-type"; import { remarkTag } from "@/utils/remark-plugins/remark-tag"; import { isSuperUser } from "@/utils/user"; -import { createConditionalComponent, isTagNode, isTaskListItemNode, isWikiLinkNode } from "./ConditionalComponent"; -import { DefaultLink } from "./DefaultLink"; +import { createConditionalComponent, isTagNode, isTaskListItemNode } from "./ConditionalComponent"; import { MemoContentContext } from "./MemoContentContext"; import { Tag } from "./Tag"; import { TaskListItem } from "./TaskListItem"; -import { WikiLink } from "./WikiLink"; // MAX_DISPLAY_HEIGHT is the maximum height of the memo content to display in compact mode. const MAX_DISPLAY_HEIGHT = 256; @@ -99,12 +96,11 @@ const MemoContent = observer((props: Props) => { onDoubleClick={onMemoContentDoubleClick} > diff --git a/web/src/index.css b/web/src/index.css index 4bfffde78..190e68b52 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -22,7 +22,7 @@ .markdown-content ul.contains-task-list, .prose ul.contains-task-list { padding: 0 !important; - margin: 0.5rem 0 !important; + margin: 0.25rem 0 !important; list-style: none !important; margin-block-start: 0 !important; margin-block-end: 0 !important; @@ -48,8 +48,8 @@ .prose ul.contains-task-list li.task-list-item { display: flex !important; align-items: center !important; - gap: 0.5rem !important; - margin: 0.125rem 0 !important; + gap: 0.375rem !important; + margin: 0.0625rem 0 !important; padding: 0 !important; line-height: 1.5rem !important; list-style: none !important; @@ -105,81 +105,111 @@ /* ======================================== * Markdown Content Styles - * Custom minimal styles for markdown rendering + * Compact spacing optimized for memos/notes + * + * Key principles: + * 1. Block elements use 8px (0.5rem) bottom margin (compact) + * 2. First child has no top margin, last child has no bottom margin + * 3. Nested elements have minimal spacing + * 4. Inline elements have no vertical spacing * ======================================== */ .markdown-content { + font-size: 1rem; + line-height: 1.5; color: var(--foreground); - white-space: pre-line; /* Preserve newlines but collapse multiple spaces */ + word-wrap: break-word; } - /* Block elements should not inherit pre-line */ - .markdown-content h1, - .markdown-content h2, - .markdown-content h3, - .markdown-content h4, - .markdown-content h5, - .markdown-content h6, + /* ======================================== + * First/Last Child Normalization + * Remove boundary spacing to prevent double margins + * ======================================== */ + + .markdown-content > :first-child { + margin-top: 0 !important; + } + + .markdown-content > :last-child { + margin-bottom: 0 !important; + } + + /* ======================================== + * Block Elements + * Compact 8px bottom margin + * ======================================== */ + .markdown-content p, + .markdown-content blockquote, .markdown-content ul, .markdown-content ol, + .markdown-content dl, + .markdown-content table, .markdown-content pre, - .markdown-content blockquote, - .markdown-content table { - white-space: normal; + .markdown-content hr { + margin-top: 0; + margin-bottom: 0.5rem; } - /* Code blocks need pre-wrap */ - .markdown-content pre { - white-space: pre-wrap; + /* ======================================== + * Headings + * Compact spacing for visual separation + * ======================================== */ + + .markdown-content h1, + .markdown-content h2, + .markdown-content h3, + .markdown-content h4, + .markdown-content h5, + .markdown-content h6 { + margin-top: 0.75rem; + margin-bottom: 0.5rem; + font-weight: 600; + line-height: 1.25; } - /* Headings */ .markdown-content h1 { - font-size: 1.875rem; + font-size: 2em; font-weight: 700; - line-height: 2.25rem; - margin-top: 1rem; - margin-bottom: 0.5rem; + border-bottom: 1px solid var(--border); + padding-bottom: 0.25rem; } .markdown-content h2 { - font-size: 1.5rem; - font-weight: 600; - line-height: 2rem; - margin-top: 0.75rem; - margin-bottom: 0.5rem; + font-size: 1.5em; + border-bottom: 1px solid var(--border); + padding-bottom: 0.25rem; } .markdown-content h3 { - font-size: 1.25rem; - font-weight: 600; - line-height: 1.75rem; - margin-top: 0.75rem; - margin-bottom: 0.5rem; + font-size: 1.25em; } - .markdown-content h4, - .markdown-content h5, - .markdown-content h6 { - font-size: 1.125rem; - font-weight: 600; - line-height: 1.75rem; - margin-top: 0.5rem; - margin-bottom: 0.5rem; + .markdown-content h4 { + font-size: 1em; } - /* First heading has no top margin */ - .markdown-content > :first-child { - margin-top: 0; + .markdown-content h5 { + font-size: 0.875em; } - /* Paragraphs */ + .markdown-content h6 { + font-size: 0.85em; + color: var(--muted-foreground); + } + + /* ======================================== + * Paragraphs + * ======================================== */ + .markdown-content p { - margin: 0.5rem 0; + line-height: 1.5; } - /* Links */ + /* ======================================== + * Links + * ======================================== */ + .markdown-content a { color: var(--primary); text-decoration: underline; @@ -190,10 +220,12 @@ opacity: 0.8; } - /* Lists - MINIMAL spacing */ + /* ======================================== + * Lists + * ======================================== */ + .markdown-content ul, .markdown-content ol { - margin: 0.5rem 0; padding-left: 1.5em; list-style-position: outside; } @@ -207,25 +239,34 @@ } .markdown-content li { - margin: 0.125rem 0; + margin-top: 0.125rem; line-height: 1.5; } + .markdown-content li > p { + margin-bottom: 0.125rem; + } + + /* Nested lists should have minimal spacing */ + .markdown-content li > ul, + .markdown-content li > ol { + margin-top: 0.125rem; + margin-bottom: 0.125rem; + } + + /* First and last items in lists */ .markdown-content li:first-child { margin-top: 0; } - .markdown-content li:last-child { - margin-bottom: 0; + .markdown-content li + li { + margin-top: 0.125rem; } - /* Nested lists */ - .markdown-content li > ul, - .markdown-content li > ol { - margin: 0.25rem 0; - } + /* ======================================== + * Code (inline and blocks) + * ======================================== */ - /* Code */ .markdown-content code { font-family: var(--font-mono); font-size: 0.875em; @@ -238,76 +279,120 @@ font-family: var(--font-mono); font-size: 0.875rem; background: var(--muted); - padding: 0.75rem 1rem; - border-radius: 0.5rem; + padding: 0.5rem 0.75rem; + border-radius: 0.375rem; overflow-x: auto; - margin: 0.75rem 0; + white-space: pre-wrap; + word-wrap: break-word; } .markdown-content pre code { background: none; padding: 0; font-size: inherit; + border-radius: 0; } - /* Blockquotes */ + /* ======================================== + * Blockquotes + * ======================================== */ + .markdown-content blockquote { - border-left: 3px solid var(--border); - padding-left: 1rem; - margin: 0.75rem 0; + padding: 0 0.75rem; color: var(--muted-foreground); - font-style: italic; + border-left: 0.25rem solid var(--border); } - /* Horizontal rule */ + .markdown-content blockquote > :first-child { + margin-top: 0; + } + + .markdown-content blockquote > :last-child { + margin-bottom: 0; + } + + /* ======================================== + * Horizontal Rules + * ======================================== */ + .markdown-content hr { - border: none; - border-top: 1px solid var(--border); - margin: 1rem 0; + height: 0.25em; + padding: 0; + background: transparent; + border: 0; + border-bottom: 1px solid var(--border); } - /* Tables */ + /* ======================================== + * Tables + * ======================================== */ + .markdown-content table { + display: block; width: 100%; + width: max-content; + max-width: 100%; + overflow: auto; + border-spacing: 0; border-collapse: collapse; - margin: 0.75rem 0; } - .markdown-content th, - .markdown-content td { + .markdown-content table th, + .markdown-content table td { + padding: 0.25rem 0.5rem; border: 1px solid var(--border); - padding: 0.5rem; - text-align: left; } - .markdown-content th { - background: var(--muted); + .markdown-content table th { font-weight: 600; + background: var(--muted); + } + + .markdown-content table tr { + background: transparent; + border-top: 1px solid var(--border); } - /* Images */ + .markdown-content table tr:nth-child(2n) { + background: var(--muted); + opacity: 0.5; + } + + /* ======================================== + * Images + * ======================================== */ + .markdown-content img { max-width: 100%; height: auto; border-radius: 0.5rem; - margin: 0.75rem 0; } - /* Strong/Bold */ + /* ======================================== + * Inline Elements + * No vertical spacing + * ======================================== */ + .markdown-content strong { font-weight: 600; } - /* Emphasis/Italic */ .markdown-content em { font-style: italic; } - /* Inline elements shouldn't add vertical spacing */ .markdown-content code, .markdown-content strong, .markdown-content em, .markdown-content a { vertical-align: baseline; } + + /* ======================================== + * Strikethrough (GFM) + * ======================================== */ + + .markdown-content del { + text-decoration: line-through; + } } diff --git a/web/src/utils/remark-plugins/remark-tag.ts b/web/src/utils/remark-plugins/remark-tag.ts index a051dc163..4f1866c6d 100644 --- a/web/src/utils/remark-plugins/remark-tag.ts +++ b/web/src/utils/remark-plugins/remark-tag.ts @@ -11,10 +11,11 @@ import { visit } from "unist-util-visit"; * #work → #work * #2024_plans → #2024_plans * #work-notes → #work-notes + * #tag1/subtag/subtag2 → #tag1/subtag/subtag2 * * Rules: - * - Tag must start with # followed by alphanumeric, underscore, or hyphen - * - Tag ends at whitespace, punctuation (except - and _), or end of line + * - Tag must start with # followed by alphanumeric, underscore, hyphen, or forward slash + * - Tag ends at whitespace, punctuation (except -, _, /), or end of line * - Tags at start of line after ## are headings, not tags */ @@ -22,7 +23,7 @@ import { visit } from "unist-util-visit"; * Check if character is valid for tag content */ function isTagChar(char: string): boolean { - return /[a-zA-Z0-9_-]/.test(char); + return /[a-zA-Z0-9_\-/]/.test(char); } /**