diff --git a/.env.example b/.env.example index 527f21c..2024024 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,9 @@ BASE_URL=http://localhost:8080 # Database (SQLite, pure-Go modernc — no cgo) DATABASE_PATH=./data/petal.db +# On-disk store for images pasted/dropped/inserted in the editor +IMAGE_DIR=./data/images + # LLM LLM_BACKEND=vllm # vllm | ollama LLM_ENDPOINT=http://localhost:8000 # vLLM :8000, Ollama :11434 diff --git a/cmd/server/main.go b/cmd/server/main.go index bea33cc..ff90ceb 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -15,6 +15,7 @@ import ( "gitea.parodia.dev/drwily/petal/internal/config" "gitea.parodia.dev/drwily/petal/internal/db" "gitea.parodia.dev/drwily/petal/internal/docs" + "gitea.parodia.dev/drwily/petal/internal/images" "gitea.parodia.dev/drwily/petal/internal/lexicon" "gitea.parodia.dev/drwily/petal/internal/llm" "gitea.parodia.dev/drwily/petal/internal/suggestions" @@ -80,6 +81,13 @@ func main() { lex := lexicon.NewHandler() api.Mount("/word", lex.Routes()) api.Mount("/gloss", lex.GlossRoutes()) + + // Editor image uploads, stored on disk and served back by content hash. + imgHandler, err := images.New(cfg.ImageDir) + if err != nil { + log.Fatalf("image store: %v", err) + } + api.Mount("/images", imgHandler.Routes()) }) // Everything else: serve the embedded SPA (with index.html fallback for client routing). diff --git a/internal/config/config.go b/internal/config/config.go index 9d46a8c..cecbd2b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,6 +11,7 @@ type Config struct { Port string BaseURL string DatabasePath string + ImageDir string // on-disk store for editor image uploads // LLM LLMBackend string // "vllm" | "ollama" @@ -32,6 +33,7 @@ func Load() *Config { Port: env("PORT", "8080"), BaseURL: env("BASE_URL", "http://localhost:8080"), DatabasePath: env("DATABASE_PATH", "./data/petal.db"), + ImageDir: env("IMAGE_DIR", "./data/images"), LLMBackend: env("LLM_BACKEND", "vllm"), LLMEndpoint: env("LLM_ENDPOINT", "http://localhost:8000"), diff --git a/internal/docs/export.go b/internal/docs/export.go index 37abd1b..fd4622e 100644 --- a/internal/docs/export.go +++ b/internal/docs/export.go @@ -92,7 +92,8 @@ type pmNode struct { } type pmMark struct { - Type string `json:"type"` + Type string `json:"type"` + Attrs map[string]any `json:"attrs"` } // parseDoc decodes Document.Content into a node tree. On empty or malformed JSON @@ -131,6 +132,29 @@ func (n pmNode) hasMark(t string) bool { return false } +// attrStr returns a string-valued attribute (e.g. an image src), or "". +func (n pmNode) attrStr(key string) string { + if n.Attrs == nil { + return "" + } + if s, ok := n.Attrs[key].(string); ok { + return s + } + return "" +} + +// markAttr returns a string attribute from the named mark (e.g. a link href). +func (n pmNode) markAttr(markType, key string) string { + for _, m := range n.Marks { + if m.Type == markType && m.Attrs != nil { + if s, ok := m.Attrs[key].(string); ok { + return s + } + } + } + return "" +} + func (n pmNode) level() int { if n.Attrs == nil { return 1 @@ -173,6 +197,11 @@ func mdBlock(n pmNode, depth int) string { return "```\n" + textContent(n) + "\n```" case "horizontalRule": return "---" + case "image": + alt := n.attrStr("alt") + return fmt.Sprintf("![%s](%s)", alt, n.attrStr("src")) + case "table": + return mdTable(n) case "bulletList", "orderedList": var items []string for i, item := range n.Content { @@ -213,6 +242,55 @@ func mdInline(nodes []pmNode) string { return b.String() } +// mdTable renders a table node as a GitHub-flavored Markdown table. The first +// row is used as the header (Markdown tables require one); a separator row is +// inserted after it. Cell text is flattened to inline Markdown. +func mdTable(n pmNode) string { + var rows [][]string + for _, row := range n.Content { + if row.Type != "tableRow" { + continue + } + var cells []string + for _, cell := range row.Content { + // A cell holds block children (usually one paragraph); flatten them. + var parts []string + for _, c := range cell.Content { + parts = append(parts, mdInline(c.Content)) + } + // Escape pipes so cell content doesn't break the column layout. + cells = append(cells, strings.ReplaceAll(strings.TrimSpace(strings.Join(parts, " ")), "|", "\\|")) + } + rows = append(rows, cells) + } + if len(rows) == 0 { + return "" + } + cols := 0 + for _, r := range rows { + if len(r) > cols { + cols = len(r) + } + } + pad := func(r []string) string { + for len(r) < cols { + r = append(r, "") + } + return "| " + strings.Join(r, " | ") + " |" + } + var lines []string + lines = append(lines, pad(rows[0])) + sep := make([]string, cols) + for i := range sep { + sep[i] = "---" + } + lines = append(lines, "| "+strings.Join(sep, " | ")+" |") + for _, r := range rows[1:] { + lines = append(lines, pad(r)) + } + return strings.Join(lines, "\n") +} + func applyMdMarks(n pmNode) string { t := n.Text if n.hasMark("code") { @@ -230,6 +308,9 @@ func applyMdMarks(n pmNode) string { if n.hasMark("underline") { t = "" + t + "" } + if href := n.markAttr("link", "href"); href != "" { + t = "[" + t + "](" + href + ")" + } return t } @@ -347,6 +428,11 @@ func htmlBlock(n pmNode) string { return "
" + htmlEscape(textContent(n)) + "
\n" case "horizontalRule": return "
\n" + case "image": + alt := htmlEscape(n.attrStr("alt")) + return fmt.Sprintf("

\"%s\"

\n", htmlEscape(n.attrStr("src")), alt) + case "table": + return htmlTable(n) case "bulletList", "orderedList": tag := "ul" if n.Type == "orderedList" { @@ -376,6 +462,38 @@ func htmlBlock(n pmNode) string { } } +// htmlTable renders a table node as an HTML . tableHeader cells become +// ", ""} { + if !strings.Contains(html, want) { + t.Fatalf("html missing %q in:\n%s", want, html) + } + } + + docx := do(t, srv, http.MethodGet, "/"+id+"/export?format=docx", "") + raw := docx.Body.Bytes() + zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + if err != nil { + t.Fatalf("docx not a zip: %v", err) + } + var docXML string + for _, f := range zr.File { + if f.Name == "word/document.xml" { + rc, _ := f.Open() + b, _ := io.ReadAll(rc) + rc.Close() + docXML = string(b) + } + } + for _, want := range []string{"", "Name", "年龄", "[a cat]", " 512 { + head = head[:512] + } + return strings.Contains(strings.ToLower(string(head)), " req(`/search?q=${encodeURIComponent(q)}`), + // Image upload: posts a single image file as multipart form data and returns + // its served URL (e.g. /api/images/.png). Stored on disk by the binary, + // so the document JSON keeps a small URL reference instead of inlined base64. + // Doesn't go through req() because the body is FormData, not JSON. + uploadImage: async (file: File): Promise<{ url: string }> => { + const form = new FormData() + form.append('image', file) + const res = await fetch('/api/images', { method: 'POST', body: form }) + if (!res.ok) { + const detail = await res.text().catch(() => '') + throw new Error(`${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`) + } + return res.json() as Promise<{ url: string }> + }, + // Tags. listTags returns the roster with per-tag document counts; createTag is // idempotent on name (returns the existing tag if it already exists); // updateTag recolors/renames; deleteTag removes it (assignments cascade). diff --git a/web/src/components/Editor/EditorCore.tsx b/web/src/components/Editor/EditorCore.tsx index 2dacb42..a69d65b 100644 --- a/web/src/components/Editor/EditorCore.tsx +++ b/web/src/components/Editor/EditorCore.tsx @@ -4,6 +4,17 @@ import Underline from '@tiptap/extension-underline' import TextAlign from '@tiptap/extension-text-align' import Placeholder from '@tiptap/extension-placeholder' import CharacterCount from '@tiptap/extension-character-count' +import Link from '@tiptap/extension-link' +import { Color } from '@tiptap/extension-color' +import TextStyle from '@tiptap/extension-text-style' +import Highlight from '@tiptap/extension-highlight' +import Image from '@tiptap/extension-image' +import Table from '@tiptap/extension-table' +import TableRow from '@tiptap/extension-table-row' +import TableHeader from '@tiptap/extension-table-header' +import TableCell from '@tiptap/extension-table-cell' +import { FontSize } from './FontSize' +import type { EditorView } from '@tiptap/pm/view' import { useCallback, useEffect, useRef, useState } from 'react' import { Toolbar } from '../Toolbar/Toolbar' import { SuggestionCard } from './SuggestionCard' @@ -103,6 +114,21 @@ function parseDoc(raw: string): object | undefined { return undefined } +// uploadImageInto sends an image file to the store and inserts the returned URL +// as an image node — at `pos` if given (a drop point), otherwise at the current +// selection (a paste). Shared by the paste/drop handlers and the toolbar button. +export async function uploadImageInto(view: EditorView, file: File, pos?: number) { + try { + const { url } = await api.uploadImage(file) + const { schema } = view.state + const node = schema.nodes.image.create({ src: url }) + const at = pos ?? view.state.selection.from + view.dispatch(view.state.tr.insert(at, node)) + } catch (err) { + console.error('image upload failed', err) + } +} + interface HoverState { suggestion: Suggestion top: number @@ -188,6 +214,16 @@ export function EditorCore({ extensions: [ StarterKit, Underline, + TextStyle, + Color, + FontSize, + Highlight.configure({ multicolor: true }), + Link.configure({ openOnClick: false, autolink: true, HTMLAttributes: { rel: 'noopener noreferrer nofollow' } }), + Image.configure({ inline: false, HTMLAttributes: { class: 'petal-image' } }), + Table.configure({ resizable: true, HTMLAttributes: { class: 'petal-table' } }), + TableRow, + TableHeader, + TableCell, TextAlign.configure({ types: ['heading', 'paragraph'] }), Placeholder.configure({ placeholder: 'Start writing…' }), CharacterCount, @@ -197,6 +233,27 @@ export function EditorCore({ content: parseDoc(initialContent), editorProps: { attributes: { class: 'petal-prose focus:outline-none' }, + // Dropping or pasting an image file uploads it and inserts it at the drop + // point (or the cursor for a paste). Returns true to consume the event so + // ProseMirror doesn't also try to handle the raw file. Non-image pastes + // fall through to the default handler. + handleDrop: (view, event) => { + const files = (event as DragEvent).dataTransfer?.files + const image = files && Array.from(files).find((f) => f.type.startsWith('image/')) + if (!image) return false + event.preventDefault() + const coords = view.posAtCoords({ left: (event as DragEvent).clientX, top: (event as DragEvent).clientY }) + uploadImageInto(view, image, coords?.pos) + return true + }, + handlePaste: (view, event) => { + const files = event.clipboardData?.files + const image = files && Array.from(files).find((f) => f.type.startsWith('image/')) + if (!image) return false + event.preventDefault() + uploadImageInto(view, image) + return true + }, }, onFocus: () => onFocusMode?.(), onUpdate: ({ editor }) => { diff --git a/web/src/components/Editor/FontSize.ts b/web/src/components/Editor/FontSize.ts new file mode 100644 index 0000000..006d67b --- /dev/null +++ b/web/src/components/Editor/FontSize.ts @@ -0,0 +1,51 @@ +import { Extension } from '@tiptap/core' + +// FontSize adds a `fontSize` attribute to the textStyle mark so a writer can +// pick a size preset (Small / Normal / Large / Title) from the toolbar. It rides +// on TextStyle (already loaded) rather than introducing a new mark, so it stacks +// cleanly with color and other inline styling. +declare module '@tiptap/core' { + interface Commands { + fontSize: { + setFontSize: (size: string) => ReturnType + unsetFontSize: () => ReturnType + } + } +} + +export const FontSize = Extension.create({ + name: 'fontSize', + + addOptions() { + return { types: ['textStyle'] } + }, + + addGlobalAttributes() { + return [ + { + types: this.options.types, + attributes: { + fontSize: { + default: null, + parseHTML: (element) => element.style.fontSize || null, + renderHTML: (attributes) => + attributes.fontSize ? { style: `font-size: ${attributes.fontSize}` } : {}, + }, + }, + }, + ] + }, + + addCommands() { + return { + setFontSize: + (size) => + ({ chain }) => + chain().setMark('textStyle', { fontSize: size }).run(), + unsetFontSize: + () => + ({ chain }) => + chain().setMark('textStyle', { fontSize: null }).removeEmptyTextStyle().run(), + } + }, +}) diff --git a/web/src/components/StatusBar/SoundToggle.tsx b/web/src/components/StatusBar/SoundToggle.tsx index f3a5d49..e5c296a 100644 --- a/web/src/components/StatusBar/SoundToggle.tsx +++ b/web/src/components/StatusBar/SoundToggle.tsx @@ -24,7 +24,7 @@ export function SoundToggle() { aria-pressed={on} title={on ? '声音开 · Sounds on' : '声音关 · Sounds off'} aria-label={on ? 'Mute Petal sounds' : 'Unmute Petal sounds'} - className="rounded-full px-1.5 py-0.5 transition-colors" + className="flex items-center justify-center rounded-full px-2 py-1 text-xl leading-none transition-colors" style={{ color: on ? 'var(--color-accent)' : 'var(--color-muted)', lineHeight: 1 }} onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-accent-hover)')} onMouseLeave={(e) => diff --git a/web/src/components/StatusBar/StatusBar.tsx b/web/src/components/StatusBar/StatusBar.tsx index 69cec87..4c8f48f 100644 --- a/web/src/components/StatusBar/StatusBar.tsx +++ b/web/src/components/StatusBar/StatusBar.tsx @@ -45,7 +45,7 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing, llmD return (
, tableCell cells become ; each cell's block children are flattened +// to inline HTML. +func htmlTable(n pmNode) string { + var b strings.Builder + b.WriteString("\n") + for _, row := range n.Content { + if row.Type != "tableRow" { + continue + } + b.WriteString("") + for _, cell := range row.Content { + tag := "td" + if cell.Type == "tableHeader" { + tag = "th" + } + var inner strings.Builder + for _, c := range cell.Content { + if c.Type == "paragraph" { + inner.WriteString(htmlInline(c.Content)) + } else { + inner.WriteString(htmlBlock(c)) + } + } + b.WriteString("<" + tag + ">" + inner.String() + "") + } + b.WriteString("\n") + } + b.WriteString("
\n") + return b.String() +} + func htmlInline(nodes []pmNode) string { var b strings.Builder for _, n := range nodes { @@ -408,6 +526,12 @@ func applyHTMLMarks(n pmNode) string { if n.hasMark("strike") { t = "" + t + "" } + if n.hasMark("highlight") { + t = "" + t + "" + } + if href := n.markAttr("link", "href"); href != "" { + t = fmt.Sprintf("%s", htmlEscape(href), t) + } return t } @@ -500,6 +624,16 @@ func docxBlock(n pmNode) string { return b.String() case "horizontalRule": return `` + case "image": + // Image bytes aren't embedded (that needs media parts); leave a labeled + // placeholder so the reader knows an image belonged here. + label := n.attrStr("alt") + if label == "" { + label = "image" + } + return docxPara(pmNode{Content: []pmNode{{Type: "text", Text: "[" + label + "]", Marks: []pmMark{{Type: "italic"}}}}}, "") + case "table": + return docxTable(n) case "bulletList", "orderedList": var b strings.Builder for i, item := range n.Content { @@ -520,6 +654,48 @@ func docxBlock(n pmNode) string { } } +// docxTable renders a table node as a Word table (w:tbl) with single-line +// borders. Header cells get a shaded background; every cell's block children are +// rendered as paragraphs inside the cell. +func docxTable(n pmNode) string { + var b strings.Builder + b.WriteString(`` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + ``) + for _, row := range n.Content { + if row.Type != "tableRow" { + continue + } + b.WriteString("") + for _, cell := range row.Content { + b.WriteString("") + if cell.Type == "tableHeader" { + b.WriteString(``) + } + b.WriteString("") + // A cell must contain at least one paragraph to be valid. + if len(cell.Content) == 0 { + b.WriteString("") + } + for _, c := range cell.Content { + b.WriteString(docxBlock(c)) + } + b.WriteString("") + } + b.WriteString("") + } + b.WriteString("") + // Word needs a paragraph after a table; otherwise consecutive tables merge. + b.WriteString("") + return b.String() +} + // docxPara renders a block's inline children as a Word paragraph. An optional // style name (e.g. "Quote") and an optional literal text prefix (for list // markers) may be supplied. @@ -571,6 +747,15 @@ func docxRun(n pmNode) string { if n.hasMark("strike") { props.WriteString("") } + if n.hasMark("highlight") { + // Word's text highlight only supports a fixed palette of named colors. + props.WriteString(``) + } + if n.markAttr("link", "href") != "" { + // Without hyperlink relationships, style links as blue underlined text so + // they at least read as links (the URL itself is preserved in md/html). + props.WriteString(``) + } rpr := "" if props.Len() > 0 { rpr = "" + props.String() + "" diff --git a/internal/docs/export_test.go b/internal/docs/export_test.go index fbb607c..25fa459 100644 --- a/internal/docs/export_test.go +++ b/internal/docs/export_test.go @@ -126,6 +126,77 @@ func TestExportDocx(t *testing.T) { } } +// richDocJSON2 exercises the newer node/mark types: links, highlight, an image, +// and a table (with a header row). Each export format should carry them through. +const richDocJSON2 = `{"type":"doc","content":[` + + `{"type":"paragraph","content":[` + + `{"type":"text","marks":[{"type":"link","attrs":{"href":"https://petal.test"}}],"text":"site"},` + + `{"type":"text","text":" and "},` + + `{"type":"text","marks":[{"type":"highlight","attrs":{"color":"#FFF1A8"}}],"text":"lit"}` + + `]},` + + `{"type":"image","attrs":{"src":"/api/images/abc123.png","alt":"a cat"}},` + + `{"type":"table","content":[` + + `{"type":"tableRow","content":[` + + `{"type":"tableHeader","content":[{"type":"paragraph","content":[{"type":"text","text":"Name"}]}]},` + + `{"type":"tableHeader","content":[{"type":"paragraph","content":[{"type":"text","text":"年龄"}]}]}` + + `]},` + + `{"type":"tableRow","content":[` + + `{"type":"tableCell","content":[{"type":"paragraph","content":[{"type":"text","text":"Mei"}]}]},` + + `{"type":"tableCell","content":[{"type":"paragraph","content":[{"type":"text","text":"30"}]}]}` + + `]}` + + `]}` + + `]}` + +func seedRichDoc2(t *testing.T, srv http.Handler) string { + t.Helper() + id := newDoc(t, srv) + body := `{"title":"Rich2","content":` + jsonString(richDocJSON2) + `,"content_text":"site and lit\nName 年龄 Mei 30","word_count":6}` + if rec := do(t, srv, http.MethodPut, "/"+id, body); rec.Code != http.StatusOK { + t.Fatalf("seed rich doc 2: code=%d body=%s", rec.Code, rec.Body) + } + return id +} + +func TestExportLinksImagesTables(t *testing.T) { + srv := newTestServer(t) + id := seedRichDoc2(t, srv) + + md := do(t, srv, http.MethodGet, "/"+id+"/export?format=md", "").Body.String() + for _, want := range []string{"[site](https://petal.test)", "![a cat](/api/images/abc123.png)", "| Name | 年龄 |", "| --- | --- |", "| Mei | 30 |"} { + if !strings.Contains(md, want) { + t.Fatalf("markdown missing %q in:\n%s", want, md) + } + } + + html := do(t, srv, http.MethodGet, "/"+id+"/export?format=html", "").Body.String() + for _, want := range []string{`site`, "lit", `a cat`, "
NameMei
without our + configured class, so a `.petal-table` selector would never match. The wrapper + Tiptap adds (.tableWrapper) handles horizontal overflow scroll. */ +.petal-prose .tableWrapper { + overflow-x: auto; +} +.petal-prose table { + border-collapse: collapse; + width: 100%; + margin: 0.4em 0; + overflow: hidden; + border-radius: var(--radius-input); + border: 1px solid var(--color-border); + table-layout: fixed; +} +.petal-prose td, +.petal-prose th { + border: 1px solid var(--color-border); + padding: 0.4em 0.6em; + vertical-align: top; + position: relative; + min-width: 3em; +} +.petal-prose th { + background: var(--color-surface-alt); + font-weight: 700; + text-align: left; +} +/* The cell being edited / the active selection inside a table. */ +.petal-prose .selectedCell::after { + content: ''; + position: absolute; + inset: 0; + background: var(--color-surface-alt); + opacity: 0.5; + pointer-events: none; +} +/* The column-resize handle Tiptap draws between columns. */ +.petal-prose .column-resize-handle { + position: absolute; + right: -2px; + top: 0; + bottom: -2px; + width: 4px; + background: var(--color-accent); + pointer-events: none; +} /* Placeholder shown on the empty first paragraph (Tiptap Placeholder ext). */ .petal-prose p.is-editor-empty:first-child::before { content: attr(data-placeholder);