Update section behavior

This commit is contained in:
2026-08-23 14:25:12 +02:00
parent c612cdd28d
commit 5ad715426d
6 changed files with 66 additions and 29 deletions
+1
View File
@@ -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>
+9 -4
View File
@@ -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&section=' + (i + 1);
if (h.tagName === 'H1') {
a.href = '?edit';
} else if (h.tagName === 'H2') {
a.href = '?edit&section=' + (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(' '))
-17
View File
@@ -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) {
+21 -8
View File
@@ -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.
+1
View File
@@ -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
View File
@@ -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)
}