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

@@ -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

View File

@@ -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).

View File

@@ -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"),

View File

@@ -93,6 +93,7 @@ type pmNode struct {
type pmMark struct {
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 = "<u>" + t + "</u>"
}
if href := n.markAttr("link", "href"); href != "" {
t = "[" + t + "](" + href + ")"
}
return t
}
@@ -347,6 +428,11 @@ func htmlBlock(n pmNode) string {
return "<pre><code>" + htmlEscape(textContent(n)) + "</code></pre>\n"
case "horizontalRule":
return "<hr>\n"
case "image":
alt := htmlEscape(n.attrStr("alt"))
return fmt.Sprintf("<p><img src=\"%s\" alt=\"%s\"></p>\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 <table>. tableHeader cells become
// <th>, tableCell cells become <td>; each cell's block children are flattened
// to inline HTML.
func htmlTable(n pmNode) string {
var b strings.Builder
b.WriteString("<table>\n")
for _, row := range n.Content {
if row.Type != "tableRow" {
continue
}
b.WriteString("<tr>")
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() + "</" + tag + ">")
}
b.WriteString("</tr>\n")
}
b.WriteString("</table>\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 = "<s>" + t + "</s>"
}
if n.hasMark("highlight") {
t = "<mark>" + t + "</mark>"
}
if href := n.markAttr("link", "href"); href != "" {
t = fmt.Sprintf("<a href=\"%s\">%s</a>", htmlEscape(href), t)
}
return t
}
@@ -500,6 +624,16 @@ func docxBlock(n pmNode) string {
return b.String()
case "horizontalRule":
return `<w:p><w:pPr><w:pBdr><w:bottom w:val="single" w:sz="6" w:space="1" w:color="auto"/></w:pBdr></w:pPr></w:p>`
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(`<w:tbl><w:tblPr><w:tblStyle w:val="TableGrid"/><w:tblW w:w="0" w:type="auto"/>` +
`<w:tblBorders>` +
`<w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
`<w:left w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
`<w:bottom w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
`<w:right w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
`<w:insideH w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
`<w:insideV w:val="single" w:sz="4" w:space="0" w:color="auto"/>` +
`</w:tblBorders></w:tblPr>`)
for _, row := range n.Content {
if row.Type != "tableRow" {
continue
}
b.WriteString("<w:tr>")
for _, cell := range row.Content {
b.WriteString("<w:tc><w:tcPr>")
if cell.Type == "tableHeader" {
b.WriteString(`<w:shd w:val="clear" w:color="auto" w:fill="FFF0F5"/>`)
}
b.WriteString("</w:tcPr>")
// A cell must contain at least one paragraph to be valid.
if len(cell.Content) == 0 {
b.WriteString("<w:p/>")
}
for _, c := range cell.Content {
b.WriteString(docxBlock(c))
}
b.WriteString("</w:tc>")
}
b.WriteString("</w:tr>")
}
b.WriteString("</w:tbl>")
// Word needs a paragraph after a table; otherwise consecutive tables merge.
b.WriteString("<w:p/>")
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("<w:strike/>")
}
if n.hasMark("highlight") {
// Word's text highlight only supports a fixed palette of named colors.
props.WriteString(`<w:highlight w:val="yellow"/>`)
}
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(`<w:color w:val="2563EB"/><w:u w:val="single"/>`)
}
rpr := ""
if props.Len() > 0 {
rpr = "<w:rPr>" + props.String() + "</w:rPr>"

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)

132
internal/images/handler.go Normal file
View File

@@ -0,0 +1,132 @@
// Package images implements a tiny content-addressed image store: writers upload
// images from the editor, they're saved to disk under the configured directory,
// and served back by hashed filename. Content addressing means the same image
// pasted twice is stored once, and URLs are stable and cacheable forever.
package images
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/go-chi/chi/v5"
)
// maxUploadBytes caps a single image at 10 MiB — generous for a writing tool,
// small enough to keep a careless paste from filling the disk.
const maxUploadBytes = 10 << 20
// extByContentType maps the image types we accept to a canonical extension. The
// allowlist doubles as validation: anything not here is rejected.
var extByContentType = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
"image/svg+xml": ".svg",
}
// Handler serves the upload + fetch endpoints, backed by a directory on disk.
type Handler struct {
dir string
}
// New constructs a Handler, ensuring the storage directory exists.
func New(dir string) (*Handler, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
return &Handler{dir: dir}, nil
}
// Routes mounts the image endpoints. Mount under "/images" so the full paths are
// POST /api/images (upload) and GET /api/images/{name} (fetch).
func (h *Handler) Routes() chi.Router {
r := chi.NewRouter()
r.Post("/", h.upload)
r.Get("/{name}", h.serve)
return r
}
// upload accepts a single multipart "image" field, sniffs and validates its
// type, and writes it under a content hash so identical images dedupe. Responds
// with the served URL the editor inserts.
func (h *Handler) upload(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
file, _, err := r.FormFile("image")
if err != nil {
http.Error(w, "expected an 'image' file field", http.StatusBadRequest)
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
http.Error(w, "could not read upload", http.StatusBadRequest)
return
}
// Trust a sniff over the client-declared type. SVG isn't reliably sniffable
// (DetectContentType returns text/plain or text/xml), so fall back to a
// lightweight tag check for it.
ct := http.DetectContentType(data)
ext, ok := extByContentType[ct]
if !ok {
if looksLikeSVG(data) {
ext, ok = ".svg", true
}
}
if !ok {
http.Error(w, "unsupported image type", http.StatusUnsupportedMediaType)
return
}
sum := sha256.Sum256(data)
name := hex.EncodeToString(sum[:])[:32] + ext
path := filepath.Join(h.dir, name)
// Skip the write if this exact content is already stored.
if _, statErr := os.Stat(path); errors.Is(statErr, os.ErrNotExist) {
if err := os.WriteFile(path, data, 0o644); err != nil {
http.Error(w, "could not store image", http.StatusInternalServerError)
return
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"url": "/api/images/" + name})
}
// serve returns a stored image by its hashed filename. The filename is validated
// to be a bare name (no path separators) so it can't escape the storage dir, and
// served with a long-lived cache header since content-addressed URLs never change.
func (h *Handler) serve(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, `/\`) {
http.NotFound(w, r)
return
}
path := filepath.Join(h.dir, filepath.Base(name))
if _, err := os.Stat(path); err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
http.ServeFile(w, r, path)
}
// looksLikeSVG does a cheap check for an <svg root tag near the start of the
// file, since DetectContentType doesn't recognize SVG.
func looksLikeSVG(data []byte) bool {
head := data
if len(head) > 512 {
head = head[:512]
}
return strings.Contains(strings.ToLower(string(head)), "<svg")
}

View File

@@ -0,0 +1,97 @@
package images
import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// a 1x1 transparent PNG.
var pngBytes = []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
0x42, 0x60, 0x82,
}
func uploadReq(t *testing.T, field string, data []byte) *http.Request {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, err := mw.CreateFormFile(field, "x.png")
if err != nil {
t.Fatal(err)
}
fw.Write(data)
mw.Close()
req := httptest.NewRequest(http.MethodPost, "/", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
return req
}
func TestUploadAndServe(t *testing.T) {
h, err := New(t.TempDir())
if err != nil {
t.Fatal(err)
}
r := h.Routes()
// Upload a PNG → expect a JSON url under /api/images/.
rec := httptest.NewRecorder()
r.ServeHTTP(rec, uploadReq(t, "image", pngBytes))
if rec.Code != http.StatusOK {
t.Fatalf("upload code=%d body=%s", rec.Code, rec.Body)
}
var resp struct{ URL string }
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(resp.URL, "/api/images/") || !strings.HasSuffix(resp.URL, ".png") {
t.Fatalf("unexpected url %q", resp.URL)
}
// The same content uploaded again dedupes to the same URL.
rec2 := httptest.NewRecorder()
r.ServeHTTP(rec2, uploadReq(t, "image", pngBytes))
var resp2 struct{ URL string }
json.Unmarshal(rec2.Body.Bytes(), &resp2)
if resp2.URL != resp.URL {
t.Fatalf("expected dedup to same url, got %q vs %q", resp2.URL, resp.URL)
}
// Fetch it back.
name := strings.TrimPrefix(resp.URL, "/api/images/")
rec3 := httptest.NewRecorder()
r.ServeHTTP(rec3, httptest.NewRequest(http.MethodGet, "/"+name, nil))
if rec3.Code != http.StatusOK {
t.Fatalf("serve code=%d", rec3.Code)
}
if !bytes.Equal(rec3.Body.Bytes(), pngBytes) {
t.Fatal("served bytes differ from uploaded")
}
}
func TestUploadRejectsNonImage(t *testing.T) {
h, _ := New(t.TempDir())
r := h.Routes()
rec := httptest.NewRecorder()
r.ServeHTTP(rec, uploadReq(t, "image", []byte("this is plainly not an image at all")))
if rec.Code != http.StatusUnsupportedMediaType {
t.Fatalf("expected 415, got %d", rec.Code)
}
}
func TestServeMissing(t *testing.T) {
h, _ := New(t.TempDir())
r := h.Routes()
rec := httptest.NewRecorder()
r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/deadbeef.png", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rec.Code)
}
}

124
web/package-lock.json generated
View File

@@ -9,7 +9,15 @@
"version": "0.0.0",
"dependencies": {
"@tiptap/extension-character-count": "^2.11.5",
"@tiptap/extension-color": "^2.27.2",
"@tiptap/extension-highlight": "^2.27.2",
"@tiptap/extension-image": "^2.27.2",
"@tiptap/extension-link": "^2.27.2",
"@tiptap/extension-placeholder": "^2.11.5",
"@tiptap/extension-table": "^2.27.2",
"@tiptap/extension-table-cell": "^2.27.2",
"@tiptap/extension-table-header": "^2.27.2",
"@tiptap/extension-table-row": "^2.27.2",
"@tiptap/extension-text-align": "^2.11.5",
"@tiptap/extension-underline": "^2.11.5",
"@tiptap/pm": "^2.11.5",
@@ -1568,6 +1576,20 @@
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-color": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-2.27.2.tgz",
"integrity": "sha512-sOKCP8/2V3sRM3FdWgMe1lFE5ewsWNCRafiVoujS1+TTHGCj4jw6W+LiumBUk7cRI8kXW/rqGWVC4RVdknYUCA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/extension-text-style": "^2.7.0"
}
},
"node_modules/@tiptap/extension-document": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.2.tgz",
@@ -1652,6 +1674,19 @@
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-highlight": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-2.27.2.tgz",
"integrity": "sha512-ZjlktDdMjruMJFAVz0TbQf0v92Jqkc7Ri1iZJqBXuLid+r+GxUzl2CVAV7qq5yagkGQgvAG+WGsMk880HgR3MA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-history": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.2.tgz",
@@ -1680,6 +1715,19 @@
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-image": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.27.2.tgz",
"integrity": "sha512-5zL/BY41FIt72azVrCrv3n+2YJ/JyO8wxCcA4Dk1eXIobcgVyIdo4rG39gCqIOiqziAsqnqoj12QHTBtHsJ6mQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz",
@@ -1693,6 +1741,23 @@
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-link": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.27.2.tgz",
"integrity": "sha512-bnP61qkr0Kj9Cgnop1hxn2zbOCBzNtmawxr92bVTOE31fJv6FhtCnQiD6tuPQVGMYhcmAj7eihtvuEMFfqEPcQ==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz",
@@ -1759,6 +1824,59 @@
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-table": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-2.27.2.tgz",
"integrity": "sha512-pDbhOpT5phZkcsyPjGBQlXv0+0hmdrvqHJ+dJjkGcCtlfy2pHiEIhmIItOFagc7wXy8G9iUFZ9Jie4zvDf+brg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-table-cell": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-2.27.2.tgz",
"integrity": "sha512-9Lk46MjZMFzVZfOj9Kd7VgC6Odt6vmEhlCYVumErShUY7EkFqCw3b2IYoUtQkntfOEx/Afnhff/okNQwPsJeUA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-table-header": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-2.27.2.tgz",
"integrity": "sha512-ZEb6lbG0NbbodWLV0b4BS/QrDIPlUbCcuOsUxzqVvlMUY1Vg6Fj6fKwLaBcsIUDHi8sxZDBEgYEDw3BR/zcO6A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-table-row": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-2.27.2.tgz",
"integrity": "sha512-Nw9+tA56Y5HtLVP01NGCZSUuTQhJPtfK9OfmDgGgcxynn2cRVdEtj+9FNZqRhQ1iRVaAI+Rd4xRvX9qYePMOxw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-text": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.2.tgz",
@@ -2828,6 +2946,12 @@
"uc.micro": "^2.0.0"
}
},
"node_modules/linkifyjs": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
"license": "MIT"
},
"node_modules/lottie-web": {
"version": "5.13.0",
"resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz",

View File

@@ -12,7 +12,15 @@
},
"dependencies": {
"@tiptap/extension-character-count": "^2.11.5",
"@tiptap/extension-color": "^2.27.2",
"@tiptap/extension-highlight": "^2.27.2",
"@tiptap/extension-image": "^2.27.2",
"@tiptap/extension-link": "^2.27.2",
"@tiptap/extension-placeholder": "^2.11.5",
"@tiptap/extension-table": "^2.27.2",
"@tiptap/extension-table-cell": "^2.27.2",
"@tiptap/extension-table-header": "^2.27.2",
"@tiptap/extension-table-row": "^2.27.2",
"@tiptap/extension-text-align": "^2.11.5",
"@tiptap/extension-underline": "^2.11.5",
"@tiptap/pm": "^2.11.5",

View File

@@ -182,6 +182,21 @@ export const api = {
// highlighted snippet. Empty query returns []. Encodes the term for the URL.
search: (q: string) => req<SearchResult[]>(`/search?q=${encodeURIComponent(q)}`),
// Image upload: posts a single image file as multipart form data and returns
// its served URL (e.g. /api/images/<hash>.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).

View File

@@ -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 }) => {

View File

@@ -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<ReturnType> {
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(),
}
},
})

View File

@@ -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) =>

View File

@@ -45,7 +45,7 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing, llmD
return (
<footer
className="flex h-9 shrink-0 items-center gap-3 px-6 text-xs"
className="flex h-11 shrink-0 items-center gap-3 px-6 text-sm"
style={{ borderTop: '1px solid var(--color-border)', color: 'var(--color-muted)' }}
>
<div className="relative" ref={statsRef}>
@@ -54,7 +54,7 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing, llmD
onClick={() => setStatsOpen((o) => !o)}
aria-haspopup="dialog"
aria-expanded={statsOpen}
className="rounded-full px-1.5 py-0.5 font-semibold transition-colors"
className="rounded-full px-2 py-0.5 text-[0.95rem] font-bold transition-colors"
style={{ color: statsOpen ? 'var(--color-accent-hover)' : 'inherit' }}
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-accent-hover)')}
onMouseLeave={(e) =>

View File

@@ -1,5 +1,7 @@
import type { Editor } from '@tiptap/react'
import { useEditorState } from '@tiptap/react'
import { useEffect, useRef, useState } from 'react'
import { uploadImageInto } from '../Editor/EditorCore'
interface Props {
editor: Editor | null
@@ -15,18 +17,21 @@ function TBtn({
active,
disabled,
label,
title,
children,
}: {
onClick: () => void
active?: boolean
disabled?: boolean
label: string
title?: string
children: React.ReactNode
}) {
return (
<button
type="button"
aria-label={label}
title={title ?? label}
aria-pressed={active}
disabled={disabled}
onMouseDown={(e) => e.preventDefault()} // keep editor selection
@@ -47,10 +52,125 @@ const Divider = () => (
<span className="mx-1 h-5 w-px" style={{ background: 'var(--color-border)' }} />
)
// A popover anchored under its trigger. The trigger + panel share a relative
// wrapper; `open`/`onClose` are owned by the toolbar so only one is open at once.
// A pointer-down outside the wrapper closes it.
function Popover({
open,
onClose,
trigger,
children,
width = 200,
}: {
open: boolean
onClose: () => void
trigger: React.ReactNode
children: React.ReactNode
width?: number
}) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) onClose()
}
document.addEventListener('mousedown', onDown)
return () => document.removeEventListener('mousedown', onDown)
}, [open, onClose])
return (
<div ref={ref} className="relative flex items-center">
{trigger}
{open && (
<div
className="absolute left-0 top-full z-40 mt-1.5 p-2"
style={{
width,
borderRadius: 'var(--radius-card)',
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
boxShadow: 'var(--shadow-soft)',
}}
>
{children}
</div>
)}
</div>
)
}
// Text-color swatches. `null` clears the color back to the theme default.
const TEXT_COLORS: { label: string; value: string | null }[] = [
{ label: 'Default', value: null },
{ label: 'Rose', value: '#D98AAF' },
{ label: 'Coral', value: '#E0785C' },
{ label: 'Honey', value: '#C68B2E' },
{ label: 'Green', value: '#4F9E7F' },
{ label: 'Sky', value: '#4E8FCC' },
{ label: 'Lavender', value: '#7D63C4' },
{ label: 'Plum', value: '#3D2E39' },
]
// Highlighter colors — soft pastels so dark text stays readable on top.
const HIGHLIGHTS: { label: string; value: string | null }[] = [
{ label: 'None', value: null },
{ label: 'Yellow', value: '#FFF1A8' },
{ label: 'Pink', value: '#FAD4E4' },
{ label: 'Mint', value: '#CDEFE2' },
{ label: 'Peach', value: '#FBE0CF' },
{ label: 'Lavender', value: '#E6DCFA' },
{ label: 'Sky', value: '#D6E8FB' },
]
// Font-size presets. `null` clears back to the document default.
const SIZES: { label: string; value: string | null; em: string }[] = [
{ label: 'Small', value: '0.85em', em: '0.85em' },
{ label: 'Normal', value: null, em: '1em' },
{ label: 'Large', value: '1.3em', em: '1.3em' },
{ label: 'Title', value: '1.7em', em: '1.7em' },
]
// A round color chip used in the color/highlight palettes.
function Swatch({
color,
active,
onClick,
title,
}: {
color: string | null
active: boolean
onClick: () => void
title: string
}) {
return (
<button
type="button"
title={title}
aria-label={title}
onMouseDown={(e) => e.preventDefault()}
onClick={onClick}
className="flex h-7 w-7 items-center justify-center"
style={{
borderRadius: 'var(--radius-pill)',
background: color ?? 'var(--color-surface)',
border: active ? '2px solid var(--color-accent)' : '1px solid var(--color-border)',
// The "clear" chip (no color) shows a tiny diagonal stroke.
backgroundImage: color
? undefined
: 'linear-gradient(135deg, transparent 44%, var(--color-accent) 44%, var(--color-accent) 56%, transparent 56%)',
}}
/>
)
}
// Toolbar renders inline formatting controls bound to the live Tiptap editor.
// useEditorState subscribes to just the flags it reads, so the buttons reflect
// the current selection without re-rendering the whole tree on every keystroke.
export function Toolbar({ editor, onVoiceCheck, voicing }: Props) {
// Which popover (if any) is open. Only one at a time.
const [menu, setMenu] = useState<'color' | 'highlight' | 'size' | 'link' | 'table' | null>(null)
const [linkUrl, setLinkUrl] = useState('')
const fileInputRef = useRef<HTMLInputElement>(null)
const state = useEditorState({
editor,
selector: ({ editor }) =>
@@ -59,27 +179,72 @@ export function Toolbar({ editor, onVoiceCheck, voicing }: Props) {
bold: editor.isActive('bold'),
italic: editor.isActive('italic'),
underline: editor.isActive('underline'),
strike: editor.isActive('strike'),
h1: editor.isActive('heading', { level: 1 }),
h2: editor.isActive('heading', { level: 2 }),
h3: editor.isActive('heading', { level: 3 }),
bullet: editor.isActive('bulletList'),
ordered: editor.isActive('orderedList'),
left: editor.isActive({ textAlign: 'left' }),
center: editor.isActive({ textAlign: 'center' }),
right: editor.isActive({ textAlign: 'right' }),
link: editor.isActive('link'),
inTable: editor.isActive('table'),
color: editor.getAttributes('textStyle').color ?? null,
highlight: editor.getAttributes('highlight').color ?? null,
fontSize: editor.getAttributes('textStyle').fontSize ?? null,
canUndo: editor.can().undo(),
canRedo: editor.can().redo(),
}
: null,
})
if (!editor || !state) return null
const close = () => setMenu(null)
// Open the link popover, pre-filling the field with any link already on the
// selection so it can be edited rather than retyped.
const openLink = () => {
setLinkUrl(editor.getAttributes('link').href ?? '')
setMenu(menu === 'link' ? null : 'link')
}
const applyLink = () => {
const url = linkUrl.trim()
if (!url) {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
} else {
// Default to https:// when the writer omits a scheme.
const href = /^(https?:|mailto:|\/)/i.test(url) ? url : `https://${url}`
editor.chain().focus().extendMarkRange('link').setLink({ href }).run()
}
close()
}
const onPickImage = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) uploadImageInto(editor.view, file)
e.target.value = '' // allow re-picking the same file
}
return (
<div
className="mb-4 flex items-center gap-0.5 self-start px-2 py-1.5"
className="petal-toolbar mb-4 flex items-center gap-0.5 self-start px-2 py-1.5"
style={{
borderRadius: 'var(--radius-pill)',
borderRadius: 'var(--radius-card)',
background: 'var(--color-surface)',
boxShadow: 'var(--shadow-soft)',
}}
>
<TBtn label="Undo" disabled={!state.canUndo} onClick={() => editor.chain().focus().undo().run()}>
</TBtn>
<TBtn label="Redo" disabled={!state.canRedo} onClick={() => editor.chain().focus().redo().run()}>
</TBtn>
<Divider />
<TBtn label="Bold" active={state.bold} onClick={() => editor.chain().focus().toggleBold().run()}>
<span className="font-extrabold">B</span>
</TBtn>
@@ -93,51 +258,257 @@ export function Toolbar({ editor, onVoiceCheck, voicing }: Props) {
>
<span className="underline">U</span>
</TBtn>
<TBtn label="Strikethrough" active={state.strike} onClick={() => editor.chain().focus().toggleStrike().run()}>
<span className="line-through">S</span>
</TBtn>
<Divider />
<TBtn
label="Heading 1"
active={state.h1}
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
{/* Text color */}
<Popover
open={menu === 'color'}
onClose={close}
width={188}
trigger={
<TBtn label="Text color" active={menu === 'color'} onClick={() => setMenu(menu === 'color' ? null : 'color')}>
<span className="flex flex-col items-center leading-none">
<span className="text-sm font-bold">A</span>
<span
className="mt-0.5 h-1 w-4 rounded-full"
style={{ background: state.color ?? 'var(--color-accent)' }}
/>
</span>
</TBtn>
}
>
<div className="grid grid-cols-4 gap-1.5">
{TEXT_COLORS.map((c) => (
<Swatch
key={c.label}
color={c.value}
title={c.label}
active={state.color === c.value}
onClick={() => {
if (c.value) editor.chain().focus().setColor(c.value).run()
else editor.chain().focus().unsetColor().run()
close()
}}
/>
))}
</div>
</Popover>
{/* Highlight */}
<Popover
open={menu === 'highlight'}
onClose={close}
width={170}
trigger={
<TBtn
label="Highlight"
active={menu === 'highlight' || !!state.highlight}
onClick={() => setMenu(menu === 'highlight' ? null : 'highlight')}
>
<span
className="flex h-5 w-5 items-center justify-center rounded text-xs font-bold"
style={{ background: state.highlight ?? '#FFF1A8', color: 'var(--color-plum)' }}
>
H
</span>
</TBtn>
}
>
<div className="grid grid-cols-4 gap-1.5">
{HIGHLIGHTS.map((c) => (
<Swatch
key={c.label}
color={c.value}
title={c.label}
active={state.highlight === c.value}
onClick={() => {
if (c.value) editor.chain().focus().setHighlight({ color: c.value }).run()
else editor.chain().focus().unsetHighlight().run()
close()
}}
/>
))}
</div>
</Popover>
{/* Font size */}
<Popover
open={menu === 'size'}
onClose={close}
width={140}
trigger={
<TBtn label="Text size" active={menu === 'size'} onClick={() => setMenu(menu === 'size' ? null : 'size')}>
<span className="text-xs font-bold tracking-tight">
<span className="text-[0.7rem]">A</span>
<span className="text-sm">A</span>
</span>
</TBtn>
}
>
<div className="flex flex-col gap-0.5">
{SIZES.map((s) => {
const active = state.fontSize === s.value
return (
<button
key={s.label}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
if (s.value) editor.chain().focus().setFontSize(s.value).run()
else editor.chain().focus().unsetFontSize().run()
close()
}}
className="flex items-center justify-between rounded px-2 py-1 text-left"
style={{
background: active ? 'var(--color-surface-alt)' : 'transparent',
color: active ? 'var(--color-accent-hover)' : 'var(--color-plum)',
}}
>
<span style={{ fontSize: s.em }}>{s.label}</span>
</button>
)
})}
</div>
</Popover>
<Divider />
<TBtn label="Heading 1" active={state.h1} onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}>
H1
</TBtn>
<TBtn
label="Heading 2"
active={state.h2}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
>
<TBtn label="Heading 2" active={state.h2} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>
H2
</TBtn>
<TBtn
label="Bullet list"
active={state.bullet}
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
<TBtn label="Heading 3" active={state.h3} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>
H3
</TBtn>
<TBtn label="Bullet list" active={state.bullet} onClick={() => editor.chain().focus().toggleBulletList().run()}>
</TBtn>
<TBtn label="Numbered list" active={state.ordered} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
1.
</TBtn>
<Divider />
<TBtn
label="Align left"
active={state.left}
onClick={() => editor.chain().focus().setTextAlign('left').run()}
>
<TBtn label="Align left" active={state.left} onClick={() => editor.chain().focus().setTextAlign('left').run()}>
</TBtn>
<TBtn
label="Align center"
active={state.center}
onClick={() => editor.chain().focus().setTextAlign('center').run()}
>
<TBtn label="Align center" active={state.center} onClick={() => editor.chain().focus().setTextAlign('center').run()}>
</TBtn>
<TBtn
label="Align right"
active={state.right}
onClick={() => editor.chain().focus().setTextAlign('right').run()}
>
<TBtn label="Align right" active={state.right} onClick={() => editor.chain().focus().setTextAlign('right').run()}>
</TBtn>
<Divider />
{/* Link */}
<Popover
open={menu === 'link'}
onClose={close}
width={236}
trigger={
<TBtn label="Link" active={state.link || menu === 'link'} onClick={openLink}>
🔗
</TBtn>
}
>
<div className="flex flex-col gap-2">
<input
autoFocus
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') applyLink()
if (e.key === 'Escape') close()
}}
placeholder="https://…"
className="w-full px-2 py-1.5 text-sm focus:outline-none"
style={{
borderRadius: 'var(--radius-input)',
border: '1px solid var(--color-border)',
color: 'var(--color-plum)',
}}
/>
<div className="flex items-center justify-between">
{state.link ? (
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
close()
}}
className="px-2 py-1 text-xs font-semibold"
style={{ color: 'var(--color-muted)' }}
>
Remove
</button>
) : (
<span />
)}
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={applyLink}
className="px-3 py-1 text-xs font-bold"
style={{
borderRadius: 'var(--radius-pill)',
background: 'var(--color-accent)',
color: '#fff',
}}
>
Apply
</button>
</div>
</div>
</Popover>
{/* Image */}
<input ref={fileInputRef} type="file" accept="image/*" hidden onChange={onPickImage} />
<TBtn label="Insert image" onClick={() => fileInputRef.current?.click()}>
🖼
</TBtn>
{/* Table */}
<Popover
open={menu === 'table'}
onClose={close}
width={state.inTable ? 188 : 150}
trigger={
<TBtn label="Table" active={state.inTable || menu === 'table'} onClick={() => setMenu(menu === 'table' ? null : 'table')}>
</TBtn>
}
>
{state.inTable ? (
<div className="flex flex-col gap-0.5 text-sm" style={{ color: 'var(--color-plum)' }}>
<TableAction label="Add row below" onClick={() => editor.chain().focus().addRowAfter().run()} />
<TableAction label="Add column right" onClick={() => editor.chain().focus().addColumnAfter().run()} />
<TableAction label="Toggle header row" onClick={() => editor.chain().focus().toggleHeaderRow().run()} />
<TableAction label="Delete row" onClick={() => editor.chain().focus().deleteRow().run()} />
<TableAction label="Delete column" onClick={() => editor.chain().focus().deleteColumn().run()} />
<TableAction
label="Delete table"
danger
onClick={() => {
editor.chain().focus().deleteTable().run()
close()
}}
/>
</div>
) : (
<GridPicker
onPick={(rows, cols) => {
editor.chain().focus().insertTable({ rows, cols, withHeaderRow: true }).run()
close()
}}
/>
)}
</Popover>
<Divider />
<button
type="button"
aria-label="Check my voice"
@@ -162,3 +533,58 @@ export function Toolbar({ editor, onVoiceCheck, voicing }: Props) {
</div>
)
}
// A row in the in-table editing menu.
function TableAction({ label, onClick, danger }: { label: string; onClick: () => void; danger?: boolean }) {
return (
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={onClick}
className="rounded px-2 py-1 text-left"
style={{ color: danger ? 'var(--color-accent-hover)' : 'var(--color-plum)' }}
>
{label}
</button>
)
}
// A hover-to-size grid for inserting a table. Hovering a cell highlights the
// rectangle from the top-left; clicking inserts that many rows × columns.
function GridPicker({ onPick }: { onPick: (rows: number, cols: number) => void }) {
const MAX = 6
const [hover, setHover] = useState<{ r: number; c: number }>({ r: 0, c: 0 })
return (
<div>
<div
className="grid gap-0.5"
style={{ gridTemplateColumns: `repeat(${MAX}, 1fr)` }}
onMouseLeave={() => setHover({ r: 0, c: 0 })}
>
{Array.from({ length: MAX * MAX }).map((_, i) => {
const r = Math.floor(i / MAX) + 1
const c = (i % MAX) + 1
const on = r <= hover.r && c <= hover.c
return (
<button
key={i}
type="button"
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setHover({ r, c })}
onClick={() => onPick(r, c)}
className="h-4 w-4"
style={{
borderRadius: 3,
background: on ? 'var(--color-accent)' : 'var(--color-surface-alt)',
border: '1px solid var(--color-border)',
}}
/>
)
})}
</div>
<div className="mt-1.5 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
{hover.r > 0 ? `${hover.r} × ${hover.c}` : 'Pick a size'}
</div>
</div>
)
}

View File

@@ -98,6 +98,93 @@ button, a, input {
padding: 0.1em 0.35em;
border-radius: 6px;
}
/* Top formatting bar: a single line at rest so it stays slim, expanding to
reveal every control on hover (or while a control inside it has focus, e.g.
the link input or an open popover). The clipped right edge fades out as a hint
that more is available. */
.petal-toolbar {
flex-wrap: nowrap;
overflow: hidden;
max-width: 100%;
-webkit-mask-image: linear-gradient(to right, #000 90%, transparent 100%);
mask-image: linear-gradient(to right, #000 90%, transparent 100%);
}
.petal-toolbar:hover,
.petal-toolbar:focus-within {
flex-wrap: wrap;
overflow: visible;
-webkit-mask-image: none;
mask-image: none;
}
/* Highlighter mark — inline background swatch behind the text. */
.petal-prose mark {
border-radius: 4px;
padding: 0.05em 0.15em;
/* color comes from the inline style the Highlight extension writes */
}
/* Inserted images: rounded, soft-shadowed, never wider than the column. The
selected-node ring matches the rest of the rose theme. Scoped to the editor's
own <img> (not a class) since Tiptap doesn't always carry the configured
class through to the rendered node. */
.petal-prose img {
max-width: 100%;
height: auto;
border-radius: var(--radius-card);
box-shadow: var(--shadow-soft);
}
.petal-prose img.ProseMirror-selectednode {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
/* Tables: soft pink grid, rounded outer corners, a tinted header row. Targeted
by element within the editor — Tiptap renders the <table> 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);