@graph-knowledge/api 0.2.0 → 0.3.2
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 +118 -0
- package/package.json +1 -1
- package/src/index.d.ts +2 -0
- package/src/index.js +4 -1
- package/src/lib/graph-knowledge-api.d.ts +33 -0
- package/src/lib/graph-knowledge-api.js +46 -0
- package/src/lib/operations/element-operations.d.ts +6 -0
- package/src/lib/operations/element-operations.js +43 -16
- package/src/lib/operations/template-operations.js +6 -12
- package/src/lib/types/element-input-types.d.ts +2 -0
- package/src/lib/types/layout-types.d.ts +71 -0
- package/src/lib/types/layout-types.js +2 -0
- package/src/lib/utils/layout/layout-helper.d.ts +37 -0
- package/src/lib/utils/layout/layout-helper.js +173 -0
- package/src/lib/validators/element-type-validators/text-validator.d.ts +1 -0
- package/src/lib/validators/element-type-validators/text-validator.js +11 -0
package/README.md
CHANGED
|
@@ -92,6 +92,7 @@ new GraphKnowledgeAPI(config: ApiConfig)
|
|
|
92
92
|
| `signOut()` | Signs out the current user |
|
|
93
93
|
| `waitForAuthInit()` | Waits for Firebase Auth to initialize |
|
|
94
94
|
| `measureText(text, options?)` | Measures text dimensions (static method, no auth required) |
|
|
95
|
+
| `fitTextToShape(text, bounds, options?)` | Fits text into a shape's bounds (static method, no auth required) |
|
|
95
96
|
|
|
96
97
|
#### Properties
|
|
97
98
|
|
|
@@ -185,6 +186,11 @@ const rect = await api.elements.create(documentId, nodeId, {
|
|
|
185
186
|
});
|
|
186
187
|
|
|
187
188
|
// Create text
|
|
189
|
+
// If you omit both `width` and `maxWidth`, the API will auto-measure the text
|
|
190
|
+
// and use a tight single-line bounding box. This is convenient for simple labels.
|
|
191
|
+
// For predictable wrapping or alignment in more complex layouts, explicitly set
|
|
192
|
+
// `width` or `maxWidth`. You can use measureText() to choose a width, or
|
|
193
|
+
// fitTextToShape() for text inside shapes.
|
|
188
194
|
await api.elements.create(documentId, nodeId, {
|
|
189
195
|
type: "text",
|
|
190
196
|
x: 100,
|
|
@@ -219,6 +225,18 @@ await api.elements.create(documentId, nodeId, {
|
|
|
219
225
|
});
|
|
220
226
|
// Note: height is auto-calculated based on the number of lines and lineHeight
|
|
221
227
|
|
|
228
|
+
// Create text with auto-wrapping
|
|
229
|
+
// maxWidth sets the wrap boundary — text will wrap at word boundaries
|
|
230
|
+
await api.elements.create(documentId, nodeId, {
|
|
231
|
+
type: "text",
|
|
232
|
+
x: 100,
|
|
233
|
+
y: 300,
|
|
234
|
+
text: "This is a long paragraph that will automatically wrap within the specified width boundary.",
|
|
235
|
+
maxWidth: 400, // Text wraps at 400px
|
|
236
|
+
fontSize: 18
|
|
237
|
+
});
|
|
238
|
+
// Note: height is auto-calculated based on wrapped lines
|
|
239
|
+
|
|
222
240
|
// Create a connector
|
|
223
241
|
await api.elements.create(documentId, nodeId, {
|
|
224
242
|
type: "connector",
|
|
@@ -450,6 +468,106 @@ npm install canvas
|
|
|
450
468
|
Without it, the API uses character-based estimation (less accurate).
|
|
451
469
|
The `canvas` package requires native compilation - see [node-canvas requirements](https://github.com/Automattic/node-canvas#compiling).
|
|
452
470
|
|
|
471
|
+
### Text Width Behavior
|
|
472
|
+
|
|
473
|
+
When you create a text element via the API without `width` or `maxWidth`, the API **auto-measures** the text and sets the width to fit the content. This prevents the legacy 100px default that caused unexpected wrapping.
|
|
474
|
+
|
|
475
|
+
For predictable wrapping or text inside shapes, explicitly set `width` or `maxWidth`:
|
|
476
|
+
|
|
477
|
+
```typescript
|
|
478
|
+
// Auto-measured — convenient for simple labels
|
|
479
|
+
await api.elements.create(docId, nodeId, {
|
|
480
|
+
type: "text", x: 100, y: 100,
|
|
481
|
+
text: "My Long Title",
|
|
482
|
+
fontSize: 24
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// Explicit width — for controlled wrapping
|
|
486
|
+
const m = GraphKnowledgeAPI.measureText("My Long Title", { fontSize: 24 });
|
|
487
|
+
await api.elements.create(docId, nodeId, {
|
|
488
|
+
type: "text", x: 100, y: 100, width: m.width,
|
|
489
|
+
text: "My Long Title",
|
|
490
|
+
fontSize: 24
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// fitTextToShape — for text inside shapes (sets maxWidth automatically)
|
|
494
|
+
const fit = GraphKnowledgeAPI.fitTextToShape("My Long Title", shapeBounds);
|
|
495
|
+
await api.elements.create(docId, nodeId, { type: "text", ...fit.textInput });
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
## Layout Helpers
|
|
499
|
+
|
|
500
|
+
Fit text into shapes with automatic wrapping, shrinking, or both. Returns a ready-to-use `textInput` for element creation.
|
|
501
|
+
|
|
502
|
+
```typescript
|
|
503
|
+
import { GraphKnowledgeAPI } from "@graph-knowledge/api";
|
|
504
|
+
|
|
505
|
+
// Fit text into a rectangle (default: wrap strategy)
|
|
506
|
+
const result = GraphKnowledgeAPI.fitTextToShape("Hello World", {
|
|
507
|
+
x: 100, y: 100, width: 200, height: 100
|
|
508
|
+
});
|
|
509
|
+
console.log(result.fits); // true if text fits within bounds
|
|
510
|
+
console.log(result.fontSize); // final font size used
|
|
511
|
+
|
|
512
|
+
// Create the text element directly
|
|
513
|
+
await api.elements.create(docId, nodeId, { type: "text", ...result.textInput });
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
### Strategies
|
|
517
|
+
|
|
518
|
+
| Strategy | Behavior |
|
|
519
|
+
|----------|----------|
|
|
520
|
+
| `"wrap"` (default) | Wraps text at word boundaries. Reports `fits: false` if height overflows. |
|
|
521
|
+
| `"shrink"` | Reduces font size (down to `minFontSize`) until text fits in one line. |
|
|
522
|
+
| `"auto"` | Tries wrapping first. If height overflows, shrinks font size with wrapping. |
|
|
523
|
+
|
|
524
|
+
```typescript
|
|
525
|
+
// Shrink: reduce font size to fit in one line
|
|
526
|
+
const shrunk = GraphKnowledgeAPI.fitTextToShape("A very long title", bounds, {
|
|
527
|
+
strategy: "shrink",
|
|
528
|
+
fontSize: 24,
|
|
529
|
+
minFontSize: 8
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
// Auto: wrap first, shrink if needed
|
|
533
|
+
const auto = GraphKnowledgeAPI.fitTextToShape("Long paragraph text...", bounds, {
|
|
534
|
+
strategy: "auto"
|
|
535
|
+
});
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
### FitTextOptions
|
|
539
|
+
|
|
540
|
+
| Option | Type | Default | Description |
|
|
541
|
+
|--------|------|---------|-------------|
|
|
542
|
+
| `fontSize` | `number` | `16` | Starting font size in pixels |
|
|
543
|
+
| `fontFamily` | `string` | `"Arial, sans-serif"` | Font family |
|
|
544
|
+
| `fontWeight` | `number` | `400` | Font weight (100-900) |
|
|
545
|
+
| `fillColor` | `string` | `"#000000"` | Text fill color |
|
|
546
|
+
| `textAlign` | `"left" \| "center" \| "right"` | `"center"` | Text alignment |
|
|
547
|
+
| `lineHeight` | `number` | `1.2` | Line height multiplier |
|
|
548
|
+
| `padding` | `number` | `8` | Padding between shape edge and text |
|
|
549
|
+
| `strategy` | `"wrap" \| "shrink" \| "auto"` | `"wrap"` | Fitting strategy |
|
|
550
|
+
| `minFontSize` | `number` | `8` | Minimum font size for shrink/auto |
|
|
551
|
+
|
|
552
|
+
### Creating Documents with AI
|
|
553
|
+
|
|
554
|
+
Combine shapes and fitted text for programmatic document creation:
|
|
555
|
+
|
|
556
|
+
```typescript
|
|
557
|
+
// Create a labeled rectangle
|
|
558
|
+
const rect = await api.elements.create(docId, nodeId, {
|
|
559
|
+
type: "rectangle",
|
|
560
|
+
x: 100, y: 100, width: 200, height: 80,
|
|
561
|
+
fillColor: "#E3F2FD"
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
const label = GraphKnowledgeAPI.fitTextToShape("User Service", {
|
|
565
|
+
x: 100, y: 100, width: 200, height: 80
|
|
566
|
+
}, { fontSize: 18, strategy: "auto" });
|
|
567
|
+
|
|
568
|
+
await api.elements.create(docId, nodeId, { type: "text", ...label.textInput });
|
|
569
|
+
```
|
|
570
|
+
|
|
453
571
|
## Error Handling
|
|
454
572
|
|
|
455
573
|
The API throws typed errors for different failure cases:
|
package/package.json
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -17,5 +17,7 @@ export { LinkLevelManager } from "./lib/utils/link-level-manager";
|
|
|
17
17
|
export type { LevelChange, LinkState } from "./lib/utils/link-level-manager";
|
|
18
18
|
export type { MeasureTextOptions, TextMetrics } from "./lib/types/text-measurement-types";
|
|
19
19
|
export { TextMeasurementService } from "./lib/utils/text-measurement/text-measurement-service";
|
|
20
|
+
export { LayoutHelper } from "./lib/utils/layout/layout-helper";
|
|
21
|
+
export type { FitTextOptions, FitTextResult, FitTextStrategy, ShapeBounds } from "./lib/types/layout-types";
|
|
20
22
|
export { MockAuthClient } from "./lib/testing/mock-auth-client";
|
|
21
23
|
export { MockFirestoreClient } from "./lib/testing/mock-firestore-client";
|
package/src/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Graph Knowledge Headless Document API
|
|
4
4
|
// =============================================================================
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.MockFirestoreClient = exports.MockAuthClient = exports.TextMeasurementService = exports.LinkLevelManager = exports.CURRENT_DOCUMENT_SCHEMA_VERSION = exports.PermissionError = exports.ValidationError = exports.NotFoundError = exports.AuthenticationError = exports.GraphKnowledgeAPIError = exports.GraphKnowledgeAPI = void 0;
|
|
6
|
+
exports.MockFirestoreClient = exports.MockAuthClient = exports.LayoutHelper = exports.TextMeasurementService = exports.LinkLevelManager = exports.CURRENT_DOCUMENT_SCHEMA_VERSION = exports.PermissionError = exports.ValidationError = exports.NotFoundError = exports.AuthenticationError = exports.GraphKnowledgeAPIError = exports.GraphKnowledgeAPI = void 0;
|
|
7
7
|
// Main API Class
|
|
8
8
|
var graph_knowledge_api_1 = require("./lib/graph-knowledge-api");
|
|
9
9
|
Object.defineProperty(exports, "GraphKnowledgeAPI", { enumerable: true, get: function () { return graph_knowledge_api_1.GraphKnowledgeAPI; } });
|
|
@@ -25,6 +25,9 @@ var link_level_manager_1 = require("./lib/utils/link-level-manager");
|
|
|
25
25
|
Object.defineProperty(exports, "LinkLevelManager", { enumerable: true, get: function () { return link_level_manager_1.LinkLevelManager; } });
|
|
26
26
|
var text_measurement_service_1 = require("./lib/utils/text-measurement/text-measurement-service");
|
|
27
27
|
Object.defineProperty(exports, "TextMeasurementService", { enumerable: true, get: function () { return text_measurement_service_1.TextMeasurementService; } });
|
|
28
|
+
// Layout
|
|
29
|
+
var layout_helper_1 = require("./lib/utils/layout/layout-helper");
|
|
30
|
+
Object.defineProperty(exports, "LayoutHelper", { enumerable: true, get: function () { return layout_helper_1.LayoutHelper; } });
|
|
28
31
|
// =============================================================================
|
|
29
32
|
// Testing Utilities
|
|
30
33
|
// =============================================================================
|
|
@@ -6,6 +6,7 @@ import { IElementOperations } from "./interfaces/element-operations.interface";
|
|
|
6
6
|
import { IBatchOperations } from "./interfaces/batch-operations.interface";
|
|
7
7
|
import { ITemplateOperations } from "./interfaces/template-operations.interface";
|
|
8
8
|
import { MeasureTextOptions, TextMetrics } from "./types/text-measurement-types";
|
|
9
|
+
import { FitTextOptions, FitTextResult, ShapeBounds } from "./types/layout-types";
|
|
9
10
|
/**
|
|
10
11
|
* Main entry point for the Graph Knowledge Headless API.
|
|
11
12
|
*
|
|
@@ -55,6 +56,10 @@ export declare class GraphKnowledgeAPI {
|
|
|
55
56
|
* Shared text measurement service for static and instance methods.
|
|
56
57
|
*/
|
|
57
58
|
private static _textMeasurementService;
|
|
59
|
+
/**
|
|
60
|
+
* Shared layout helper for static and instance methods.
|
|
61
|
+
*/
|
|
62
|
+
private static _layoutHelper;
|
|
58
63
|
/**
|
|
59
64
|
* Creates a new GraphKnowledgeAPI instance.
|
|
60
65
|
* @param config Configuration including Firebase credentials
|
|
@@ -146,4 +151,32 @@ export declare class GraphKnowledgeAPI {
|
|
|
146
151
|
* ```
|
|
147
152
|
*/
|
|
148
153
|
measureText(text: string, options?: MeasureTextOptions): TextMetrics;
|
|
154
|
+
/**
|
|
155
|
+
* Fits text into a shape's bounding box, returning a ready-to-use TextInput.
|
|
156
|
+
* No authentication required.
|
|
157
|
+
*
|
|
158
|
+
* @param text The text to fit
|
|
159
|
+
* @param bounds The shape's bounding box (x, y, width, height)
|
|
160
|
+
* @param options Fitting options (strategy, font, padding, etc.)
|
|
161
|
+
* @returns FitTextResult with textInput ready for element creation
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* ```typescript
|
|
165
|
+
* const result = GraphKnowledgeAPI.fitTextToShape("Hello World", {
|
|
166
|
+
* x: 100, y: 100, width: 200, height: 100
|
|
167
|
+
* });
|
|
168
|
+
* await api.elements.create(docId, nodeId, { type: "text", ...result.textInput });
|
|
169
|
+
* ```
|
|
170
|
+
*/
|
|
171
|
+
static fitTextToShape(text: string, bounds: ShapeBounds, options?: FitTextOptions): FitTextResult;
|
|
172
|
+
/**
|
|
173
|
+
* Fits text into a shape's bounding box, returning a ready-to-use TextInput.
|
|
174
|
+
* Instance method that delegates to the static method.
|
|
175
|
+
*
|
|
176
|
+
* @param text The text to fit
|
|
177
|
+
* @param bounds The shape's bounding box (x, y, width, height)
|
|
178
|
+
* @param options Fitting options (strategy, font, padding, etc.)
|
|
179
|
+
* @returns FitTextResult with textInput ready for element creation
|
|
180
|
+
*/
|
|
181
|
+
fitTextToShape(text: string, bounds: ShapeBounds, options?: FitTextOptions): FitTextResult;
|
|
149
182
|
}
|
|
@@ -16,6 +16,7 @@ const node_validator_1 = require("./validators/node-validator");
|
|
|
16
16
|
const element_validator_registry_1 = require("./validators/element-validator-registry");
|
|
17
17
|
const template_validator_1 = require("./validators/template-validator");
|
|
18
18
|
const text_measurement_service_1 = require("./utils/text-measurement/text-measurement-service");
|
|
19
|
+
const layout_helper_1 = require("./utils/layout/layout-helper");
|
|
19
20
|
/**
|
|
20
21
|
* Main entry point for the Graph Knowledge Headless API.
|
|
21
22
|
*
|
|
@@ -65,6 +66,10 @@ class GraphKnowledgeAPI {
|
|
|
65
66
|
* Shared text measurement service for static and instance methods.
|
|
66
67
|
*/
|
|
67
68
|
static _textMeasurementService = null;
|
|
69
|
+
/**
|
|
70
|
+
* Shared layout helper for static and instance methods.
|
|
71
|
+
*/
|
|
72
|
+
static _layoutHelper = null;
|
|
68
73
|
/**
|
|
69
74
|
* Creates a new GraphKnowledgeAPI instance.
|
|
70
75
|
* @param config Configuration including Firebase credentials
|
|
@@ -211,5 +216,46 @@ class GraphKnowledgeAPI {
|
|
|
211
216
|
measureText(text, options = {}) {
|
|
212
217
|
return GraphKnowledgeAPI.measureText(text, options);
|
|
213
218
|
}
|
|
219
|
+
// =========================================================================
|
|
220
|
+
// Layout Helpers (static and instance methods)
|
|
221
|
+
// =========================================================================
|
|
222
|
+
/**
|
|
223
|
+
* Fits text into a shape's bounding box, returning a ready-to-use TextInput.
|
|
224
|
+
* No authentication required.
|
|
225
|
+
*
|
|
226
|
+
* @param text The text to fit
|
|
227
|
+
* @param bounds The shape's bounding box (x, y, width, height)
|
|
228
|
+
* @param options Fitting options (strategy, font, padding, etc.)
|
|
229
|
+
* @returns FitTextResult with textInput ready for element creation
|
|
230
|
+
*
|
|
231
|
+
* @example
|
|
232
|
+
* ```typescript
|
|
233
|
+
* const result = GraphKnowledgeAPI.fitTextToShape("Hello World", {
|
|
234
|
+
* x: 100, y: 100, width: 200, height: 100
|
|
235
|
+
* });
|
|
236
|
+
* await api.elements.create(docId, nodeId, { type: "text", ...result.textInput });
|
|
237
|
+
* ```
|
|
238
|
+
*/
|
|
239
|
+
static fitTextToShape(text, bounds, options) {
|
|
240
|
+
if (!GraphKnowledgeAPI._layoutHelper) {
|
|
241
|
+
if (!GraphKnowledgeAPI._textMeasurementService) {
|
|
242
|
+
GraphKnowledgeAPI._textMeasurementService = new text_measurement_service_1.TextMeasurementService();
|
|
243
|
+
}
|
|
244
|
+
GraphKnowledgeAPI._layoutHelper = new layout_helper_1.LayoutHelper(GraphKnowledgeAPI._textMeasurementService.provider);
|
|
245
|
+
}
|
|
246
|
+
return GraphKnowledgeAPI._layoutHelper.fitTextToShape(text, bounds, options);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Fits text into a shape's bounding box, returning a ready-to-use TextInput.
|
|
250
|
+
* Instance method that delegates to the static method.
|
|
251
|
+
*
|
|
252
|
+
* @param text The text to fit
|
|
253
|
+
* @param bounds The shape's bounding box (x, y, width, height)
|
|
254
|
+
* @param options Fitting options (strategy, font, padding, etc.)
|
|
255
|
+
* @returns FitTextResult with textInput ready for element creation
|
|
256
|
+
*/
|
|
257
|
+
fitTextToShape(text, bounds, options) {
|
|
258
|
+
return GraphKnowledgeAPI.fitTextToShape(text, bounds, options);
|
|
259
|
+
}
|
|
214
260
|
}
|
|
215
261
|
exports.GraphKnowledgeAPI = GraphKnowledgeAPI;
|
|
@@ -25,6 +25,12 @@ export declare class ElementOperations implements IElementOperations {
|
|
|
25
25
|
* Normalizes rotation to integer in range [0, 360).
|
|
26
26
|
*/
|
|
27
27
|
private normalizeRotation;
|
|
28
|
+
/**
|
|
29
|
+
* Auto-measures text width for text elements created without explicit width/maxWidth.
|
|
30
|
+
* Uses a lazily-initialized TextMeasurementService singleton.
|
|
31
|
+
*/
|
|
32
|
+
private measureTextWidth;
|
|
33
|
+
private static _textMeasurementService;
|
|
28
34
|
/**
|
|
29
35
|
* Builds properties object from element input.
|
|
30
36
|
* Extracts type-specific properties, excluding common fields.
|
|
@@ -4,6 +4,7 @@ exports.ElementOperations = void 0;
|
|
|
4
4
|
const uuid_1 = require("../utils/uuid");
|
|
5
5
|
const api_errors_1 = require("../errors/api-errors");
|
|
6
6
|
const element_defaults_1 = require("../constants/element-defaults");
|
|
7
|
+
const text_measurement_service_1 = require("../utils/text-measurement/text-measurement-service");
|
|
7
8
|
/**
|
|
8
9
|
* Implementation of element CRUD operations.
|
|
9
10
|
*/
|
|
@@ -54,12 +55,25 @@ class ElementOperations {
|
|
|
54
55
|
// Connectors have optional x/y since their position is derived from connected elements
|
|
55
56
|
const x = input.x ?? 0;
|
|
56
57
|
const y = input.y ?? 0;
|
|
58
|
+
// For text elements, maxWidth is a convenience alias for width (the wrap boundary)
|
|
59
|
+
let resolvedWidth = input.width;
|
|
60
|
+
if (input.type === "text" && resolvedWidth === undefined) {
|
|
61
|
+
const textInput = input;
|
|
62
|
+
if (textInput.maxWidth !== undefined) {
|
|
63
|
+
resolvedWidth = textInput.maxWidth;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
// Auto-measure text width to avoid the 100px default which causes
|
|
67
|
+
// unexpected wrapping in the editor.
|
|
68
|
+
resolvedWidth = this.measureTextWidth(textInput);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
57
71
|
const element = {
|
|
58
72
|
id: (0, uuid_1.generateUuid)(),
|
|
59
73
|
elementType: input.type,
|
|
60
74
|
x,
|
|
61
75
|
y,
|
|
62
|
-
width:
|
|
76
|
+
width: resolvedWidth ?? defaults.width,
|
|
63
77
|
height: input.height ?? defaults.height,
|
|
64
78
|
rotation: this.normalizeRotation(input.rotation ?? 0),
|
|
65
79
|
properties: this.buildProperties(input)
|
|
@@ -149,6 +163,24 @@ class ElementOperations {
|
|
|
149
163
|
}
|
|
150
164
|
return Math.round(normalized);
|
|
151
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Auto-measures text width for text elements created without explicit width/maxWidth.
|
|
168
|
+
* Uses a lazily-initialized TextMeasurementService singleton.
|
|
169
|
+
*/
|
|
170
|
+
measureTextWidth(input) {
|
|
171
|
+
if (!ElementOperations._textMeasurementService) {
|
|
172
|
+
ElementOperations._textMeasurementService = new text_measurement_service_1.TextMeasurementService();
|
|
173
|
+
}
|
|
174
|
+
const metrics = ElementOperations._textMeasurementService.measureText(input.text, {
|
|
175
|
+
fontSize: input.fontSize,
|
|
176
|
+
fontFamily: input.fontFamily,
|
|
177
|
+
fontWeight: input.fontWeight,
|
|
178
|
+
textAlign: input.textAlign,
|
|
179
|
+
lineHeight: input.lineHeight
|
|
180
|
+
});
|
|
181
|
+
return Math.ceil(metrics.width);
|
|
182
|
+
}
|
|
183
|
+
static _textMeasurementService = null;
|
|
152
184
|
/**
|
|
153
185
|
* Builds properties object from element input.
|
|
154
186
|
* Extracts type-specific properties, excluding common fields.
|
|
@@ -157,7 +189,7 @@ class ElementOperations {
|
|
|
157
189
|
const props = {
|
|
158
190
|
__schemaVersion: 1
|
|
159
191
|
};
|
|
160
|
-
// Exclude common fields, copy the rest as properties
|
|
192
|
+
// Exclude common fields and convenience aliases, copy the rest as properties
|
|
161
193
|
const commonFields = new Set([
|
|
162
194
|
"type",
|
|
163
195
|
"x",
|
|
@@ -166,27 +198,22 @@ class ElementOperations {
|
|
|
166
198
|
"height",
|
|
167
199
|
"rotation",
|
|
168
200
|
"isLink",
|
|
169
|
-
"linkTarget"
|
|
201
|
+
"linkTarget",
|
|
202
|
+
"maxWidth"
|
|
170
203
|
]);
|
|
171
204
|
for (const [key, value] of Object.entries(input)) {
|
|
172
205
|
if (!commonFields.has(key) && value !== undefined) {
|
|
173
206
|
props[key] = value;
|
|
174
207
|
}
|
|
175
208
|
}
|
|
176
|
-
// For text elements
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
// However, when width is omitted, a default width is applied elsewhere. In that
|
|
183
|
-
// case we DO NOT set __usesTopLeft, so the text plugin can treat the element as
|
|
184
|
-
// auto-sized text instead of preserving the default container width.
|
|
209
|
+
// For text elements, set __usesTopLeft: true so the app's normalization
|
|
210
|
+
// knows that x/y is the bounding box top-left (not the text anchor point).
|
|
211
|
+
// Without this, the app would incorrectly adjust x/y based on textAlign,
|
|
212
|
+
// causing centered text to be positioned in negative coordinates.
|
|
213
|
+
// Since we now always resolve a proper width for text (via explicit width,
|
|
214
|
+
// maxWidth, or auto-measurement), we always set this flag.
|
|
185
215
|
if (input.type === "text") {
|
|
186
|
-
|
|
187
|
-
if (hasExplicitWidth) {
|
|
188
|
-
props["__usesTopLeft"] = true;
|
|
189
|
-
}
|
|
216
|
+
props["__usesTopLeft"] = true;
|
|
190
217
|
}
|
|
191
218
|
return props;
|
|
192
219
|
}
|
|
@@ -229,19 +229,13 @@ class TemplateOperations {
|
|
|
229
229
|
const clonedProperties = { ...element.properties };
|
|
230
230
|
// Update connector endpoint references if this is a connector
|
|
231
231
|
if (element.elementType === "connector" && clonedProperties) {
|
|
232
|
-
if (clonedProperties["
|
|
233
|
-
idMapping.has(clonedProperties["
|
|
234
|
-
clonedProperties["
|
|
235
|
-
...clonedProperties["startAnchor"],
|
|
236
|
-
elementId: idMapping.get(clonedProperties["startAnchor"].elementId)
|
|
237
|
-
};
|
|
232
|
+
if (clonedProperties["startElementId"] &&
|
|
233
|
+
idMapping.has(clonedProperties["startElementId"])) {
|
|
234
|
+
clonedProperties["startElementId"] = idMapping.get(clonedProperties["startElementId"]);
|
|
238
235
|
}
|
|
239
|
-
if (clonedProperties["
|
|
240
|
-
idMapping.has(clonedProperties["
|
|
241
|
-
clonedProperties["
|
|
242
|
-
...clonedProperties["endAnchor"],
|
|
243
|
-
elementId: idMapping.get(clonedProperties["endAnchor"].elementId)
|
|
244
|
-
};
|
|
236
|
+
if (clonedProperties["endElementId"] &&
|
|
237
|
+
idMapping.has(clonedProperties["endElementId"])) {
|
|
238
|
+
clonedProperties["endElementId"] = idMapping.get(clonedProperties["endElementId"]);
|
|
245
239
|
}
|
|
246
240
|
}
|
|
247
241
|
const clonedElement = {
|
|
@@ -50,6 +50,8 @@ export interface TextInput extends BaseElementInput {
|
|
|
50
50
|
textAlign?: "left" | "center" | "right";
|
|
51
51
|
/** Line height multiplier */
|
|
52
52
|
lineHeight?: number;
|
|
53
|
+
/** Maximum width for text wrapping. Text will auto-wrap at word boundaries within this width. */
|
|
54
|
+
maxWidth?: number;
|
|
53
55
|
}
|
|
54
56
|
/**
|
|
55
57
|
* Anchor points for connectors.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strategy for fitting text within a shape.
|
|
3
|
+
*
|
|
4
|
+
* - `wrap`: Wrap text at word boundaries within the shape width (default)
|
|
5
|
+
* - `shrink`: Reduce font size until text fits in a single line
|
|
6
|
+
* - `auto`: Try wrapping first; if height overflows, shrink font size with wrapping
|
|
7
|
+
*/
|
|
8
|
+
export type FitTextStrategy = "wrap" | "shrink" | "auto";
|
|
9
|
+
/**
|
|
10
|
+
* Bounding box of a shape to fit text into.
|
|
11
|
+
*/
|
|
12
|
+
export interface ShapeBounds {
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Options for fitting text into a shape.
|
|
20
|
+
* All properties are optional and have sensible defaults.
|
|
21
|
+
*/
|
|
22
|
+
export interface FitTextOptions {
|
|
23
|
+
/** Font family. @default "Arial, sans-serif" */
|
|
24
|
+
fontFamily?: string;
|
|
25
|
+
/** Font size in pixels. @default 16 */
|
|
26
|
+
fontSize?: number;
|
|
27
|
+
/** Font weight (100-900). @default 400 */
|
|
28
|
+
fontWeight?: number;
|
|
29
|
+
/** Fill color for the text. @default "#000000" */
|
|
30
|
+
fillColor?: string;
|
|
31
|
+
/** Text alignment within the shape. @default "center" */
|
|
32
|
+
textAlign?: "left" | "center" | "right";
|
|
33
|
+
/** Line height multiplier. @default 1.2 */
|
|
34
|
+
lineHeight?: number;
|
|
35
|
+
/** Padding in pixels between shape edge and text. @default 8 */
|
|
36
|
+
padding?: number;
|
|
37
|
+
/** Strategy for fitting text. @default "wrap" */
|
|
38
|
+
strategy?: FitTextStrategy;
|
|
39
|
+
/** Minimum font size for shrink/auto strategies. @default 8 */
|
|
40
|
+
minFontSize?: number;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Result of fitting text into a shape.
|
|
44
|
+
*/
|
|
45
|
+
export interface FitTextResult {
|
|
46
|
+
/**
|
|
47
|
+
* Ready to spread into api.elements.create(docId, nodeId, { type: "text", ...textInput }).
|
|
48
|
+
*/
|
|
49
|
+
textInput: {
|
|
50
|
+
x: number;
|
|
51
|
+
y: number;
|
|
52
|
+
text: string;
|
|
53
|
+
maxWidth?: number;
|
|
54
|
+
fontSize: number;
|
|
55
|
+
fontFamily: string;
|
|
56
|
+
fontWeight: number;
|
|
57
|
+
fillColor: string;
|
|
58
|
+
textAlign: "left" | "center" | "right";
|
|
59
|
+
lineHeight: number;
|
|
60
|
+
};
|
|
61
|
+
/** Final font size used (may differ from input if shrunk). */
|
|
62
|
+
fontSize: number;
|
|
63
|
+
/** Number of lines after fitting. */
|
|
64
|
+
lines: number;
|
|
65
|
+
/** Width of the measured text. */
|
|
66
|
+
textWidth: number;
|
|
67
|
+
/** Height of the measured text. */
|
|
68
|
+
textHeight: number;
|
|
69
|
+
/** False if text overflows the shape even after applying the strategy. */
|
|
70
|
+
fits: boolean;
|
|
71
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ITextMeasurementProvider } from "../../interfaces/text-measurement.interface";
|
|
2
|
+
import { FitTextOptions, FitTextResult, ShapeBounds } from "../../types/layout-types";
|
|
3
|
+
/**
|
|
4
|
+
* Pure layout utility for fitting text into shapes.
|
|
5
|
+
* No Firestore/Auth dependencies — only requires a text measurement provider.
|
|
6
|
+
*/
|
|
7
|
+
export declare class LayoutHelper {
|
|
8
|
+
private readonly _measurementProvider;
|
|
9
|
+
constructor(_measurementProvider: ITextMeasurementProvider);
|
|
10
|
+
/**
|
|
11
|
+
* Fits text into a shape's bounding box, returning a ready-to-use TextInput.
|
|
12
|
+
*
|
|
13
|
+
* @param text The text to fit
|
|
14
|
+
* @param bounds The shape's bounding box
|
|
15
|
+
* @param options Fitting options (strategy, font, padding, etc.)
|
|
16
|
+
* @returns FitTextResult with textInput ready for element creation
|
|
17
|
+
*/
|
|
18
|
+
fitTextToShape(text: string, bounds: ShapeBounds, options?: FitTextOptions): FitTextResult;
|
|
19
|
+
/**
|
|
20
|
+
* Wrap strategy: set maxWidth and measure. Report fits: false if height overflows.
|
|
21
|
+
*/
|
|
22
|
+
private _fitWrap;
|
|
23
|
+
/**
|
|
24
|
+
* Shrink strategy: binary search font size until single-line text fits width.
|
|
25
|
+
*/
|
|
26
|
+
private _fitShrink;
|
|
27
|
+
/**
|
|
28
|
+
* Auto strategy: try wrap first. If height overflows, binary search font size with wrapping.
|
|
29
|
+
*/
|
|
30
|
+
private _fitAuto;
|
|
31
|
+
/** Measure without wrapping. */
|
|
32
|
+
private _measure;
|
|
33
|
+
/** Measure with wrapping at maxWidth. */
|
|
34
|
+
private _measureWrapped;
|
|
35
|
+
/** Build the final FitTextResult with centered positioning. */
|
|
36
|
+
private _buildResult;
|
|
37
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LayoutHelper = void 0;
|
|
4
|
+
const FIT_TEXT_DEFAULTS = {
|
|
5
|
+
fontFamily: "Arial, sans-serif",
|
|
6
|
+
fontSize: 16,
|
|
7
|
+
fontWeight: 400,
|
|
8
|
+
fillColor: "#000000",
|
|
9
|
+
textAlign: "center",
|
|
10
|
+
lineHeight: 1.2,
|
|
11
|
+
padding: 8,
|
|
12
|
+
strategy: "wrap",
|
|
13
|
+
minFontSize: 8
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Pure layout utility for fitting text into shapes.
|
|
17
|
+
* No Firestore/Auth dependencies — only requires a text measurement provider.
|
|
18
|
+
*/
|
|
19
|
+
class LayoutHelper {
|
|
20
|
+
_measurementProvider;
|
|
21
|
+
constructor(_measurementProvider) {
|
|
22
|
+
this._measurementProvider = _measurementProvider;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Fits text into a shape's bounding box, returning a ready-to-use TextInput.
|
|
26
|
+
*
|
|
27
|
+
* @param text The text to fit
|
|
28
|
+
* @param bounds The shape's bounding box
|
|
29
|
+
* @param options Fitting options (strategy, font, padding, etc.)
|
|
30
|
+
* @returns FitTextResult with textInput ready for element creation
|
|
31
|
+
*/
|
|
32
|
+
fitTextToShape(text, bounds, options) {
|
|
33
|
+
const opts = { ...FIT_TEXT_DEFAULTS, ...options };
|
|
34
|
+
const contentWidth = Math.max(0, bounds.width - 2 * opts.padding);
|
|
35
|
+
const contentHeight = Math.max(0, bounds.height - 2 * opts.padding);
|
|
36
|
+
if (!text) {
|
|
37
|
+
return this._buildResult(text, bounds, opts, 0, 0, 0, opts.fontSize, true);
|
|
38
|
+
}
|
|
39
|
+
switch (opts.strategy) {
|
|
40
|
+
case "shrink":
|
|
41
|
+
return this._fitShrink(text, bounds, opts, contentWidth, contentHeight);
|
|
42
|
+
case "auto":
|
|
43
|
+
return this._fitAuto(text, bounds, opts, contentWidth, contentHeight);
|
|
44
|
+
case "wrap":
|
|
45
|
+
default:
|
|
46
|
+
return this._fitWrap(text, bounds, opts, contentWidth, contentHeight);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Wrap strategy: set maxWidth and measure. Report fits: false if height overflows.
|
|
51
|
+
*/
|
|
52
|
+
_fitWrap(text, bounds, opts, contentWidth, contentHeight) {
|
|
53
|
+
const metrics = this._measurementProvider.measureText(text, {
|
|
54
|
+
fontSize: opts.fontSize,
|
|
55
|
+
fontFamily: opts.fontFamily,
|
|
56
|
+
fontWeight: opts.fontWeight,
|
|
57
|
+
textAlign: opts.textAlign,
|
|
58
|
+
lineHeight: opts.lineHeight,
|
|
59
|
+
maxWidth: contentWidth
|
|
60
|
+
});
|
|
61
|
+
const fits = metrics.height <= contentHeight;
|
|
62
|
+
return this._buildResult(text, bounds, opts, metrics.width, metrics.height, metrics.lines, opts.fontSize, fits);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Shrink strategy: binary search font size until single-line text fits width.
|
|
66
|
+
*/
|
|
67
|
+
_fitShrink(text, bounds, opts, contentWidth, contentHeight) {
|
|
68
|
+
let lo = opts.minFontSize;
|
|
69
|
+
let hi = opts.fontSize;
|
|
70
|
+
let bestSize = lo;
|
|
71
|
+
// If text fits at full size, no need to shrink
|
|
72
|
+
const fullMetrics = this._measure(text, hi, opts);
|
|
73
|
+
if (fullMetrics.width <= contentWidth && fullMetrics.height <= contentHeight) {
|
|
74
|
+
return this._buildResult(text, bounds, opts, fullMetrics.width, fullMetrics.height, fullMetrics.lines, hi, true);
|
|
75
|
+
}
|
|
76
|
+
// Binary search for largest font size that fits
|
|
77
|
+
while (hi - lo > 0.5) {
|
|
78
|
+
const mid = (lo + hi) / 2;
|
|
79
|
+
const metrics = this._measure(text, mid, opts);
|
|
80
|
+
if (metrics.width <= contentWidth && metrics.height <= contentHeight) {
|
|
81
|
+
bestSize = mid;
|
|
82
|
+
lo = mid;
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
hi = mid;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const finalSize = Math.floor(bestSize);
|
|
89
|
+
const finalMetrics = this._measure(text, finalSize, opts);
|
|
90
|
+
const fits = finalMetrics.width <= contentWidth && finalMetrics.height <= contentHeight;
|
|
91
|
+
return this._buildResult(text, bounds, opts, finalMetrics.width, finalMetrics.height, finalMetrics.lines, finalSize, fits);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Auto strategy: try wrap first. If height overflows, binary search font size with wrapping.
|
|
95
|
+
*/
|
|
96
|
+
_fitAuto(text, bounds, opts, contentWidth, contentHeight) {
|
|
97
|
+
// Try wrapping at current font size
|
|
98
|
+
const wrapMetrics = this._measureWrapped(text, opts.fontSize, opts, contentWidth);
|
|
99
|
+
if (wrapMetrics.height <= contentHeight) {
|
|
100
|
+
return this._buildResult(text, bounds, opts, wrapMetrics.width, wrapMetrics.height, wrapMetrics.lines, opts.fontSize, true);
|
|
101
|
+
}
|
|
102
|
+
// Binary search font size with wrapping
|
|
103
|
+
let lo = opts.minFontSize;
|
|
104
|
+
let hi = opts.fontSize;
|
|
105
|
+
let bestSize = lo;
|
|
106
|
+
while (hi - lo > 0.5) {
|
|
107
|
+
const mid = (lo + hi) / 2;
|
|
108
|
+
const metrics = this._measureWrapped(text, mid, opts, contentWidth);
|
|
109
|
+
if (metrics.height <= contentHeight) {
|
|
110
|
+
bestSize = mid;
|
|
111
|
+
lo = mid;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
hi = mid;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const finalSize = Math.floor(bestSize);
|
|
118
|
+
const finalMetrics = this._measureWrapped(text, finalSize, opts, contentWidth);
|
|
119
|
+
const fits = finalMetrics.height <= contentHeight;
|
|
120
|
+
return this._buildResult(text, bounds, opts, finalMetrics.width, finalMetrics.height, finalMetrics.lines, finalSize, fits);
|
|
121
|
+
}
|
|
122
|
+
/** Measure without wrapping. */
|
|
123
|
+
_measure(text, fontSize, opts) {
|
|
124
|
+
return this._measurementProvider.measureText(text, {
|
|
125
|
+
fontSize,
|
|
126
|
+
fontFamily: opts.fontFamily,
|
|
127
|
+
fontWeight: opts.fontWeight,
|
|
128
|
+
textAlign: opts.textAlign,
|
|
129
|
+
lineHeight: opts.lineHeight
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/** Measure with wrapping at maxWidth. */
|
|
133
|
+
_measureWrapped(text, fontSize, opts, maxWidth) {
|
|
134
|
+
return this._measurementProvider.measureText(text, {
|
|
135
|
+
fontSize,
|
|
136
|
+
fontFamily: opts.fontFamily,
|
|
137
|
+
fontWeight: opts.fontWeight,
|
|
138
|
+
textAlign: opts.textAlign,
|
|
139
|
+
lineHeight: opts.lineHeight,
|
|
140
|
+
maxWidth
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/** Build the final FitTextResult with centered positioning. */
|
|
144
|
+
_buildResult(text, bounds, opts, textWidth, textHeight, lines, fontSize, fits) {
|
|
145
|
+
const contentWidth = Math.max(0, bounds.width - 2 * opts.padding);
|
|
146
|
+
const contentHeight = Math.max(0, bounds.height - 2 * opts.padding);
|
|
147
|
+
const x = bounds.x + opts.padding;
|
|
148
|
+
const y = bounds.y + opts.padding + (contentHeight - textHeight) / 2;
|
|
149
|
+
const textInput = {
|
|
150
|
+
x,
|
|
151
|
+
y,
|
|
152
|
+
text,
|
|
153
|
+
fontSize,
|
|
154
|
+
fontFamily: opts.fontFamily,
|
|
155
|
+
fontWeight: opts.fontWeight,
|
|
156
|
+
fillColor: opts.fillColor,
|
|
157
|
+
textAlign: opts.textAlign,
|
|
158
|
+
lineHeight: opts.lineHeight
|
|
159
|
+
};
|
|
160
|
+
if (contentWidth > 0) {
|
|
161
|
+
textInput.maxWidth = contentWidth;
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
textInput,
|
|
165
|
+
fontSize,
|
|
166
|
+
lines,
|
|
167
|
+
textWidth,
|
|
168
|
+
textHeight,
|
|
169
|
+
fits
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
exports.LayoutHelper = LayoutHelper;
|
|
@@ -28,6 +28,7 @@ class TextValidator extends base_element_validator_1.BaseElementValidator {
|
|
|
28
28
|
this.validateFontWeight(textInput.fontWeight);
|
|
29
29
|
this.validateTextAlign(textInput.textAlign);
|
|
30
30
|
this.validateLineHeight(textInput.lineHeight);
|
|
31
|
+
this.validateMaxWidth(textInput.maxWidth);
|
|
31
32
|
}
|
|
32
33
|
validateFontSize(value) {
|
|
33
34
|
if (value === undefined)
|
|
@@ -66,5 +67,15 @@ class TextValidator extends base_element_validator_1.BaseElementValidator {
|
|
|
66
67
|
throw new api_errors_1.ValidationError("lineHeight must be positive", "lineHeight");
|
|
67
68
|
}
|
|
68
69
|
}
|
|
70
|
+
validateMaxWidth(value) {
|
|
71
|
+
if (value === undefined)
|
|
72
|
+
return;
|
|
73
|
+
if (typeof value !== "number" || isNaN(value)) {
|
|
74
|
+
throw new api_errors_1.ValidationError("maxWidth must be a valid number", "maxWidth");
|
|
75
|
+
}
|
|
76
|
+
if (value <= 0) {
|
|
77
|
+
throw new api_errors_1.ValidationError("maxWidth must be positive", "maxWidth");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
69
80
|
}
|
|
70
81
|
exports.TextValidator = TextValidator;
|