vikunja/pkg/richtext/tiptap.go

154 lines
5.8 KiB
Go

// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package richtext
import (
"github.com/JohannesKaufmann/dom"
"github.com/JohannesKaufmann/html-to-markdown/v2/converter"
"golang.org/x/net/html"
)
// registerTipTapRules teaches the HTML→Markdown converter about the two
// Vikunja-specific nodes that standard GFM doesn't model: TipTap mentions and
// TipTap task lists.
func registerTipTapRules(conv *converter.Converter) {
// Empty mention elements (the common stored form is <mention-user data-id data-label></mention-user>)
// would otherwise be treated as content-less by the whitespace collapser, eating the
// following space. Giving them a text child before collapse (PriorityLate) preserves it.
conv.Register.PreRenderer(ensureMentionContent, converter.PriorityEarly)
conv.Register.RendererFor("mention-user", converter.TagTypeInline, renderMentionUser, converter.PriorityEarly)
// Normalize TipTap task-list items to a single <input type="checkbox"> that
// renderTaskCheckbox turns into the GFM "[x]"/"[ ]" marker. We drive off the
// <li data-checked> attribute (the same source of truth resetDescriptionChecklist
// uses) rather than TipTap's <label><input> chrome, which may not always be present.
conv.Register.PreRenderer(normalizeTaskListItems, converter.PriorityEarly)
conv.Register.RendererFor("input", converter.TagTypeInline, renderTaskCheckbox, converter.PriorityEarly)
}
// renderMentionUser converts <mention-user data-id="username"> to "@username"
// (label and inner text dropped). Tags without data-id fall through to the
// default renderer, keeping their inner text.
func renderMentionUser(_ converter.Context, w converter.Writer, n *html.Node) converter.RenderStatus {
username := dom.GetAttributeOr(n, "data-id", "")
if username == "" {
return converter.RenderTryNext
}
// Written directly to the writer so the username isn't markdown-escaped;
// the inbound side re-tokenizes "@username" verbatim. The writer is
// buffer-backed and never errors.
_, _ = w.WriteString("@" + username)
return converter.RenderSuccess
}
// ensureMentionContent gives every mention with a data-id a text child if it has
// none, so the whitespace collapser keeps it (and the surrounding spaces). The
// child is never rendered — renderMentionUser writes "@data-id" and stops.
func ensureMentionContent(_ converter.Context, doc *html.Node) {
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "mention-user" && n.FirstChild == nil {
if username := dom.GetAttributeOr(n, "data-id", ""); username != "" {
n.AppendChild(&html.Node{Type: html.TextNode, Data: "@" + username})
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(doc)
}
// renderTaskCheckbox emits the GFM task-list marker for the normalized checkbox
// input. The trailing space separates it from the item text ("- [x] text").
func renderTaskCheckbox(_ converter.Context, w converter.Writer, n *html.Node) converter.RenderStatus {
if dom.GetAttributeOr(n, "type", "") != "checkbox" {
return converter.RenderTryNext
}
marker := "[ ] "
if _, checked := dom.GetAttribute(n, "checked"); checked {
marker = "[x] "
}
_, _ = w.WriteString(marker)
return converter.RenderSuccess
}
// normalizeTaskListItems rewrites every <li data-checked="…"> so its checkbox
// state is carried by a single leading <input type="checkbox">, removing
// TipTap's <label> chrome. This makes the marker independent of whether the
// stored HTML used the full TipTap form or the bare data-checked form.
func normalizeTaskListItems(_ converter.Context, doc *html.Node) {
var items []*html.Node
var collect func(*html.Node)
collect = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "li" {
if _, ok := dom.GetAttribute(n, "data-checked"); ok {
items = append(items, n)
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
collect(c)
}
}
collect(doc)
for _, li := range items {
checked := dom.GetAttributeOr(li, "data-checked", "false") == "true"
// Drop the existing checkbox chrome (<label><input><span>) so we don't
// render a duplicate or stale marker.
for _, child := range dom.AllChildNodes(li) {
if child.Type == html.ElementNode && (child.Data == "label" || child.Data == "input") {
dom.RemoveNode(child)
}
}
input := &html.Node{
Type: html.ElementNode,
Data: "input",
Attr: []html.Attribute{{Key: "type", Val: "checkbox"}},
}
if checked {
input.Attr = append(input.Attr, html.Attribute{Key: "checked", Val: "checked"})
}
// Insert the marker inside the item's first paragraph so it stays inline
// with the text ("- [x] text"). TipTap wraps task text in <div><p>…</p></div>;
// inserting at the <li> level instead would put a block boundary between
// the marker and the text.
host := firstParagraph(li)
if host == nil {
host = li
}
host.InsertBefore(input, host.FirstChild)
}
}
func firstParagraph(n *html.Node) *html.Node {
for _, c := range dom.AllChildNodes(n) {
if c.Type == html.ElementNode && c.Data == "p" {
return c
}
if found := firstParagraph(c); found != nil {
return found
}
}
return nil
}