Support canvas frame nodes and explicit membership snapshots
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user