Compare commits
2
Commits
045ec19b92
...
6e6e4edce7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e6e4edce7 | ||
|
|
58c326d0cf |
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"),
|
||||
|
||||
+186
-1
@@ -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("", 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>"
|
||||
|
||||
@@ -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)", "", "| 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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Generated
+124
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -475,6 +475,7 @@ export default function App() {
|
||||
<PetalCompanion
|
||||
wordCount={wordCount}
|
||||
saveStatus={status}
|
||||
llmDown={llmDown}
|
||||
editTick={editTick}
|
||||
acceptTick={acceptTick}
|
||||
text={docText}
|
||||
|
||||
@@ -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).
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+52
-32
@@ -1,16 +1,18 @@
|
||||
// Petal's cute sound palette. The actual tones live as tiny WAV assets in
|
||||
// ./assets (generated by scripts/gen_sounds.py — warm bubble pops, water drops,
|
||||
// and soft bells tuned to match the app's gentle look). Vite fingerprints and
|
||||
// bundles them into dist, so they ride along inside the single Go binary; here
|
||||
// we just fetch + decode them once and play them through a Web Audio bus with a
|
||||
// soft master volume. The whole thing is opt-out-able and the mute choice
|
||||
// persists in localStorage so it survives reloads.
|
||||
// Petal's cute sound palette. These are hand-picked mp3 effects (warm bubble
|
||||
// pops, a wooden "tok", rolling baoding balls, a Chinese fanfare for milestones,
|
||||
// and a playful "haiya" for errors) living as assets in ./assets/sounds. Vite
|
||||
// fingerprints and bundles them into dist, so they ride along inside the single
|
||||
// Go binary; here we just fetch + decode them once and play them through a Web
|
||||
// Audio bus with a soft master volume. The whole thing is opt-out-able and the
|
||||
// mute choice persists in localStorage so it survives reloads.
|
||||
|
||||
import popUrl from '../assets/sounds/pop.wav'
|
||||
import dropletUrl from '../assets/sounds/droplet.wav'
|
||||
import chimeUrl from '../assets/sounds/chime.wav'
|
||||
import shimmerUrl from '../assets/sounds/shimmer.wav'
|
||||
import cheerUrl from '../assets/sounds/cheer.wav'
|
||||
import pop1Url from '../assets/sounds/pop1.mp3'
|
||||
import pop2Url from '../assets/sounds/pop2.mp3'
|
||||
import tokUrl from '../assets/sounds/tok.mp3'
|
||||
import blockUrl from '../assets/sounds/block.mp3'
|
||||
import baodingUrl from '../assets/sounds/baoding.mp3'
|
||||
import milestoneUrl from '../assets/sounds/milestone.mp3'
|
||||
import errorUrl from '../assets/sounds/error.mp3'
|
||||
|
||||
const STORAGE_KEY = 'petal.sound'
|
||||
|
||||
@@ -18,20 +20,32 @@ const STORAGE_KEY = 'petal.sound'
|
||||
const MASTER_GAIN = 0.5
|
||||
|
||||
export type SoundName =
|
||||
| 'pop' // a soft bubble pop — the signature "something arrived"
|
||||
| 'droplet' // a single rounded water-drop ping
|
||||
| 'chime' // a gentle two-note bell
|
||||
| 'shimmer' // three quick ascending sparkles
|
||||
| 'cheer' // a happy little major arpeggio (accepts, milestones)
|
||||
| 'pop1' // a soft bubble pop
|
||||
| 'pop2' // a second bubble pop, slightly different
|
||||
| 'tok' // a light wooden "tok"
|
||||
| 'block' // a quick Chinese wood-block tap
|
||||
| 'baoding' // rolling baoding-ball shimmer
|
||||
| 'milestone' // a happy Chinese fanfare (word-count milestones)
|
||||
| 'error' // a playful "haiya" — something went wrong
|
||||
|
||||
const SOURCES: Record<SoundName, string> = {
|
||||
pop: popUrl,
|
||||
droplet: dropletUrl,
|
||||
chime: chimeUrl,
|
||||
shimmer: shimmerUrl,
|
||||
cheer: cheerUrl,
|
||||
pop1: pop1Url,
|
||||
pop2: pop2Url,
|
||||
tok: tokUrl,
|
||||
block: blockUrl,
|
||||
baoding: baodingUrl,
|
||||
milestone: milestoneUrl,
|
||||
error: errorUrl,
|
||||
}
|
||||
|
||||
// The pool of light "something happened" notification sounds. We rotate through
|
||||
// these (skipping the one just played) so the same blip never repeats back to
|
||||
// back — a batch of suggestions or a run of tips stays lively instead of
|
||||
// hammering one note. milestone/error are deliberately NOT in the pool: they're
|
||||
// reserved for their specific moments.
|
||||
const POP_POOL: SoundName[] = ['pop1', 'pop2', 'tok', 'block', 'baoding']
|
||||
let lastPop = -1
|
||||
|
||||
let ctx: AudioContext | null = null
|
||||
let master: GainNode | null = null
|
||||
const buffers = new Map<SoundName, AudioBuffer>()
|
||||
@@ -147,18 +161,24 @@ export function playSound(name: SoundName): void {
|
||||
})()
|
||||
}
|
||||
|
||||
// Map an editor suggestion type to its sound, so each kind of help has its own
|
||||
// little voice (grammar pops like a bubble, idioms chime, etc.).
|
||||
const SUGGESTION_SOUND: Record<string, SoundName> = {
|
||||
grammar: 'pop',
|
||||
phrasing: 'droplet',
|
||||
idiom: 'chime',
|
||||
clarity: 'shimmer',
|
||||
voice: 'droplet',
|
||||
// Play the next notification pop from the pool, never repeating the last one, so
|
||||
// consecutive blips always sound different. This is the everyday "something
|
||||
// arrived" voice for suggestions, tips, and gentle cheers.
|
||||
export function playPop(): void {
|
||||
let i = lastPop
|
||||
// Tiny pool, so a short scan to pick anything but the last is plenty.
|
||||
for (let tries = 0; tries < POP_POOL.length && i === lastPop; tries++) {
|
||||
i = Math.floor(Math.random() * POP_POOL.length)
|
||||
}
|
||||
lastPop = i
|
||||
playSound(POP_POOL[i])
|
||||
}
|
||||
|
||||
export function playSuggestionSound(type: string): void {
|
||||
playSound(SUGGESTION_SOUND[type] ?? 'pop')
|
||||
// Each freshly-surfaced suggestion just gets a rotating pop — the variety comes
|
||||
// from the pool, not from the suggestion type, so a batch of same-type fixes
|
||||
// still sounds lively.
|
||||
export function playSuggestionSound(_type: string): void {
|
||||
playPop()
|
||||
}
|
||||
|
||||
// Unlock + warm audio on the first user gesture so the very first sound isn't
|
||||
|
||||
@@ -7,6 +7,7 @@ import { COMPANIONS, DEFAULT_COMPANION } from './companions'
|
||||
interface Props {
|
||||
wordCount: number
|
||||
saveStatus: SaveStatus
|
||||
llmDown: boolean
|
||||
editTick: number
|
||||
acceptTick: number
|
||||
text: string
|
||||
@@ -27,10 +28,11 @@ const STORAGE_KEY = 'petal.companion'
|
||||
// useCompanion and shows a Mandarin-first speech bubble for cheers, tips, and
|
||||
// break reminders. Clicking the mascot opens a picker to switch companions
|
||||
// (the choice persists in localStorage).
|
||||
export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick, text }: Props) {
|
||||
export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, acceptTick, text }: Props) {
|
||||
const { mood, bubble, dismiss, holdBubble, releaseBubble } = useCompanion({
|
||||
wordCount,
|
||||
saveStatus,
|
||||
llmDown,
|
||||
editTick,
|
||||
acceptTick,
|
||||
text,
|
||||
|
||||
@@ -48,8 +48,16 @@ export const WELCOME_BACK: Line = {
|
||||
en: 'Welcome back ✨ let’s keep going!',
|
||||
}
|
||||
|
||||
// Word-count milestones worth a little cheer.
|
||||
export const MILESTONES = [50, 100, 250, 500, 1000, 2000]
|
||||
// Gentle "haiya, something went wrong" lines — paired with the error sound when
|
||||
// the LLM is unreachable or a save fails. Never alarming, always reassuring.
|
||||
export const ERRORS: Line[] = [
|
||||
{ zh: '哎呀~出了点小问题,你的字都还在哦。', en: 'Oops — a little hiccup, but your words are safe.' },
|
||||
{ zh: '哎呀,我这边卡了一下,马上就好。', en: 'Haiya, I got stuck for a sec — back in a moment.' },
|
||||
{ zh: '别担心,等一下再试试看 🍵', en: "Don't worry — let's try again in a bit. 🍵" },
|
||||
]
|
||||
|
||||
// Word-count milestones worth a little cheer — every 100 words, on up.
|
||||
export const MILESTONES = Array.from({ length: 100 }, (_, i) => (i + 1) * 100)
|
||||
|
||||
export function milestoneLine(n: number): Line {
|
||||
return {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { SaveStatus } from '../../hooks/useAutoSave'
|
||||
import {
|
||||
BREAKS,
|
||||
ENCOURAGEMENTS,
|
||||
ERRORS,
|
||||
GREETING,
|
||||
MILESTONES,
|
||||
TIPS,
|
||||
@@ -12,13 +13,13 @@ import {
|
||||
type Line,
|
||||
} from './tips'
|
||||
import { analyzeProse } from './prose'
|
||||
import { playSound } from '../../audio/sounds'
|
||||
import { playPop, playSound, type SoundName } from '../../audio/sounds'
|
||||
|
||||
// The kitten's expression. Maps to a Lottie animation when assets are present,
|
||||
// otherwise to an emoji placeholder (see PetalCompanion).
|
||||
export type Mood = 'idle' | 'happy' | 'talking' | 'sleeping' | 'celebrate'
|
||||
|
||||
export type BubbleTone = 'cheer' | 'tip' | 'break'
|
||||
export type BubbleTone = 'cheer' | 'tip' | 'break' | 'error'
|
||||
export interface Bubble extends Line {
|
||||
tone: BubbleTone
|
||||
}
|
||||
@@ -26,6 +27,9 @@ export interface Bubble extends Line {
|
||||
interface Signals {
|
||||
wordCount: number
|
||||
saveStatus: SaveStatus
|
||||
// True while the writing-assist LLM is unreachable (timeout / down). A
|
||||
// false→true flip earns a gentle "haiya".
|
||||
llmDown: boolean
|
||||
// Monotonic counters bumped by the app on each editor change / accepted
|
||||
// suggestion — lets the companion react without a full event bus.
|
||||
editTick: number
|
||||
@@ -62,7 +66,7 @@ function readBubbleMs(b: Bubble): number {
|
||||
// useCompanion is the behavior engine: it watches writing signals and decides
|
||||
// when the kitten speaks, what mood it shows, and how to pace itself so the
|
||||
// companion feels alive without interrupting. UI-agnostic — returns state only.
|
||||
export function useCompanion({ wordCount, saveStatus, editTick, acceptTick, text }: Signals) {
|
||||
export function useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptTick, text }: Signals) {
|
||||
const [mood, setMood] = useState<Mood>('idle')
|
||||
const [bubble, setBubble] = useState<Bubble | null>(null)
|
||||
|
||||
@@ -88,7 +92,8 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick, text
|
||||
|
||||
// Show a bubble + talking mood, then settle back. `proactive` messages respect
|
||||
// the spacing floor; user-triggered ones (cheers) always go through.
|
||||
const say = useCallback((b: Bubble, opts?: { proactive?: boolean; celebrate?: boolean }) => {
|
||||
const say = useCallback(
|
||||
(b: Bubble, opts?: { proactive?: boolean; celebrate?: boolean; sound?: SoundName }) => {
|
||||
const t = now()
|
||||
if (opts?.proactive) {
|
||||
if (bubble) return
|
||||
@@ -96,11 +101,11 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick, text
|
||||
lastProactive.current = t
|
||||
}
|
||||
sleeping.current = false
|
||||
// A soft sound to match the bubble's mood — celebrations cheer, gentle
|
||||
// cheers sparkle, break nudges chime, and tips give a little bubble pop.
|
||||
playSound(
|
||||
opts?.celebrate ? 'cheer' : b.tone === 'cheer' ? 'shimmer' : b.tone === 'break' ? 'chime' : 'pop',
|
||||
)
|
||||
// A sound to match the bubble: callers can name a specific one (the milestone
|
||||
// fanfare, the "haiya" on errors); everything else gets a rotating pop so the
|
||||
// same blip never repeats back to back.
|
||||
if (opts?.sound) playSound(opts.sound)
|
||||
else playPop()
|
||||
setBubble(b)
|
||||
setMood(opts?.celebrate ? 'celebrate' : 'talking')
|
||||
clearTimeout(bubbleTimer.current)
|
||||
@@ -196,11 +201,39 @@ export function useCompanion({ wordCount, saveStatus, editTick, acceptTick, text
|
||||
const n = MILESTONES[nextMilestone.current]
|
||||
nextMilestone.current += 1
|
||||
// Skip silently if we're just loading a long doc (no edits yet).
|
||||
if (!firstEdit.current) say({ ...milestoneLine(n), tone: 'cheer' }, { celebrate: true })
|
||||
if (!firstEdit.current)
|
||||
say({ ...milestoneLine(n), tone: 'cheer' }, { celebrate: true, sound: 'milestone' })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wordCount])
|
||||
|
||||
// Trouble — a save failed or the writing-assist LLM went unreachable. Play the
|
||||
// playful "haiya" and reassure her, but never spam: at most one per gap, and
|
||||
// only on the *transition* into trouble (not while it lingers).
|
||||
const lastError = useRef(0)
|
||||
const ERROR_GAP = 20_000
|
||||
const wasLlmDown = useRef(false)
|
||||
const haiya = useCallback(() => {
|
||||
const t = now()
|
||||
if (t - lastError.current < ERROR_GAP) {
|
||||
playSound('error') // still acknowledge it, just without a fresh bubble
|
||||
return
|
||||
}
|
||||
lastError.current = t
|
||||
say({ ...pick(ERRORS), tone: 'error' }, { sound: 'error' })
|
||||
}, [say])
|
||||
|
||||
useEffect(() => {
|
||||
if (saveStatus === 'error') haiya()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [saveStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (llmDown && !wasLlmDown.current) haiya()
|
||||
wasLlmDown.current = llmDown
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [llmDown])
|
||||
|
||||
// Occasional gentle cheer on a successful save (kept rare so it isn't noise).
|
||||
useEffect(() => {
|
||||
if (saveStatus === 'saved' && Math.random() < 0.18) {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { isSoundEnabled, onSoundEnabledChange, playSound, setSoundEnabled } from '../../audio/sounds'
|
||||
import { isSoundEnabled, onSoundEnabledChange, playPop, setSoundEnabled } from '../../audio/sounds'
|
||||
|
||||
// A tiny mute toggle for Petal's cute sounds, tucked at the right of the status
|
||||
// bar. Bilingual tooltip (she reads Mandarin first), and a soft confirming pop
|
||||
@@ -14,7 +14,7 @@ export function SoundToggle() {
|
||||
const next = !on
|
||||
setSoundEnabled(next)
|
||||
setOn(next)
|
||||
if (next) playSound('pop') // a little hello when re-enabled
|
||||
if (next) playPop() // a little hello when re-enabled
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user