From 6f504ebe1503a20e76ed4c08807a5e7f2b5abad4 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 6 Nov 2026 11:20:05 +0800 Subject: [PATCH] Support canvas frame nodes and explicit membership snapshots --- README.md | 12 +++++++ internal/platform/canvas.go | 28 +++++++++++++-- internal/platform/canvas_test.go | 49 +++++++++++++++++++++++++++ internal/platform/integration_test.go | 23 +++++++++++++ 4 files changed, 110 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8f6a90d..a16a6e8 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/internal/platform/canvas.go b/internal/platform/canvas.go index a0e02a4..4b4a404 100644 --- a/internal/platform/canvas.go +++ b/internal/platform/canvas.go @@ -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) diff --git a/internal/platform/canvas_test.go b/internal/platform/canvas_test.go index 7cba1d9..e299d97 100644 --- a/internal/platform/canvas_test.go +++ b/internal/platform/canvas_test.go @@ -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") + } +} diff --git a/internal/platform/integration_test.go b/internal/platform/integration_test.go index 5292294..f6b035b 100644 --- a/internal/platform/integration_test.go +++ b/internal/platform/integration_test.go @@ -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)