Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f504ebe15 | |||
| 57aab71d45 |
@@ -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
|
serialize autosaves and handle 412 explicitly. Restore node style dimensions
|
||||||
from width/height and render edges as Bézier curves. These frontend changes are
|
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.
|
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)
|
delete(n, key)
|
||||||
}
|
}
|
||||||
if !canvasFields(n, "id", "type", "position", "width", "height", "data") {
|
if !canvasFields(n, "id", "type", "position", "width", "height", "data") {
|
||||||
@@ -125,7 +125,7 @@ func normalizeCanvas(raw json.RawMessage) (json.RawMessage, bool) {
|
|||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
d, ok := n["data"].(map[string]any)
|
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
|
return nil, false
|
||||||
}
|
}
|
||||||
kind, ok := d["kind"].(string)
|
kind, ok := d["kind"].(string)
|
||||||
@@ -137,12 +137,28 @@ func normalizeCanvas(raw json.RawMessage) (json.RawMessage, bool) {
|
|||||||
typ = "plainText"
|
typ = "plainText"
|
||||||
} else if kind == "icon" {
|
} else if kind == "icon" {
|
||||||
typ = "icon"
|
typ = "icon"
|
||||||
|
} else if kind == "frame" {
|
||||||
|
typ = "frame"
|
||||||
} else if !canvasChoice(kind, "text", "image", "script") {
|
} else if !canvasChoice(kind, "text", "image", "script") {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
if n["type"] != typ || !canvasString(d["title"], 300) || !canvasString(d["text"], 100000) {
|
if n["type"] != typ || !canvasString(d["title"], 300) || !canvasString(d["text"], 100000) {
|
||||||
return nil, false
|
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
|
kinds[id] = kind
|
||||||
if r, exists := d["rotation"]; exists && (!canvasNumber(r, 0, 360) || r == float64(360)) {
|
if r, exists := d["rotation"]; exists && (!canvasNumber(r, 0, 360) || r == float64(360)) {
|
||||||
return nil, false
|
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{}
|
seen := map[string]bool{}
|
||||||
for _, v := range edges {
|
for _, v := range edges {
|
||||||
e, ok := v.(map[string]any)
|
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"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -234,6 +235,28 @@ func TestOnboardingAndSharing(t *testing.T) {
|
|||||||
if call("GET", workspacePath, tokens[0], nil, 200)["slug"] != "research-team" {
|
if call("GET", workspacePath, tokens[0], nil, 200)["slug"] != "research-team" {
|
||||||
t.Fatal("rename changed slug")
|
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"`)
|
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("GET", boardPath+"/layouts", tokens[1], nil, 200)
|
||||||
call("POST", boardPath+"/grants", tokens[0], response{"account_id": accounts[2], "role": "viewer"}, 204)
|
call("POST", boardPath+"/grants", tokens[0], response{"account_id": accounts[2], "role": "viewer"}, 204)
|
||||||
|
|||||||
Reference in New Issue
Block a user