@graph-knowledge/api 0.4.0 → 0.9.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.
Files changed (42) hide show
  1. package/package.json +1 -1
  2. package/src/index.d.ts +2 -1
  3. package/src/lib/clients/firebase-auth-client.d.ts +7 -0
  4. package/src/lib/clients/firebase-auth-client.js +20 -2
  5. package/src/lib/clients/firebase-firestore-client.js +83 -29
  6. package/src/lib/graph-knowledge-api.d.ts +21 -2
  7. package/src/lib/graph-knowledge-api.js +35 -5
  8. package/src/lib/interfaces/auth-client.interface.d.ts +10 -0
  9. package/src/lib/interfaces/custom-shape-operations.interface.d.ts +82 -0
  10. package/src/lib/interfaces/custom-shape-operations.interface.js +2 -0
  11. package/src/lib/interfaces/node-operations.interface.d.ts +29 -1
  12. package/src/lib/interfaces/template-operations.interface.d.ts +22 -1
  13. package/src/lib/operations/batch-operations.d.ts +3 -15
  14. package/src/lib/operations/batch-operations.js +8 -80
  15. package/src/lib/operations/custom-shape-operations.d.ts +27 -0
  16. package/src/lib/operations/custom-shape-operations.js +175 -0
  17. package/src/lib/operations/document-operations.js +9 -2
  18. package/src/lib/operations/element-operations.d.ts +3 -22
  19. package/src/lib/operations/element-operations.js +6 -119
  20. package/src/lib/operations/node-operations.d.ts +9 -3
  21. package/src/lib/operations/node-operations.js +18 -5
  22. package/src/lib/operations/template-operations.d.ts +3 -1
  23. package/src/lib/operations/template-operations.js +50 -7
  24. package/src/lib/testing/mock-auth-client.d.ts +2 -0
  25. package/src/lib/testing/mock-auth-client.js +7 -0
  26. package/src/lib/types/api-types.d.ts +113 -2
  27. package/src/lib/types/element-input-types.d.ts +38 -10
  28. package/src/lib/utils/element-builder.d.ts +63 -0
  29. package/src/lib/utils/element-builder.js +258 -0
  30. package/src/lib/utils/rotation.d.ts +4 -0
  31. package/src/lib/utils/rotation.js +13 -0
  32. package/src/lib/validators/custom-shape-definition-validator.d.ts +17 -0
  33. package/src/lib/validators/custom-shape-definition-validator.js +135 -0
  34. package/src/lib/validators/element-type-validators/base-element-validator.d.ts +4 -0
  35. package/src/lib/validators/element-type-validators/base-element-validator.js +30 -0
  36. package/src/lib/validators/element-type-validators/basic-shape-validators.js +2 -0
  37. package/src/lib/validators/element-type-validators/block-arrow-validator.js +2 -0
  38. package/src/lib/validators/element-type-validators/line-validator.d.ts +1 -0
  39. package/src/lib/validators/element-type-validators/line-validator.js +12 -5
  40. package/src/lib/validators/element-type-validators/rectangle-validator.js +2 -0
  41. package/src/lib/validators/template-validator.d.ts +7 -1
  42. package/src/lib/validators/template-validator.js +21 -0
@@ -111,6 +111,40 @@ class TemplateOperations {
111
111
  nodes
112
112
  };
113
113
  }
114
+ async update(templateId, input) {
115
+ this.auth.requireAuth();
116
+ await this.auth.requireAdmin();
117
+ this.validator.validateUpdate(input);
118
+ const docData = await this.firestore.getDocument(`documents/${templateId}`);
119
+ if (!docData) {
120
+ throw new api_errors_1.NotFoundError(`Template ${templateId} not found`);
121
+ }
122
+ if (!docData.isTemplate) {
123
+ throw new api_errors_1.NotFoundError(`Document ${templateId} is not a template`);
124
+ }
125
+ const updates = {};
126
+ if (input.title !== undefined)
127
+ updates.title = input.title;
128
+ if (input.content !== undefined)
129
+ updates.content = input.content;
130
+ if (Object.keys(updates).length > 0) {
131
+ await this.firestore.updateDocument(`documents/${templateId}`, updates);
132
+ }
133
+ }
134
+ async delete(templateId) {
135
+ this.auth.requireAuth();
136
+ await this.auth.requireAdmin();
137
+ const docData = await this.firestore.getDocument(`documents/${templateId}`);
138
+ if (!docData) {
139
+ throw new api_errors_1.NotFoundError(`Template ${templateId} not found`);
140
+ }
141
+ if (!docData.isTemplate) {
142
+ throw new api_errors_1.NotFoundError(`Document ${templateId} is not a template`);
143
+ }
144
+ await this.firestore.deleteDocument(`documents/${templateId}`);
145
+ // Note: Sub-collection nodes are not automatically deleted
146
+ // A Cloud Function should handle cascading deletion
147
+ }
114
148
  async clone(templateId, title) {
115
149
  const userId = this.auth.requireAuth();
116
150
  // Require premium access for cloning
@@ -213,9 +247,13 @@ class TemplateOperations {
213
247
  level: node.level,
214
248
  owner: userId
215
249
  };
216
- // Only include parentNodeId if it exists (Firestore doesn't allow undefined)
250
+ // Remap parentNodeId. Drop if the parent doesn't exist in the mapping
251
+ // (orphaned reference) rather than keeping stale IDs.
217
252
  if (node.parentNodeId) {
218
- clonedNode.parentNodeId = idMapping.get(node.parentNodeId) || node.parentNodeId;
253
+ const mappedParentId = idMapping.get(node.parentNodeId);
254
+ if (mappedParentId) {
255
+ clonedNode.parentNodeId = mappedParentId;
256
+ }
219
257
  }
220
258
  return clonedNode;
221
259
  }
@@ -248,12 +286,17 @@ class TemplateOperations {
248
286
  rotation: element.rotation,
249
287
  properties: clonedProperties
250
288
  };
251
- // Only include optional fields if they have values (Firestore doesn't allow undefined)
252
- if (element.isLink !== undefined) {
253
- clonedElement.isLink = element.isLink;
254
- }
289
+ // Remap link target to new node ID. If the target doesn't exist in the
290
+ // mapping (e.g. orphaned link), drop the link rather than keeping stale IDs.
255
291
  if (element.linkTarget) {
256
- clonedElement.linkTarget = idMapping.get(element.linkTarget) || element.linkTarget;
292
+ const mappedTarget = idMapping.get(element.linkTarget);
293
+ if (mappedTarget) {
294
+ clonedElement.isLink = element.isLink ?? true;
295
+ clonedElement.linkTarget = mappedTarget;
296
+ }
297
+ }
298
+ else if (element.isLink !== undefined) {
299
+ clonedElement.isLink = element.isLink;
257
300
  }
258
301
  return clonedElement;
259
302
  }
@@ -12,6 +12,7 @@ export declare class MockAuthClient implements IAuthClient {
12
12
  isAdmin?: boolean;
13
13
  });
14
14
  signIn(email: string, _password: string): Promise<void>;
15
+ signInWithCustomToken(_token: string): Promise<void>;
15
16
  signOut(): Promise<void>;
16
17
  get currentUserId(): string | null;
17
18
  requireAuth(): string;
@@ -31,4 +32,5 @@ export declare class MockAuthClient implements IAuthClient {
31
32
  * Sets the admin status for testing.
32
33
  */
33
34
  setAdmin(isAdmin: boolean): void;
35
+ destroy(): void;
34
36
  }
@@ -18,6 +18,10 @@ class MockAuthClient {
18
18
  // Simulate successful sign-in
19
19
  this._currentUserId = `mock-user-${email.split("@")[0]}`;
20
20
  }
21
+ async signInWithCustomToken(_token) {
22
+ // Simulate successful custom token sign-in
23
+ this._currentUserId = this._currentUserId ?? "mock-custom-token-user";
24
+ }
21
25
  async signOut() {
22
26
  this._currentUserId = null;
23
27
  }
@@ -66,5 +70,8 @@ class MockAuthClient {
66
70
  setAdmin(isAdmin) {
67
71
  this._isAdmin = isAdmin;
68
72
  }
73
+ destroy() {
74
+ // No-op for mock
75
+ }
69
76
  }
70
77
  exports.MockAuthClient = MockAuthClient;
@@ -1,4 +1,5 @@
1
- import { Document, GraphNode } from "../models";
1
+ import { Document, GraphElement, GraphNode } from "../models";
2
+ import { AnyElementInput } from "./element-input-types";
2
3
  /**
3
4
  * Input for creating a new document.
4
5
  */
@@ -22,10 +23,13 @@ export interface UpdateDocumentInput {
22
23
  content?: string;
23
24
  }
24
25
  /**
25
- * Result of document operations, extends Document with required id.
26
+ * Result of document operations.
27
+ * Always includes `id` and `rootNodeId` — every document has a root node created at document creation time.
28
+ * Use `rootNodeId` to add elements directly to the document's root canvas.
26
29
  */
27
30
  export type DocumentResult = Document & {
28
31
  id: string;
32
+ rootNodeId: string;
29
33
  };
30
34
  /**
31
35
  * Input for creating a new node.
@@ -59,6 +63,22 @@ export interface UpdateNodeInput {
59
63
  export type NodeResult = GraphNode & {
60
64
  id: string;
61
65
  };
66
+ /**
67
+ * Input for creating a node with all its elements in a single atomic operation.
68
+ */
69
+ export interface CreateNodeWithElementsInput extends CreateNodeInput {
70
+ /** Elements to create within the node */
71
+ elements: AnyElementInput[];
72
+ }
73
+ /**
74
+ * Result of creating a node with elements.
75
+ */
76
+ export interface CreateNodeWithElementsResult {
77
+ /** The created node (includes elements in its elements array) */
78
+ node: GraphNode;
79
+ /** The created elements with their generated IDs */
80
+ elements: GraphElement[];
81
+ }
62
82
  /**
63
83
  * Input for creating a new template (admin only).
64
84
  */
@@ -74,3 +94,94 @@ export interface CreateTemplateInput {
74
94
  /** Whether to publish immediately (defaults to false - draft) */
75
95
  isPublished?: boolean;
76
96
  }
97
+ /**
98
+ * Input for updating an existing template (admin only).
99
+ */
100
+ export interface UpdateTemplateInput {
101
+ title?: string;
102
+ content?: string;
103
+ }
104
+ /**
105
+ * ViewBox configuration for SVG scaling.
106
+ */
107
+ export interface ViewBoxInput {
108
+ minX: number;
109
+ minY: number;
110
+ width: number;
111
+ height: number;
112
+ }
113
+ /**
114
+ * Input for creating a new custom shape.
115
+ */
116
+ export interface CreateCustomShapeInput {
117
+ /** Shape display name (required) */
118
+ name: string;
119
+ /** Optional description for the store listing */
120
+ description?: string;
121
+ /** Material icon name for the toolbar button (defaults to "category") */
122
+ icon?: string;
123
+ /** Category for toolbar grouping (defaults to "Custom") */
124
+ category?: string;
125
+ /** The SVG path "d" attribute (required) */
126
+ svgPathData: string;
127
+ /** ViewBox for proper SVG scaling (required) */
128
+ viewBox: ViewBoxInput;
129
+ /** Default width when element is created (defaults to 120) */
130
+ defaultWidth?: number;
131
+ /** Default height when element is created (defaults to 120) */
132
+ defaultHeight?: number;
133
+ /** If true, aspect ratio is preserved on resize */
134
+ aspectRatioLocked?: boolean;
135
+ /** Default property values for new elements */
136
+ defaultProperties?: {
137
+ fillColor?: string;
138
+ strokeColor?: string;
139
+ strokeWidth?: number;
140
+ };
141
+ }
142
+ /**
143
+ * Input for updating an existing custom shape.
144
+ */
145
+ export interface UpdateCustomShapeInput {
146
+ name?: string;
147
+ description?: string;
148
+ icon?: string;
149
+ category?: string;
150
+ svgPathData?: string;
151
+ viewBox?: ViewBoxInput;
152
+ defaultWidth?: number;
153
+ defaultHeight?: number;
154
+ aspectRatioLocked?: boolean;
155
+ defaultProperties?: {
156
+ fillColor?: string;
157
+ strokeColor?: string;
158
+ strokeWidth?: number;
159
+ };
160
+ }
161
+ /**
162
+ * Result of custom shape operations.
163
+ */
164
+ export interface CustomShapeResult {
165
+ id: string;
166
+ name: string;
167
+ description?: string;
168
+ icon: string;
169
+ category: string;
170
+ authorId: string;
171
+ authorDisplayName?: string;
172
+ status: "draft" | "published";
173
+ createdAt: Date;
174
+ updatedAt?: Date;
175
+ publishedAt?: Date;
176
+ svgPathData: string;
177
+ viewBox: ViewBoxInput;
178
+ defaultWidth: number;
179
+ defaultHeight: number;
180
+ aspectRatioLocked?: boolean;
181
+ defaultProperties: {
182
+ fillColor: string;
183
+ strokeColor: string;
184
+ strokeWidth: number;
185
+ };
186
+ schemaVersion: number;
187
+ }
@@ -1,11 +1,19 @@
1
1
  /**
2
2
  * Common properties for all element inputs.
3
+ *
4
+ * Position can be specified as either x/y (top-left) or centerX/centerY (center point).
5
+ * When centerX/centerY are used, x/y are computed as `centerX - width/2` and `centerY - height/2`.
6
+ * You cannot specify both x and centerX, or both y and centerY.
3
7
  */
4
8
  export interface BaseElementInput {
5
- /** X position on canvas */
6
- x: number;
7
- /** Y position on canvas */
8
- y: number;
9
+ /** X position on canvas (top-left corner). Cannot be used with centerX. */
10
+ x?: number;
11
+ /** Y position on canvas (top-left corner). Cannot be used with centerY. */
12
+ y?: number;
13
+ /** Center X position. Computes x as centerX - width/2. Cannot be used with x. */
14
+ centerX?: number;
15
+ /** Center Y position. Computes y as centerY - height/2. Cannot be used with y. */
16
+ centerY?: number;
9
17
  /** Element width (uses default if not specified) */
10
18
  width?: number;
11
19
  /** Element height (uses default if not specified) */
@@ -17,10 +25,26 @@ export interface BaseElementInput {
17
25
  /** Target node ID when isLink is true */
18
26
  linkTarget?: string;
19
27
  }
28
+ /**
29
+ * Optional text label properties for basic shapes.
30
+ * Supported by: rectangle, triangle, diamond, hexagon, ellipse, block-arrow.
31
+ */
32
+ export interface ShapeTextLabelProperties {
33
+ /** Text label displayed inside the shape */
34
+ text?: string;
35
+ /** Font size in pixels (default: 14) */
36
+ fontSize?: number;
37
+ /** Text color (hex, e.g., "#000000") */
38
+ textColor?: string;
39
+ /** Horizontal text alignment */
40
+ textAlign?: "left" | "center" | "right";
41
+ /** Vertical text alignment */
42
+ verticalAlign?: "top" | "middle" | "bottom";
43
+ }
20
44
  /**
21
45
  * Input for creating a rectangle element.
22
46
  */
23
- export interface RectangleInput extends BaseElementInput {
47
+ export interface RectangleInput extends BaseElementInput, ShapeTextLabelProperties {
24
48
  type: "rectangle";
25
49
  /** Fill color (hex, e.g., "#FF5733") */
26
50
  fillColor?: string;
@@ -206,7 +230,7 @@ export interface UmlNoteInput extends BaseElementInput {
206
230
  /**
207
231
  * Input for creating a triangle element.
208
232
  */
209
- export interface TriangleInput extends BaseElementInput {
233
+ export interface TriangleInput extends BaseElementInput, ShapeTextLabelProperties {
210
234
  type: "triangle";
211
235
  /** Fill color (hex, e.g., "#FF5733") */
212
236
  fillColor?: string;
@@ -218,7 +242,7 @@ export interface TriangleInput extends BaseElementInput {
218
242
  /**
219
243
  * Input for creating a diamond element.
220
244
  */
221
- export interface DiamondInput extends BaseElementInput {
245
+ export interface DiamondInput extends BaseElementInput, ShapeTextLabelProperties {
222
246
  type: "diamond";
223
247
  /** Fill color (hex, e.g., "#FF5733") */
224
248
  fillColor?: string;
@@ -230,7 +254,7 @@ export interface DiamondInput extends BaseElementInput {
230
254
  /**
231
255
  * Input for creating a hexagon element.
232
256
  */
233
- export interface HexagonInput extends BaseElementInput {
257
+ export interface HexagonInput extends BaseElementInput, ShapeTextLabelProperties {
234
258
  type: "hexagon";
235
259
  /** Fill color (hex, e.g., "#FF5733") */
236
260
  fillColor?: string;
@@ -242,7 +266,7 @@ export interface HexagonInput extends BaseElementInput {
242
266
  /**
243
267
  * Input for creating an ellipse element.
244
268
  */
245
- export interface EllipseInput extends BaseElementInput {
269
+ export interface EllipseInput extends BaseElementInput, ShapeTextLabelProperties {
246
270
  type: "ellipse";
247
271
  /** Fill color (hex, e.g., "#FF5733") */
248
272
  fillColor?: string;
@@ -262,6 +286,10 @@ export interface LineInput extends BaseElementInput {
262
286
  strokeWidth?: number;
263
287
  /** Line style (reuses LineStyle from connectors) */
264
288
  lineStyle?: LineStyle;
289
+ /** Marker at start of line */
290
+ startMarker?: MarkerType;
291
+ /** Marker at end of line */
292
+ endMarker?: MarkerType;
265
293
  }
266
294
  /**
267
295
  * Input for creating a cubic bezier curve element.
@@ -298,7 +326,7 @@ export type BlockArrowDirection = "right" | "left" | "up" | "down";
298
326
  /**
299
327
  * Input for creating a block arrow element.
300
328
  */
301
- export interface BlockArrowInput extends BaseElementInput {
329
+ export interface BlockArrowInput extends BaseElementInput, ShapeTextLabelProperties {
302
330
  type: "block-arrow";
303
331
  /** Fill color (hex, e.g., "#FF5733") */
304
332
  fillColor?: string;
@@ -0,0 +1,63 @@
1
+ import { GraphElement } from "../models";
2
+ import { IElementValidatorRegistry } from "../interfaces/element-validator.interface";
3
+ import { IFirestoreClient } from "../interfaces/firestore-client.interface";
4
+ import { IAuthClient } from "../interfaces/auth-client.interface";
5
+ import { AnyElementInput } from "../types/element-input-types";
6
+ import { CustomShapeResult } from "../types/api-types";
7
+ /**
8
+ * Builds GraphElement objects from AnyElementInput without persisting to Firestore.
9
+ *
10
+ * Handles: validation, default dimensions, centerX/centerY resolution,
11
+ * text auto-measurement, property building, custom shape enrichment.
12
+ *
13
+ * Shared by ElementOperations (single create) and NodeOperations (createWithElements).
14
+ */
15
+ export declare class ElementBuilder {
16
+ private readonly validatorRegistry;
17
+ private readonly firestore;
18
+ private readonly auth;
19
+ private static _textMeasurementService;
20
+ constructor(validatorRegistry: IElementValidatorRegistry, firestore: IFirestoreClient, auth: IAuthClient);
21
+ /**
22
+ * Builds multiple GraphElements from inputs.
23
+ * Pre-fetches custom shape definitions with deduplication for efficiency.
24
+ */
25
+ buildMany(inputs: AnyElementInput[]): Promise<GraphElement[]>;
26
+ /**
27
+ * Builds a GraphElement from input.
28
+ * Validates, resolves defaults and center positioning, builds properties,
29
+ * enriches custom shapes. Does NOT write to Firestore.
30
+ */
31
+ build(input: AnyElementInput, shapeCache?: Map<string, CustomShapeResult>): Promise<GraphElement>;
32
+ /**
33
+ * Validates that centerX/centerY are not used together with x/y.
34
+ */
35
+ private validateCenterPosition;
36
+ /**
37
+ * Resolves x/y position, handling centerX/centerY conversion.
38
+ */
39
+ private resolvePosition;
40
+ /**
41
+ * Gets default dimensions for an element type.
42
+ */
43
+ private getDefaultDimensions;
44
+ /**
45
+ * Auto-measures text width for text elements created without explicit width/maxWidth.
46
+ */
47
+ private measureTextWidth;
48
+ /**
49
+ * Pre-fetches custom shape definitions from the shape store.
50
+ * Each unique shape is fetched only once for efficiency.
51
+ */
52
+ private _prefetchCustomShapes;
53
+ /**
54
+ * Enriches custom shape element properties with shape definition data
55
+ * from the published shape store or a pre-fetched cache.
56
+ */
57
+ private _enrichCustomShapeProperties;
58
+ /**
59
+ * Builds properties object from element input.
60
+ * Extracts type-specific properties, excluding common/positional fields.
61
+ */
62
+ private buildProperties;
63
+ }
@@ -0,0 +1,258 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ElementBuilder = void 0;
4
+ const uuid_1 = require("./uuid");
5
+ const api_errors_1 = require("../errors/api-errors");
6
+ const element_defaults_1 = require("../constants/element-defaults");
7
+ const text_measurement_service_1 = require("./text-measurement/text-measurement-service");
8
+ const rotation_1 = require("./rotation");
9
+ /**
10
+ * Builds GraphElement objects from AnyElementInput without persisting to Firestore.
11
+ *
12
+ * Handles: validation, default dimensions, centerX/centerY resolution,
13
+ * text auto-measurement, property building, custom shape enrichment.
14
+ *
15
+ * Shared by ElementOperations (single create) and NodeOperations (createWithElements).
16
+ */
17
+ class ElementBuilder {
18
+ validatorRegistry;
19
+ firestore;
20
+ auth;
21
+ static _textMeasurementService = null;
22
+ constructor(validatorRegistry, firestore, auth) {
23
+ this.validatorRegistry = validatorRegistry;
24
+ this.firestore = firestore;
25
+ this.auth = auth;
26
+ }
27
+ /**
28
+ * Builds multiple GraphElements from inputs.
29
+ * Pre-fetches custom shape definitions with deduplication for efficiency.
30
+ */
31
+ async buildMany(inputs) {
32
+ // Pre-fetch custom shape definitions (one fetch per unique shape)
33
+ const shapeCache = await this._prefetchCustomShapes(inputs);
34
+ const elements = [];
35
+ for (const input of inputs) {
36
+ elements.push(await this.build(input, shapeCache));
37
+ }
38
+ return elements;
39
+ }
40
+ /**
41
+ * Builds a GraphElement from input.
42
+ * Validates, resolves defaults and center positioning, builds properties,
43
+ * enriches custom shapes. Does NOT write to Firestore.
44
+ */
45
+ async build(input, shapeCache) {
46
+ // Custom shapes require premium
47
+ if (input.type.startsWith("custom:")) {
48
+ await this.auth.requirePremium();
49
+ }
50
+ // Validate centerX/centerY conflicts before anything else
51
+ this.validateCenterPosition(input);
52
+ // Resolve default dimensions
53
+ const defaults = this.getDefaultDimensions(input.type);
54
+ // Resolve text width
55
+ let resolvedWidth = input.width;
56
+ if (input.type === "text" && resolvedWidth === undefined) {
57
+ const textInput = input;
58
+ if (textInput.maxWidth !== undefined) {
59
+ resolvedWidth = textInput.maxWidth;
60
+ }
61
+ else {
62
+ resolvedWidth = this.measureTextWidth(textInput);
63
+ }
64
+ }
65
+ const width = resolvedWidth ?? defaults.width;
66
+ const height = input.height ?? defaults.height;
67
+ // Resolve centerX/centerY → x/y (after defaults, since center depends on dimensions)
68
+ const { x, y } = this.resolvePosition(input, width, height);
69
+ // Create a resolved copy of the input with x/y set for validation
70
+ const resolvedInput = { ...input, x, y };
71
+ // Remove center fields so validators don't see unknown properties
72
+ delete resolvedInput["centerX"];
73
+ delete resolvedInput["centerY"];
74
+ // Validate the resolved input (validators expect x/y to be numbers)
75
+ this.validatorRegistry.validate(resolvedInput);
76
+ let properties = this.buildProperties(input);
77
+ // For custom shapes, bake shape definition into properties
78
+ if (input.type.startsWith("custom:")) {
79
+ properties = await this._enrichCustomShapeProperties(properties, shapeCache);
80
+ }
81
+ const element = {
82
+ id: (0, uuid_1.generateUuid)(),
83
+ elementType: input.type,
84
+ x,
85
+ y,
86
+ width,
87
+ height,
88
+ rotation: (0, rotation_1.normalizeRotation)(input.rotation ?? 0),
89
+ properties
90
+ };
91
+ // Only add link properties if they have values (Firestore rejects undefined)
92
+ if (input.isLink !== undefined) {
93
+ element.isLink = input.isLink;
94
+ }
95
+ if (input.linkTarget !== undefined) {
96
+ element.linkTarget = input.linkTarget;
97
+ }
98
+ return element;
99
+ }
100
+ /**
101
+ * Validates that centerX/centerY are not used together with x/y.
102
+ */
103
+ validateCenterPosition(input) {
104
+ // ConnectorInput doesn't support center positioning
105
+ if (input.type === "connector") {
106
+ return;
107
+ }
108
+ const record = input;
109
+ const hasCenterX = record["centerX"] !== undefined;
110
+ const hasCenterY = record["centerY"] !== undefined;
111
+ if (hasCenterX && input.x !== undefined) {
112
+ throw new api_errors_1.ValidationError("Cannot specify both x and centerX", "centerX");
113
+ }
114
+ if (hasCenterY && input.y !== undefined) {
115
+ throw new api_errors_1.ValidationError("Cannot specify both y and centerY", "centerY");
116
+ }
117
+ // Validate types if provided
118
+ if (hasCenterX) {
119
+ const centerX = record["centerX"];
120
+ if (typeof centerX !== "number" || isNaN(centerX)) {
121
+ throw new api_errors_1.ValidationError("centerX must be a valid number", "centerX");
122
+ }
123
+ }
124
+ if (hasCenterY) {
125
+ const centerY = record["centerY"];
126
+ if (typeof centerY !== "number" || isNaN(centerY)) {
127
+ throw new api_errors_1.ValidationError("centerY must be a valid number", "centerY");
128
+ }
129
+ }
130
+ }
131
+ /**
132
+ * Resolves x/y position, handling centerX/centerY conversion.
133
+ */
134
+ resolvePosition(input, width, height) {
135
+ const record = input;
136
+ const centerX = record["centerX"];
137
+ const centerY = record["centerY"];
138
+ return {
139
+ x: centerX !== undefined
140
+ ? centerX - width / 2
141
+ : (input.x ?? 0),
142
+ y: centerY !== undefined
143
+ ? centerY - height / 2
144
+ : (input.y ?? 0)
145
+ };
146
+ }
147
+ /**
148
+ * Gets default dimensions for an element type.
149
+ */
150
+ getDefaultDimensions(type) {
151
+ if (type.startsWith("custom:")) {
152
+ return element_defaults_1.DEFAULT_CUSTOM_SHAPE_DIMENSIONS;
153
+ }
154
+ return (element_defaults_1.DEFAULT_ELEMENT_DIMENSIONS[type] ?? element_defaults_1.DEFAULT_CUSTOM_SHAPE_DIMENSIONS);
155
+ }
156
+ /**
157
+ * Auto-measures text width for text elements created without explicit width/maxWidth.
158
+ */
159
+ measureTextWidth(input) {
160
+ if (!ElementBuilder._textMeasurementService) {
161
+ ElementBuilder._textMeasurementService =
162
+ new text_measurement_service_1.TextMeasurementService();
163
+ }
164
+ const metrics = ElementBuilder._textMeasurementService.measureText(input.text, {
165
+ fontSize: input.fontSize,
166
+ fontFamily: input.fontFamily,
167
+ fontWeight: input.fontWeight,
168
+ textAlign: input.textAlign,
169
+ lineHeight: input.lineHeight
170
+ });
171
+ return Math.ceil(metrics.width);
172
+ }
173
+ /**
174
+ * Pre-fetches custom shape definitions from the shape store.
175
+ * Each unique shape is fetched only once for efficiency.
176
+ */
177
+ async _prefetchCustomShapes(inputs) {
178
+ const shapeIds = new Set();
179
+ for (const input of inputs) {
180
+ if (input.type.startsWith("custom:")) {
181
+ const shapeId = input["customShapeId"];
182
+ if (shapeId) {
183
+ shapeIds.add(shapeId);
184
+ }
185
+ }
186
+ }
187
+ const cache = new Map();
188
+ await Promise.all(Array.from(shapeIds).map(async (shapeId) => {
189
+ const shape = await this.firestore.getDocument(`shapeStore/${shapeId}`);
190
+ if (shape) {
191
+ cache.set(shapeId, shape);
192
+ }
193
+ }));
194
+ return cache;
195
+ }
196
+ /**
197
+ * Enriches custom shape element properties with shape definition data
198
+ * from the published shape store or a pre-fetched cache.
199
+ */
200
+ async _enrichCustomShapeProperties(properties, shapeCache) {
201
+ const shapeId = properties["customShapeId"];
202
+ if (!shapeId) {
203
+ return properties;
204
+ }
205
+ // Skip if already has svgPathData (caller provided it explicitly)
206
+ if (properties["svgPathData"]) {
207
+ return properties;
208
+ }
209
+ // Use cache if available, otherwise fetch directly
210
+ const shape = shapeCache
211
+ ? shapeCache.get(shapeId) ?? null
212
+ : await this.firestore.getDocument(`shapeStore/${shapeId}`);
213
+ if (!shape) {
214
+ return properties;
215
+ }
216
+ return {
217
+ ...properties,
218
+ svgPathData: shape.svgPathData,
219
+ viewBox: shape.viewBox,
220
+ aspectRatioLocked: shape.aspectRatioLocked ?? false
221
+ };
222
+ }
223
+ /**
224
+ * Builds properties object from element input.
225
+ * Extracts type-specific properties, excluding common/positional fields.
226
+ */
227
+ buildProperties(input) {
228
+ const props = {
229
+ __schemaVersion: 1
230
+ };
231
+ // Exclude common fields, positional aliases, and convenience aliases
232
+ const commonFields = new Set([
233
+ "type",
234
+ "x",
235
+ "y",
236
+ "centerX",
237
+ "centerY",
238
+ "width",
239
+ "height",
240
+ "rotation",
241
+ "isLink",
242
+ "linkTarget",
243
+ "maxWidth"
244
+ ]);
245
+ for (const [key, value] of Object.entries(input)) {
246
+ if (!commonFields.has(key) && value !== undefined) {
247
+ props[key] = value;
248
+ }
249
+ }
250
+ // For text elements, set __usesTopLeft: true so the app knows
251
+ // that x/y is the bounding box top-left, not the text anchor point.
252
+ if (input.type === "text") {
253
+ props["__usesTopLeft"] = true;
254
+ }
255
+ return props;
256
+ }
257
+ }
258
+ exports.ElementBuilder = ElementBuilder;
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Normalizes rotation to integer in range [0, 360).
3
+ */
4
+ export declare function normalizeRotation(rotation: number): number;