Compare commits

..

3 Commits

Author SHA1 Message Date
codex-bot 460b05f187 feat(canvas): support unframed image snapshot nodes
Foundry Go checks / check (push) Successful in 3m24s
Foundry Go checks / check (pull_request) Successful in 3m27s
2026-11-06 12:38:45 +08:00
Zayden-Jung 022086beb8 Merge pull request '支持 Canvas 框选区、成员归属与名称显示模式' (#9) from codex/canvas-frames into main
Foundry Go checks / check (push) Successful in 3m28s
Deploy Foundry Core / deploy (push) Successful in 3m4s
Reviewed-on: #9
2026-11-06 03:21:48 +00:00
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
4 changed files with 135 additions and 3 deletions
+17
View File
@@ -104,3 +104,20 @@ 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 supports unframed images: `type: "plainImage"` with
`data.kind: "plain-image"`. They require the same validated `data.src` as image
cards, support geometry/rotation/frame membership, and cannot connect to edges.
This is a snapshot-format extension with no additional migration.
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.
+29 -3
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)
@@ -135,14 +135,32 @@ func normalizeCanvas(raw json.RawMessage) (json.RawMessage, bool) {
typ := "material"
if kind == "plain-text" {
typ = "plainText"
} else if kind == "plain-image" {
typ = "plainImage"
} 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
@@ -156,7 +174,7 @@ func normalizeCanvas(raw json.RawMessage) (json.RawMessage, bool) {
return nil, false
}
}
if src, exists := d["src"]; (exists || kind == "image") && !canvasImage(src) {
if src, exists := d["src"]; (exists || kind == "image" || kind == "plain-image") && !canvasImage(src) {
return nil, false
}
if value, exists := d["textStyle"]; exists {
@@ -191,6 +209,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)
+66
View File
@@ -39,3 +39,69 @@ 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")
}
}
func TestCanvasPlainImages(t *testing.T) {
fixture := `{"schema_version":1,"nodes":[{"id":"photo","type":"plainImage","position":{"x":20,"y":30},"width":320,"height":240,"data":{"kind":"plain-image","title":"Photo","text":"","src":"https://example.com/photo.png","rotation":30,"frameId":"frame"}},{"id":"frame","type":"frame","position":{"x":0,"y":0},"width":640,"height":400,"data":{"kind":"frame","title":"Frame","text":"","labelMode":"fixed"}}],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}`
if _, ok := normalizeCanvas(json.RawMessage(fixture)); !ok {
t.Fatal("plain image rejected")
}
for _, change := range [][2]string{
{`"type":"plainImage"`, `"type":"material"`},
{`,"src":"https://example.com/photo.png"`, ``},
{`"src":"https://example.com/photo.png"`, `"src":"javascript:bad"`},
{`"edges":[]`, `"edges":[{"id":"e","source":"photo","target":"photo"}]`},
} {
if _, ok := normalizeCanvas(json.RawMessage(strings.Replace(fixture, change[0], change[1], 1))); ok {
t.Fatal("invalid pure image accepted")
}
}
}
+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)