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:
@@ -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>"
|
||||
|
||||
Reference in New Issue
Block a user