Update section behavior
This commit is contained in:
@@ -9,6 +9,7 @@
|
|||||||
</script>
|
</script>
|
||||||
<form id="edit-form" class="edit-form" method="POST" action="{{.PostURL}}">
|
<form id="edit-form" class="edit-form" method="POST" action="{{.PostURL}}">
|
||||||
{{if ge .SectionIndex 0}}<input type="hidden" name="section" value="{{.SectionIndex}}">{{end}}
|
{{if ge .SectionIndex 0}}<input type="hidden" name="section" value="{{.SectionIndex}}">{{end}}
|
||||||
|
{{if .Span}}<input type="hidden" name="span" value="1">{{end}}
|
||||||
{{if ge .InsertBefore 0}}<input type="hidden" name="insert_before" value="{{.InsertBefore}}">{{end}}
|
{{if ge .InsertBefore 0}}<input type="hidden" name="insert_before" value="{{.InsertBefore}}">{{end}}
|
||||||
<div class="editor-toolbar">
|
<div class="editor-toolbar">
|
||||||
<button type="button" class="btn btn-tool" data-action="undo" title="Undo">↶</button>
|
<button type="button" class="btn btn-tool" data-action="undo" title="Undo">↶</button>
|
||||||
|
|||||||
@@ -4,13 +4,18 @@
|
|||||||
var headings = content.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
var headings = content.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||||
if (!headings.length) return;
|
if (!headings.length) return;
|
||||||
|
|
||||||
// Section 0 is pre-heading content, editable via full-page edit.
|
|
||||||
// Sections 1..N each start at a heading; that is the index sent to the server.
|
|
||||||
// Skip headings that already carry a server-rendered edit link.
|
|
||||||
headings.forEach(function (h, i) {
|
headings.forEach(function (h, i) {
|
||||||
if (h.querySelector('a.btn')) return;
|
if (h.querySelector('a.btn')) return;
|
||||||
var a = document.createElement('a');
|
var a = document.createElement('a');
|
||||||
a.href = '?edit§ion=' + (i + 1);
|
|
||||||
|
if (h.tagName === 'H1') {
|
||||||
|
a.href = '?edit';
|
||||||
|
} else if (h.tagName === 'H2') {
|
||||||
|
a.href = '?edit§ion=' + (i + 1) + '&span';
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
a.className = 'btn btn-small btn-float';
|
a.className = 'btn btn-small btn-float';
|
||||||
a.textContent = 'edit';
|
a.textContent = 'edit';
|
||||||
h.appendChild(document.createTextNode(' '))
|
h.appendChild(document.createTextNode(' '))
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -208,22 +207,6 @@ func findDiaryContext(root, fsPath, urlPath string) (depth int, diaryRootFS, dia
|
|||||||
return 0, "", "", false
|
return 0, "", "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// headingTextRe matches an ATX heading at the start of a section. The
|
|
||||||
// heading text is everything after the `#`s and the required space, on the
|
|
||||||
// first line.
|
|
||||||
var headingTextRe = regexp.MustCompile(`^(#{1,6})\s+([^\n]*)`)
|
|
||||||
|
|
||||||
// sectionHeading returns the heading level (1..6) and trimmed text of a
|
|
||||||
// section produced by splitSections. Returns level=0 for the pre-heading
|
|
||||||
// section (index 0).
|
|
||||||
func sectionHeading(section []byte) (level int, text string) {
|
|
||||||
m := headingTextRe.FindSubmatch(section)
|
|
||||||
if m == nil {
|
|
||||||
return 0, ""
|
|
||||||
}
|
|
||||||
return len(m[1]), strings.TrimSpace(string(m[2]))
|
|
||||||
}
|
|
||||||
|
|
||||||
// findSectionIndex returns the absolute section index whose heading text
|
// findSectionIndex returns the absolute section index whose heading text
|
||||||
// matches target (e.g. "2026-05" or "2026-05-28"). Returns the first match.
|
// matches target (e.g. "2026-05" or "2026-05-28"). Returns the first match.
|
||||||
func findSectionIndex(sections [][]byte, target string) (int, bool) {
|
func findSectionIndex(sections [][]byte, target string) (int, bool) {
|
||||||
|
|||||||
@@ -261,6 +261,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
// Determine section index (-1 = whole page).
|
// Determine section index (-1 = whole page).
|
||||||
sectionIndex := -1
|
sectionIndex := -1
|
||||||
insertBefore := -1
|
insertBefore := -1
|
||||||
|
sectionSpan := false
|
||||||
if editMode {
|
if editMode {
|
||||||
if s := r.URL.Query().Get("section"); s != "" {
|
if s := r.URL.Query().Get("section"); s != "" {
|
||||||
if n, err := strconv.Atoi(s); err == nil && n >= 0 {
|
if n, err := strconv.Atoi(s); err == nil && n >= 0 {
|
||||||
@@ -272,6 +273,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
insertBefore = n
|
insertBefore = n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
sectionSpan = r.URL.Query().Has("span")
|
||||||
}
|
}
|
||||||
|
|
||||||
var special *specialPage
|
var special *specialPage
|
||||||
@@ -323,7 +325,11 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
} else if editMode && sectionIndex >= 0 {
|
} else if editMode && sectionIndex >= 0 {
|
||||||
sections := splitSections(rawMD)
|
sections := splitSections(rawMD)
|
||||||
if sectionIndex < len(sections) {
|
if sectionIndex < len(sections) {
|
||||||
rawContent = string(sections[sectionIndex])
|
end := sectionIndex + 1
|
||||||
|
if sectionSpan {
|
||||||
|
end = secionSpanEnd(sections, sectionIndex)
|
||||||
|
}
|
||||||
|
rawContent = string(joinSections(sections[sectionIndex:end]))
|
||||||
}
|
}
|
||||||
} else if editMode && rawContent == "" && urlPath != "/" {
|
} else if editMode && rawContent == "" && urlPath != "/" {
|
||||||
rawContent = "# " + pageTitle(urlPath) + "\n\n"
|
rawContent = "# " + pageTitle(urlPath) + "\n\n"
|
||||||
@@ -336,6 +342,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
IsRoot: urlPath == "/",
|
IsRoot: urlPath == "/",
|
||||||
SectionIndex: sectionIndex,
|
SectionIndex: sectionIndex,
|
||||||
InsertBefore: insertBefore,
|
InsertBefore: insertBefore,
|
||||||
|
Span: sectionSpan,
|
||||||
PostURL: urlPath,
|
PostURL: urlPath,
|
||||||
RawContent: rawContent,
|
RawContent: rawContent,
|
||||||
Content: rendered,
|
Content: rendered,
|
||||||
@@ -390,11 +397,7 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
|||||||
indexPath := filepath.Join(fsPath, "index.md")
|
indexPath := filepath.Join(fsPath, "index.md")
|
||||||
redirectTarget := urlPath
|
redirectTarget := urlPath
|
||||||
|
|
||||||
// insert_before splices a new section into the file *at* index N rather
|
// handle section saving
|
||||||
// than replacing index N (used by the diary "create new day" flow).
|
|
||||||
// section replaces the section at index N (used by per-section edits).
|
|
||||||
// Exactly one of insert_before / section should be set; insert_before
|
|
||||||
// wins if both are present.
|
|
||||||
if s := r.FormValue("insert_before"); s != "" {
|
if s := r.FormValue("insert_before"); s != "" {
|
||||||
insertIndex, err := strconv.Atoi(s)
|
insertIndex, err := strconv.Atoi(s)
|
||||||
if err != nil || insertIndex < 0 {
|
if err != nil || insertIndex < 0 {
|
||||||
@@ -431,8 +434,18 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
|||||||
http.Error(w, "section no longer exists — the page changed since you opened the editor", http.StatusConflict)
|
http.Error(w, "section no longer exists — the page changed since you opened the editor", http.StatusConflict)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sections[sectionIndex] = []byte(content)
|
|
||||||
content = string(joinSections(sections))
|
// handling of section editing that spans until the next heading of the same level
|
||||||
|
end := sectionIndex + 1
|
||||||
|
if r.FormValue("span") != "" {
|
||||||
|
end = secionSpanEnd(sections, sectionIndex)
|
||||||
|
}
|
||||||
|
merged := make([][]byte, 0, len(sections)-(end-sectionIndex)+1)
|
||||||
|
merged = append(merged, sections[:sectionIndex]...)
|
||||||
|
merged = append(merged, []byte(content))
|
||||||
|
merged = append(merged, sections[end:]...)
|
||||||
|
content = string(joinSections(merged))
|
||||||
|
|
||||||
// Section index ≥ 1 is a heading-anchored section. Redirect to its
|
// Section index ≥ 1 is a heading-anchored section. Redirect to its
|
||||||
// anchor so the user lands on the section they just saved, even if
|
// anchor so the user lands on the section they just saved, even if
|
||||||
// the heading text changed.
|
// the heading text changed.
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ type pageData struct {
|
|||||||
IsRoot bool
|
IsRoot bool
|
||||||
SectionIndex int // -1 = whole page; >=0 = section being edited
|
SectionIndex int // -1 = whole page; >=0 = section being edited
|
||||||
InsertBefore int // -1 = no insert; >=0 = splice new section at this index
|
InsertBefore int // -1 = no insert; >=0 = splice new section at this index
|
||||||
|
Span bool // causes section editing to also cover every sub-section to the next section of the same level
|
||||||
PostURL string
|
PostURL string
|
||||||
RawContent string
|
RawContent string
|
||||||
Content template.HTML
|
Content template.HTML
|
||||||
|
|||||||
+34
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/yuin/goldmark/ast"
|
"github.com/yuin/goldmark/ast"
|
||||||
"github.com/yuin/goldmark/text"
|
"github.com/yuin/goldmark/text"
|
||||||
@@ -10,6 +11,11 @@ import (
|
|||||||
|
|
||||||
var sectionHeadingRe = regexp.MustCompile(`(?m)^#{1,6} `)
|
var sectionHeadingRe = regexp.MustCompile(`(?m)^#{1,6} `)
|
||||||
|
|
||||||
|
// headingTextRe matches an ATX heading at the start of a section. The
|
||||||
|
// heading text is everything after the `#`s and the required space, on the
|
||||||
|
// first line.
|
||||||
|
var headingTextRe = regexp.MustCompile(`^(#{1,6})\s+([^\n]*)`)
|
||||||
|
|
||||||
// splitSections splits raw markdown into sections.
|
// splitSections splits raw markdown into sections.
|
||||||
// Section 0 is any content before the first heading.
|
// Section 0 is any content before the first heading.
|
||||||
// Each subsequent section begins at a heading line and runs to the next.
|
// Each subsequent section begins at a heading line and runs to the next.
|
||||||
@@ -64,3 +70,31 @@ func joinSections(sections [][]byte) []byte {
|
|||||||
}
|
}
|
||||||
return buf.Bytes()
|
return buf.Bytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sectionHeading returns the heading level (1..6) and trimmed text of a
|
||||||
|
// section produced by splitSections. Returns level=0 for the pre-heading
|
||||||
|
// section (index 0).
|
||||||
|
func sectionHeading(section []byte) (level int, text string) {
|
||||||
|
m := headingTextRe.FindSubmatch(section)
|
||||||
|
if m == nil {
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
return len(m[1]), strings.TrimSpace(string(m[2]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// sectionSpanEnd returns the exclusive end index of the section span starting
|
||||||
|
// at start: the first following section whose heading level is <= start's.
|
||||||
|
// Sections nested deeper than start (its subsections) belong to the span, so
|
||||||
|
// editing a `##` covers every `###`+ under it up to the next `##` or `#`.
|
||||||
|
func secionSpanEnd(sections [][]byte, start int) int {
|
||||||
|
level, _ := sectionHeading(sections[start])
|
||||||
|
if level == 0 {
|
||||||
|
return start + 1
|
||||||
|
}
|
||||||
|
for i := start + 1; i <= len(sections); i++ {
|
||||||
|
if l, _ := sectionHeading(sections[i]); l > 0 && l <= level {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(sections)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user