Editor: font-size presets + image insert/export support

Two editor features that were in flight alongside the sound work:
- FontSize TipTap extension (rides on textStyle) with Small/Normal/Large/
  Title presets in the toolbar; StatusBar + CSS support
- Image handling: internal/images handler, upload route + config, client
  API, EditorCore wiring, and md/html/docx export support for images

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-26 09:05:40 -07:00
parent 58c326d0cf
commit 6e6e4edce7
16 changed files with 1301 additions and 35 deletions

View File

@@ -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{`<a href="https://petal.test">site</a>`, "<mark>lit</mark>", `<img src="/api/images/abc123.png" alt="a cat">`, "<th>Name</th>", "<td>Mei</td>"} {
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{"<w:tbl>", "Name", "年龄", "[a cat]", "<w:highlight"} {
if !strings.Contains(docXML, want) {
t.Fatalf("docx document.xml missing %q", want)
}
}
}
func TestExportUnsupportedFormat(t *testing.T) {
srv := newTestServer(t)
id := newDoc(t, srv)