@zseven-w/pen-core 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -9
- package/package.json +5 -5
- package/src/__tests__/arc-path.test.ts +23 -23
- package/src/__tests__/codegen-utils.test.ts +50 -0
- package/src/__tests__/design-md-parser.test.ts +49 -0
- package/src/__tests__/font-utils.test.ts +15 -15
- package/src/__tests__/layout-engine.test.ts +169 -83
- package/src/__tests__/merge-helpers.test.ts +143 -0
- package/src/__tests__/node-diff.test.ts +139 -0
- package/src/__tests__/node-helpers.test.ts +19 -19
- package/src/__tests__/node-merge.test.ts +425 -0
- package/src/__tests__/normalize-stroke-fill-schema.test.ts +416 -0
- package/src/__tests__/normalize-tree-layout.test.ts +294 -0
- package/src/__tests__/normalize.test.ts +119 -80
- package/src/__tests__/path-anchors.test.ts +98 -0
- package/src/__tests__/resolve-variables-recursive.test.ts +109 -54
- package/src/__tests__/strip-redundant-section-fills.test.ts +278 -0
- package/src/__tests__/text-measure.test.ts +84 -79
- package/src/__tests__/tree-utils.test.ts +133 -102
- package/src/__tests__/unwrap-fake-phone-mockup.test.ts +322 -0
- package/src/__tests__/variables.test.ts +68 -65
- package/src/arc-path.ts +35 -35
- package/src/boolean-ops.ts +131 -142
- package/src/constants.ts +36 -36
- package/src/design-md-parser.ts +363 -0
- package/src/font-utils.ts +30 -15
- package/src/id.ts +1 -1
- package/src/index.ts +47 -13
- package/src/layout/engine.ts +255 -224
- package/src/layout/normalize-tree.ts +140 -0
- package/src/layout/strip-redundant-section-fills.ts +155 -0
- package/src/layout/text-measure.ts +130 -106
- package/src/layout/unwrap-fake-phone-mockup.ts +147 -0
- package/src/merge/index.ts +16 -0
- package/src/merge/merge-helpers.ts +113 -0
- package/src/merge/node-diff.ts +123 -0
- package/src/merge/node-merge.ts +651 -0
- package/src/node-helpers.ts +18 -5
- package/src/normalize/normalize-stroke-fill-schema.ts +297 -0
- package/src/normalize.ts +75 -81
- package/src/path-anchors.ts +331 -0
- package/src/sync-lock.ts +3 -3
- package/src/tree-utils.ts +180 -158
- package/src/variables/replace-refs.ts +63 -60
- package/src/variables/resolve.ts +98 -106
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { PenNode } from '@zseven-w/pen-types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Detect and repair "fake phone mockup" frames — frames that look like a
|
|
5
|
+
* phone mockup (cornerRadius 28-40 + width 240-320, or literally named
|
|
6
|
+
* "Phone Mockup") but are stuffed with multiple children or use a horizontal/
|
|
7
|
+
* vertical layout. A genuine phone mockup is `ONE` frame with at most one
|
|
8
|
+
* placeholder child and `layout: 'none'`.
|
|
9
|
+
*
|
|
10
|
+
* Failure mode this addresses: weaker AI sub-agents (M2 family, GLM, Kimi)
|
|
11
|
+
* sometimes read a generic "Phone mockup = ONE frame, cornerRadius 32" prompt
|
|
12
|
+
* fragment and apply those visual properties to their own root frame, then
|
|
13
|
+
* shove the entire section content inside. The resulting wrapper has a
|
|
14
|
+
* 260px-wide horizontal layout that compresses every section into a 40px
|
|
15
|
+
* column — visually a complete mess (see 2026-04-06 health-tracker case).
|
|
16
|
+
*
|
|
17
|
+
* Two recovery modes:
|
|
18
|
+
*
|
|
19
|
+
* 1. **Unwrap (preferred)** — when the fake wrapper is a CHILD of `node`, we
|
|
20
|
+
* drop it and promote its children up one level. Visual styling is
|
|
21
|
+
* discarded along with the wrapper.
|
|
22
|
+
*
|
|
23
|
+
* 2. **Sanitize (fallback)** — when `node` ITSELF is the fake wrapper (the
|
|
24
|
+
* sub-agent's own root frame is the fake; we have no parent in scope),
|
|
25
|
+
* we strip the phone bezel visuals (cornerRadius, fixed width, layout) and
|
|
26
|
+
* rename it. Children stay; they may be duplicates, but at least they
|
|
27
|
+
* render in a normal vertical stack instead of being crushed into a 260px
|
|
28
|
+
* horizontal column.
|
|
29
|
+
*
|
|
30
|
+
* Returns `true` if any node was mutated. Callers operating on store-owned
|
|
31
|
+
* nodes MUST publish a Zustand update when this returns true — the in-place
|
|
32
|
+
* mutation does not by itself notify subscribers, so canvas would otherwise
|
|
33
|
+
* keep showing the old (fake-mockup) state until something else triggered a
|
|
34
|
+
* store write.
|
|
35
|
+
*/
|
|
36
|
+
export function unwrapFakePhoneMockups(node: PenNode): boolean {
|
|
37
|
+
let changed = false;
|
|
38
|
+
|
|
39
|
+
// Mode (2): self-sanitize if the root itself is the fake wrapper.
|
|
40
|
+
if (isFakePhoneMockup(node)) {
|
|
41
|
+
sanitizeFakePhoneMockupRoot(node);
|
|
42
|
+
changed = true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!('children' in node) || !Array.isArray(node.children)) return changed;
|
|
46
|
+
|
|
47
|
+
// Mode (1): drop fake wrappers among the children, promote their content.
|
|
48
|
+
const newChildren: PenNode[] = [];
|
|
49
|
+
let unwrappedAnyChild = false;
|
|
50
|
+
for (const child of node.children) {
|
|
51
|
+
if (isFakePhoneMockup(child)) {
|
|
52
|
+
const grandchildren =
|
|
53
|
+
('children' in child && Array.isArray(child.children) ? child.children : []) ?? [];
|
|
54
|
+
for (const gc of grandchildren) newChildren.push(gc);
|
|
55
|
+
unwrappedAnyChild = true;
|
|
56
|
+
} else {
|
|
57
|
+
newChildren.push(child);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (unwrappedAnyChild) {
|
|
61
|
+
node.children = newChildren;
|
|
62
|
+
changed = true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Recurse into the (possibly updated) children. Promoted grandchildren are
|
|
66
|
+
// visited too — if a fake mockup wrapped another fake mockup, both are
|
|
67
|
+
// handled in a single pass.
|
|
68
|
+
for (const child of node.children) {
|
|
69
|
+
if (unwrapFakePhoneMockups(child)) {
|
|
70
|
+
changed = true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return changed;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Strip phone bezel visual signature from a frame in place. Used when the
|
|
79
|
+
* frame itself is the fake wrapper and we cannot detach it from a parent.
|
|
80
|
+
*/
|
|
81
|
+
function sanitizeFakePhoneMockupRoot(node: PenNode): void {
|
|
82
|
+
const rec = node as PenNode & {
|
|
83
|
+
name?: string;
|
|
84
|
+
cornerRadius?: unknown;
|
|
85
|
+
width?: unknown;
|
|
86
|
+
height?: unknown;
|
|
87
|
+
layout?: 'none' | 'vertical' | 'horizontal';
|
|
88
|
+
fill?: unknown;
|
|
89
|
+
};
|
|
90
|
+
// Drop misleading visuals
|
|
91
|
+
delete rec.cornerRadius;
|
|
92
|
+
// Restore a sensible container width — fill_container lets the parent's
|
|
93
|
+
// layout decide actual width instead of locking us at 260px.
|
|
94
|
+
rec.width = 'fill_container';
|
|
95
|
+
// Drop the fixed bezel height; let content drive intrinsic height.
|
|
96
|
+
rec.height = 'fit_content';
|
|
97
|
+
// Force vertical so children stack instead of getting compressed
|
|
98
|
+
// horizontally inside a fake bezel.
|
|
99
|
+
rec.layout = 'vertical';
|
|
100
|
+
// Clear the misleading "Phone Mockup" name so downstream role inference
|
|
101
|
+
// doesn't act on it.
|
|
102
|
+
if (typeof rec.name === 'string' && /phone\s*mockup|app\s*mockup/i.test(rec.name)) {
|
|
103
|
+
rec.name = 'Section';
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isFakePhoneMockup(node: PenNode): boolean {
|
|
108
|
+
if (node.type !== 'frame') return false;
|
|
109
|
+
if (!('children' in node) || !Array.isArray(node.children)) return false;
|
|
110
|
+
|
|
111
|
+
// Detection signal #1: literal name match. Models often copy the prompt's
|
|
112
|
+
// wording verbatim into the node name.
|
|
113
|
+
const name = (node.name ?? '').toLowerCase();
|
|
114
|
+
const hasPhoneName = /phone\s*mockup|app\s*mockup|手机\s*样机|手机\s*模型|device\s*frame/.test(
|
|
115
|
+
name,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// Detection signal #2: visual signature. The combination of large radius
|
|
119
|
+
// (>= 28) and narrow width (240-320) is the classic phone bezel.
|
|
120
|
+
const cornerR = readCornerRadius(node);
|
|
121
|
+
const widthNum = typeof node.width === 'number' ? node.width : null;
|
|
122
|
+
const hasPhoneShape =
|
|
123
|
+
cornerR != null &&
|
|
124
|
+
cornerR >= 28 &&
|
|
125
|
+
cornerR <= 40 &&
|
|
126
|
+
widthNum != null &&
|
|
127
|
+
widthNum >= 240 &&
|
|
128
|
+
widthNum <= 320;
|
|
129
|
+
|
|
130
|
+
if (!hasPhoneName && !hasPhoneShape) return false;
|
|
131
|
+
|
|
132
|
+
// A real phone mockup is ONE frame with at most one placeholder child and
|
|
133
|
+
// `layout: 'none'` (or layout unset). Anything more elaborate is a sub-agent
|
|
134
|
+
// mistake.
|
|
135
|
+
const childCount = node.children.length;
|
|
136
|
+
const layout = (node as PenNode & { layout?: string }).layout;
|
|
137
|
+
const tooManyChildren = childCount > 1;
|
|
138
|
+
const wrongLayout = layout === 'horizontal' || layout === 'vertical';
|
|
139
|
+
return tooManyChildren || wrongLayout;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function readCornerRadius(node: PenNode): number | null {
|
|
143
|
+
const cr = (node as PenNode & { cornerRadius?: number | number[] }).cornerRadius;
|
|
144
|
+
if (typeof cr === 'number') return cr;
|
|
145
|
+
if (Array.isArray(cr) && cr.length > 0 && typeof cr[0] === 'number') return cr[0];
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// packages/pen-core/src/merge/index.ts
|
|
2
|
+
//
|
|
3
|
+
// Public surface for the merge module.
|
|
4
|
+
|
|
5
|
+
export type { NodePatch } from './node-diff.js';
|
|
6
|
+
export { diffDocuments } from './node-diff.js';
|
|
7
|
+
|
|
8
|
+
export type {
|
|
9
|
+
MergeInput,
|
|
10
|
+
MergeResult,
|
|
11
|
+
NodeConflict,
|
|
12
|
+
NodeConflictReason,
|
|
13
|
+
DocFieldConflict,
|
|
14
|
+
DocFieldName,
|
|
15
|
+
} from './node-merge.js';
|
|
16
|
+
export { mergeDocuments } from './node-merge.js';
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// packages/pen-core/src/merge/merge-helpers.ts
|
|
2
|
+
//
|
|
3
|
+
// Pure indexing and walking utilities shared by node-diff.ts and node-merge.ts.
|
|
4
|
+
// Not exported from the module's public surface — these are internal helpers.
|
|
5
|
+
|
|
6
|
+
import type { PenDocument, PenNode } from '@zseven-w/pen-types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Information stored per indexed node, capturing both the node itself and
|
|
10
|
+
* the structural context that the merge algorithm needs (page, parent, index).
|
|
11
|
+
*/
|
|
12
|
+
export interface IndexedNode {
|
|
13
|
+
/** null for legacy single-page documents (no pages array) */
|
|
14
|
+
pageId: string | null;
|
|
15
|
+
/** null when the node sits at the top of a page (or top of legacy children) */
|
|
16
|
+
parentId: string | null;
|
|
17
|
+
/** position within parent.children (or top-level array) */
|
|
18
|
+
index: number;
|
|
19
|
+
node: PenNode;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Walk a document and produce a Map<nodeId, IndexedNode>. Handles both
|
|
24
|
+
* `pages` and legacy `children` shapes uniformly.
|
|
25
|
+
*/
|
|
26
|
+
export function indexNodesById(doc: PenDocument): Map<string, IndexedNode> {
|
|
27
|
+
const out = new Map<string, IndexedNode>();
|
|
28
|
+
for (const page of getAllPages(doc)) {
|
|
29
|
+
walk(page.children, page.id, null, out);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function walk(
|
|
35
|
+
nodes: PenNode[],
|
|
36
|
+
pageId: string | null,
|
|
37
|
+
parentId: string | null,
|
|
38
|
+
out: Map<string, IndexedNode>,
|
|
39
|
+
): void {
|
|
40
|
+
nodes.forEach((node, index) => {
|
|
41
|
+
out.set(node.id, { pageId, parentId, index, node });
|
|
42
|
+
const children = (node as { children?: PenNode[] }).children;
|
|
43
|
+
if (children && children.length > 0) {
|
|
44
|
+
walk(children, pageId, node.id, out);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Normalize a document into a list of pages, regardless of whether it uses
|
|
51
|
+
* the explicit `pages` array or the legacy `children` array. Legacy mode
|
|
52
|
+
* produces a single synthetic page with `id = null`.
|
|
53
|
+
*/
|
|
54
|
+
export function getAllPages(doc: PenDocument): Array<{ id: string | null; children: PenNode[] }> {
|
|
55
|
+
if (doc.pages && doc.pages.length > 0) {
|
|
56
|
+
return doc.pages.map((p) => ({ id: p.id, children: p.children }));
|
|
57
|
+
}
|
|
58
|
+
return [{ id: null, children: doc.children ?? [] }];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Compare two nodes by atomic fields only (everything except `children`).
|
|
63
|
+
* Returns true if they would produce the same fields-only diff.
|
|
64
|
+
*/
|
|
65
|
+
export function nodeFieldsEqual(a: PenNode, b: PenNode): boolean {
|
|
66
|
+
const aFields = stripChildren(a);
|
|
67
|
+
const bFields = stripChildren(b);
|
|
68
|
+
return jsonEqual(aFields, bFields);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Return a shallow copy of `node` with the `children` field removed.
|
|
73
|
+
* Used everywhere we want to compare or diff a node's atomic fields without
|
|
74
|
+
* the recursive children noise.
|
|
75
|
+
*/
|
|
76
|
+
export function stripChildren<T extends PenNode>(node: T): Omit<T, 'children'> {
|
|
77
|
+
// Use destructuring; TS-safe.
|
|
78
|
+
const copy = { ...node } as T & { children?: unknown };
|
|
79
|
+
delete copy.children;
|
|
80
|
+
return copy as Omit<T, 'children'>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Deep value-equality via JSON canonicalization. Used by nodeFieldsEqual and
|
|
85
|
+
* by the doc-field merge for comparing variable values, theme entries, etc.
|
|
86
|
+
*
|
|
87
|
+
* Important: this is intentionally simple. PenNode field values are JSON-safe
|
|
88
|
+
* (numbers, strings, booleans, plain objects, arrays). No Dates, Maps, Sets,
|
|
89
|
+
* functions, etc. live in PenDocument.
|
|
90
|
+
*/
|
|
91
|
+
export function jsonEqual(a: unknown, b: unknown): boolean {
|
|
92
|
+
if (a === b) return true;
|
|
93
|
+
if (a === null || b === null) return false;
|
|
94
|
+
if (typeof a !== typeof b) return false;
|
|
95
|
+
if (typeof a !== 'object') return false;
|
|
96
|
+
// Both are non-null objects (or arrays)
|
|
97
|
+
return JSON.stringify(canonicalize(a)) === JSON.stringify(canonicalize(b));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Sort object keys recursively so JSON.stringify produces a deterministic string
|
|
102
|
+
* regardless of insertion order.
|
|
103
|
+
*/
|
|
104
|
+
function canonicalize(value: unknown): unknown {
|
|
105
|
+
if (value === null || typeof value !== 'object') return value;
|
|
106
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
107
|
+
const sortedKeys = Object.keys(value as Record<string, unknown>).sort();
|
|
108
|
+
const out: Record<string, unknown> = {};
|
|
109
|
+
for (const key of sortedKeys) {
|
|
110
|
+
out[key] = canonicalize((value as Record<string, unknown>)[key]);
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// packages/pen-core/src/merge/node-diff.ts
|
|
2
|
+
//
|
|
3
|
+
// One-direction diff: compute the patches needed to transform `base` into `next`.
|
|
4
|
+
|
|
5
|
+
import type { PenNode, PenDocument } from '@zseven-w/pen-types';
|
|
6
|
+
import { indexNodesById, nodeFieldsEqual, stripChildren, jsonEqual } from './merge-helpers.js';
|
|
7
|
+
|
|
8
|
+
export interface NodePatch {
|
|
9
|
+
op: 'add' | 'remove' | 'modify' | 'move';
|
|
10
|
+
/** null for legacy single-page documents (no pages array) */
|
|
11
|
+
pageId: string | null;
|
|
12
|
+
nodeId: string;
|
|
13
|
+
/** for `add` / `move`: target parent id; null = top-level on the page */
|
|
14
|
+
parentId?: string | null;
|
|
15
|
+
/** for `add` / `move`: target index within parent's children */
|
|
16
|
+
index?: number;
|
|
17
|
+
/** for `modify`: only the atomic fields that changed (never includes `children`) */
|
|
18
|
+
fields?: Partial<PenNode>;
|
|
19
|
+
/** for `modify`: pre-change values of those same fields, used by 3-way merge */
|
|
20
|
+
beforeFields?: Partial<PenNode>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Compute the patches needed to transform `base` into `next`. Walks both trees
|
|
25
|
+
* by node id and emits one of `add` / `remove` / `modify` / `move` per change.
|
|
26
|
+
*
|
|
27
|
+
* Algorithm:
|
|
28
|
+
* 1. Index both documents by node id.
|
|
29
|
+
* 2. For each id in base ∪ next:
|
|
30
|
+
* - in next only → `add`
|
|
31
|
+
* - in base only → `remove`
|
|
32
|
+
* - in both → check parent/index (if changed → `move`) and atomic fields
|
|
33
|
+
* (if any changed → `modify`); a single id may produce both a `move`
|
|
34
|
+
* and a `modify`.
|
|
35
|
+
*/
|
|
36
|
+
export function diffDocuments(base: PenDocument, next: PenDocument): NodePatch[] {
|
|
37
|
+
const baseIdx = indexNodesById(base);
|
|
38
|
+
const nextIdx = indexNodesById(next);
|
|
39
|
+
const allIds = new Set<string>([...baseIdx.keys(), ...nextIdx.keys()]);
|
|
40
|
+
const patches: NodePatch[] = [];
|
|
41
|
+
|
|
42
|
+
for (const id of allIds) {
|
|
43
|
+
const b = baseIdx.get(id);
|
|
44
|
+
const n = nextIdx.get(id);
|
|
45
|
+
|
|
46
|
+
if (!b && n) {
|
|
47
|
+
// Added.
|
|
48
|
+
patches.push({
|
|
49
|
+
op: 'add',
|
|
50
|
+
pageId: n.pageId,
|
|
51
|
+
nodeId: id,
|
|
52
|
+
parentId: n.parentId,
|
|
53
|
+
index: n.index,
|
|
54
|
+
fields: stripChildren(n.node) as Partial<PenNode>,
|
|
55
|
+
});
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (b && !n) {
|
|
60
|
+
// Removed.
|
|
61
|
+
patches.push({
|
|
62
|
+
op: 'remove',
|
|
63
|
+
pageId: b.pageId,
|
|
64
|
+
nodeId: id,
|
|
65
|
+
});
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (b && n) {
|
|
70
|
+
// Present in both. Check for `move` (parent or page changed) and `modify`
|
|
71
|
+
// (atomic fields changed). They are independent — one node may produce
|
|
72
|
+
// both kinds of patches.
|
|
73
|
+
const moved = b.parentId !== n.parentId || b.pageId !== n.pageId || b.index !== n.index;
|
|
74
|
+
if (moved) {
|
|
75
|
+
patches.push({
|
|
76
|
+
op: 'move',
|
|
77
|
+
pageId: n.pageId,
|
|
78
|
+
nodeId: id,
|
|
79
|
+
parentId: n.parentId,
|
|
80
|
+
index: n.index,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
if (!nodeFieldsEqual(b.node, n.node)) {
|
|
84
|
+
const { changed, before } = diffFields(b.node, n.node);
|
|
85
|
+
patches.push({
|
|
86
|
+
op: 'modify',
|
|
87
|
+
pageId: n.pageId,
|
|
88
|
+
nodeId: id,
|
|
89
|
+
fields: changed,
|
|
90
|
+
beforeFields: before,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return patches;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Compute the per-field delta between two nodes. Returns the keys that changed
|
|
101
|
+
* (in `next`) along with the original values (from `base`). Skips the `children`
|
|
102
|
+
* field — it's handled separately by the recursive walk.
|
|
103
|
+
*/
|
|
104
|
+
function diffFields(
|
|
105
|
+
base: PenNode,
|
|
106
|
+
next: PenNode,
|
|
107
|
+
): { changed: Partial<PenNode>; before: Partial<PenNode> } {
|
|
108
|
+
const baseStripped = stripChildren(base) as Record<string, unknown>;
|
|
109
|
+
const nextStripped = stripChildren(next) as Record<string, unknown>;
|
|
110
|
+
const allKeys = new Set<string>([...Object.keys(baseStripped), ...Object.keys(nextStripped)]);
|
|
111
|
+
const changed: Record<string, unknown> = {};
|
|
112
|
+
const before: Record<string, unknown> = {};
|
|
113
|
+
for (const key of allKeys) {
|
|
114
|
+
if (!jsonEqual(baseStripped[key], nextStripped[key])) {
|
|
115
|
+
changed[key] = nextStripped[key];
|
|
116
|
+
before[key] = baseStripped[key];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
changed: changed as Partial<PenNode>,
|
|
121
|
+
before: before as Partial<PenNode>,
|
|
122
|
+
};
|
|
123
|
+
}
|