Compare commits

...

2 Commits

Author SHA1 Message Date
codex-bot 6f504ebe15 Support canvas frame nodes and explicit membership snapshots
Foundry Go checks / check (push) Successful in 3m13s
Foundry Go checks / check (pull_request) Successful in 4m24s
2026-11-06 11:20:05 +08:00
Zayden-Jung 57aab71d45 Merge pull request 'Canvas 快照持久化与 workspace/board SEO slug' (#7) from codex/canvas-persistence into main
Foundry Go checks / check (push) Successful in 2m38s
Deploy Foundry Core / deploy (push) Successful in 3m45s
Foundry Go checks / check (pull_request) Successful in 3m20s
Reviewed-on: #7
2026-11-05 18:57:43 +00:00
4 changed files with 110 additions and 2 deletions
+12
View File
@@ -104,3 +104,15 @@ at least 5 MiB; load the server snapshot, offer migration of old local drafts,
serialize autosaves and handle 412 explicitly. Restore node style dimensions
from width/height and render edges as Bézier curves. These frontend changes are
outside this Core delivery; local drafts alone are not database persistence.
Canvas schema 1 also supports frames: `type: "frame"` with `data.kind: "frame"`.
Frame names use `data.title`; optional `data.labelMode` is `hidden`, `fixed`, or
`scaled` (frontend default: `fixed`). Frames cannot rotate, connect to edges,
or belong to other frames. Any non-frame node can specify `data.frameId`, which
must reference a frame in the same snapshot; forward references are supported.
Membership is explicit and independent of geometry: moving a frame over existing
content does not add members. The frontend assigns membership on content drop
and implements label zoom behavior. Core preserves the supplied membership and
label mode without inferring them from positions. Renderer-only `zIndex` is
ignored alongside transient state; restore frame layering in the frontend.
This extends existing JSONB snapshots and requires no additional migration.
+26 -2
View File
@@ -105,7 +105,7 @@ func normalizeCanvas(raw json.RawMessage) (json.RawMessage, bool) {
}
}
}
for _, key := range []string{"selected", "dragging", "measured", "style"} {
for _, key := range []string{"selected", "dragging", "measured", "style", "zIndex"} {
delete(n, key)
}
if !canvasFields(n, "id", "type", "position", "width", "height", "data") {
@@ -125,7 +125,7 @@ func normalizeCanvas(raw json.RawMessage) (json.RawMessage, bool) {
return nil, false
}
d, ok := n["data"].(map[string]any)
if !ok || !canvasFields(d, "kind", "title", "text", "src", "icon", "rotation", "fontSize", "textStyle") {
if !ok || !canvasFields(d, "kind", "title", "text", "src", "icon", "rotation", "fontSize", "textStyle", "frameId", "labelMode") {
return nil, false
}
kind, ok := d["kind"].(string)
@@ -137,12 +137,28 @@ func normalizeCanvas(raw json.RawMessage) (json.RawMessage, bool) {
typ = "plainText"
} else if kind == "icon" {
typ = "icon"
} else if kind == "frame" {
typ = "frame"
} else if !canvasChoice(kind, "text", "image", "script") {
return nil, false
}
if n["type"] != typ || !canvasString(d["title"], 300) || !canvasString(d["text"], 100000) {
return nil, false
}
if frameID, exists := d["frameId"]; exists {
value, ok := frameID.(string)
if kind == "frame" || !ok || !canvasID.MatchString(value) {
return nil, false
}
}
if mode, exists := d["labelMode"]; exists && (kind != "frame" || !canvasChoice(mode, "hidden", "fixed", "scaled")) {
return nil, false
}
if kind == "frame" {
if rotation, exists := d["rotation"]; exists && rotation != float64(0) {
return nil, false
}
}
kinds[id] = kind
if r, exists := d["rotation"]; exists && (!canvasNumber(r, 0, 360) || r == float64(360)) {
return nil, false
@@ -191,6 +207,14 @@ func normalizeCanvas(raw json.RawMessage) (json.RawMessage, bool) {
}
}
}
// Resolve memberships after all nodes so forward references are valid.
// Geometry never creates membership: only explicit frameId is persisted.
for _, value := range nodes {
data := value.(map[string]any)["data"].(map[string]any)
if id, exists := data["frameId"]; exists && kinds[id.(string)] != "frame" {
return nil, false
}
}
seen := map[string]bool{}
for _, v := range edges {
e, ok := v.(map[string]any)
+49
View File
@@ -39,3 +39,52 @@ func TestSlugValidation(t *testing.T) {
}
}
}
// Frame nodes follow the frontend contract: membership is data, not containment.
const frameFixture = `{"schema_version":1,"nodes":[{"id":"text","type":"material","position":{"x":100,"y":100},"data":{"kind":"text","title":"成员","text":"正文","frameId":"frame"}},{"id":"plain","type":"plainText","position":{"x":0,"y":0},"data":{"kind":"plain-text","title":"纯文本","text":"文字","frameId":"frame"}},{"id":"icon","type":"icon","position":{"x":10,"y":10},"data":{"kind":"icon","title":"图标","text":"","icon":"Star","frameId":"frame"}},{"id":"frame","type":"frame","position":{"x":0,"y":0},"width":600,"height":400,"zIndex":-1,"style":{"width":600,"height":400,"pointerEvents":"none"},"data":{"kind":"frame","title":"框选区","text":"","rotation":0,"labelMode":"fixed"}},{"id":"outside","type":"material","position":{"x":100,"y":100},"data":{"kind":"script","title":"重叠但未加入","text":""}}],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}`
func TestCanvasFrames(t *testing.T) {
for _, mode := range []string{"hidden", "fixed", "scaled"} {
t.Run(mode, func(t *testing.T) {
out, ok := normalizeCanvas(json.RawMessage(strings.Replace(frameFixture, `"labelMode":"fixed"`, `"labelMode":"`+mode+`"`, 1)))
if !ok {
t.Fatal("valid frame rejected")
}
var doc map[string]any
json.Unmarshal(out, &doc)
nodes := doc["nodes"].([]any)
if nodes[0].(map[string]any)["data"].(map[string]any)["frameId"] != "frame" || nodes[3].(map[string]any)["data"].(map[string]any)["labelMode"] != mode {
t.Fatal("frame data lost")
}
if _, exists := nodes[4].(map[string]any)["data"].(map[string]any)["frameId"]; exists {
t.Fatal("position inferred membership")
}
if _, exists := nodes[3].(map[string]any)["zIndex"]; exists {
t.Fatal("rendering state persisted")
}
})
}
cases := [][2]string{
{`"labelMode":"fixed"`, `"labelMode":"invalid"`},
{`"labelMode":"fixed"`, `"labelMode":null`},
{`"rotation":0`, `"rotation":1`},
{`"frameId":"frame"`, `"frameId":"missing"`},
{`"frameId":"frame"`, `"frameId":"text"`},
{`"frameId":"frame"`, `"frameId":null`},
{`"frameId":"frame"`, `"frameId":""`},
{`"labelMode":"fixed"`, `"labelMode":"fixed","frameId":"frame"`},
{`"type":"frame"`, `"type":"material"`},
{`"edges":[]`, `"edges":[{"id":"e","source":"text","target":"frame"}]`},
}
for _, c := range cases {
t.Run(c[1], func(t *testing.T) {
if _, ok := normalizeCanvas(json.RawMessage(strings.Replace(frameFixture, c[0], c[1], 1))); ok {
t.Fatal("invalid frame accepted")
}
})
}
withoutMode := strings.Replace(frameFixture, `,"labelMode":"fixed"`, "", 1)
if _, ok := normalizeCanvas(json.RawMessage(withoutMode)); !ok {
t.Fatal("optional labelMode rejected")
}
}
+23
View File
@@ -14,6 +14,7 @@ import (
"net/http/httptest"
"os"
"regexp"
"strconv"
"strings"
"testing"
@@ -234,6 +235,28 @@ func TestOnboardingAndSharing(t *testing.T) {
if call("GET", workspacePath, tokens[0], nil, 200)["slug"] != "research-team" {
t.Fatal("rename changed slug")
}
// Frames round-trip through PostgreSQL with all label modes and explicit memberships.
for index, mode := range []string{"hidden", "fixed", "scaled"} {
version := strconv.Itoa(4 + index)
raw := strings.Replace(frameFixture, `"labelMode":"fixed"`, `"labelMode":"`+mode+`"`, 1)
call("PATCH", canvasPath, tokens[0], response{"document": json.RawMessage(raw)}, 200, `"`+version+`"`)
persisted := call("GET", canvasPath, tokens[0], nil, 200)
normalized, _ := normalizeCanvas(json.RawMessage(raw))
want, _ := json.Marshal(json.RawMessage(normalized))
got, _ := json.Marshal(persisted["document"])
if !bytes.Equal(want, got) {
t.Fatal("frame snapshot not preserved")
}
}
invalidFrame := strings.Replace(frameFixture, `"frameId":"frame"`, `"frameId":"missing"`, 1)
rejected := call("PATCH", canvasPath, tokens[0], response{"document": json.RawMessage(invalidFrame)}, 400, `"7"`)
if rejected["error"] != "invalid_canvas" {
t.Fatal("unexpected frame error")
}
if call("GET", canvasPath, tokens[0], nil, 200)["version"] != float64(7) {
t.Fatal("invalid frame changed saved version")
}
layout := call("POST", boardPath+"/layouts", tokens[0], response{"material_id": articleID, "x": 1, "y": 2, "width": 320, "height": 200, "z_index": 1}, 201, `"2"`)
call("GET", boardPath+"/layouts", tokens[1], nil, 200)
call("POST", boardPath+"/grants", tokens[0], response{"account_id": accounts[2], "role": "viewer"}, 204)