@canva/cli 1.10.0 → 1.11.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/CHANGELOG.md +13 -0
- package/cli.js +472 -463
- package/lib/cjs/index.cjs +2 -2
- package/lib/esm/index.mjs +2 -2
- package/lib/index.d.ts +2 -0
- package/package.json +1 -1
- package/templates/base/package.json +3 -2
- package/templates/base/styles/components.css +18 -0
- package/templates/common/.env.template +1 -1
- package/templates/dam/canva-app.json +5 -0
- package/templates/dam/package.json +4 -2
- package/templates/dam/src/index.tsx +3 -21
- package/templates/dam/src/intents/design_editor/index.tsx +25 -0
- package/templates/data_connector/package.json +3 -2
- package/templates/data_connector/src/api/data_sources/designs.tsx +1 -1
- package/templates/data_connector/src/api/data_sources/templates.tsx +1 -1
- package/templates/data_connector/src/components/header.tsx +1 -1
- package/templates/data_connector/src/index.tsx +2 -66
- package/templates/data_connector/src/{app.tsx → intents/data_connector/app.tsx} +3 -3
- package/templates/data_connector/src/{entrypoint.tsx → intents/data_connector/entrypoint.tsx} +5 -5
- package/templates/data_connector/src/{home.tsx → intents/data_connector/home.tsx} +1 -1
- package/templates/data_connector/src/intents/data_connector/index.tsx +56 -0
- package/templates/data_connector/src/pages/error.tsx +1 -1
- package/templates/data_connector/src/pages/login.tsx +1 -1
- package/templates/data_connector/src/routes/protected_route.tsx +1 -1
- package/templates/data_connector/src/routes/routes.tsx +3 -3
- package/templates/data_connector/styles/components.css +18 -0
- package/templates/gen_ai/canva-app.json +5 -0
- package/templates/gen_ai/package.json +4 -2
- package/templates/gen_ai/src/components/footer.tsx +1 -1
- package/templates/gen_ai/src/components/loading_results.tsx +1 -1
- package/templates/gen_ai/src/components/prompt_input.tsx +1 -1
- package/templates/gen_ai/src/index.tsx +3 -14
- package/templates/gen_ai/src/{app.tsx → intents/design_editor/app.tsx} +3 -3
- package/templates/gen_ai/src/{home.tsx → intents/design_editor/home.tsx} +1 -1
- package/templates/gen_ai/src/intents/design_editor/index.tsx +17 -0
- package/templates/gen_ai/src/pages/error.tsx +1 -1
- package/templates/gen_ai/src/routes/routes.tsx +2 -2
- package/templates/gen_ai/styles/components.css +18 -0
- package/templates/hello_world/canva-app.json +5 -0
- package/templates/hello_world/package.json +4 -2
- package/templates/hello_world/src/index.tsx +3 -21
- package/templates/hello_world/src/{app.tsx → intents/design_editor/app.tsx} +26 -3
- package/templates/hello_world/src/intents/design_editor/index.tsx +25 -0
- package/templates/hello_world/src/{tests → intents/design_editor/tests}/app.tests.tsx +19 -13
- package/templates/hello_world/styles/components.css +18 -0
- package/templates/optional/AGENTS.md +80 -2
- package/templates/optional/CLAUDE.md +80 -2
- package/templates/base/utils/use_add_element.ts +0 -58
- package/templates/base/utils/use_feature_support.ts +0 -28
- package/templates/common/utils/table_wrapper.ts +0 -520
- package/templates/common/utils/use_add_element.ts +0 -58
- package/templates/common/utils/use_feature_support.ts +0 -28
- package/templates/common/utils/use_overlay_hook.ts +0 -76
- package/templates/common/utils/use_selection_hook.ts +0 -37
- package/templates/hello_world/utils/use_add_element.ts +0 -58
- package/templates/hello_world/utils/use_feature_support.ts +0 -28
- /package/templates/dam/src/{adapter.ts → intents/design_editor/adapter.ts} +0 -0
- /package/templates/dam/src/{app.tsx → intents/design_editor/app.tsx} +0 -0
- /package/templates/dam/src/{config.ts → intents/design_editor/config.ts} +0 -0
- /package/templates/dam/src/{index.css → intents/design_editor/index.css} +0 -0
- /package/templates/data_connector/src/{paths.ts → routes/paths.ts} +0 -0
- /package/templates/gen_ai/src/{paths.ts → routes/paths.ts} +0 -0
- /package/templates/hello_world/src/{tests → intents/design_editor/tests}/__snapshots__/app.tests.tsx.snap +0 -0
|
@@ -1,520 +0,0 @@
|
|
|
1
|
-
import type { Cell, TableElement } from "@canva/design";
|
|
2
|
-
|
|
3
|
-
const MAX_CELL_COUNT = 225;
|
|
4
|
-
|
|
5
|
-
// Additional information in the wrapper that are not available in the table cell element.
|
|
6
|
-
// Currently, only merged cells, but it can later extend to other custom properties, like border, size,...
|
|
7
|
-
type MetaCell = {
|
|
8
|
-
// If a cell is merged into another cell, then this field should tell that other cell.
|
|
9
|
-
mergedInto?: { row: number; column: number };
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
export class TableWrapper {
|
|
13
|
-
// A shadow of the cell array, that highlight the relationship between merged cells.
|
|
14
|
-
private readonly metaCells: MetaCell[][];
|
|
15
|
-
|
|
16
|
-
private constructor(
|
|
17
|
-
private readonly rows: {
|
|
18
|
-
cells: (Cell | null | undefined)[];
|
|
19
|
-
}[],
|
|
20
|
-
) {
|
|
21
|
-
this.validateRowColumn();
|
|
22
|
-
this.metaCells = [];
|
|
23
|
-
for (const row of this.rows) {
|
|
24
|
-
this.metaCells.push(Array.from({ length: row.cells.length }, () => ({})));
|
|
25
|
-
}
|
|
26
|
-
this.syncMergedCellsFromRows();
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Creates an empty table wrapper.
|
|
31
|
-
* @param rowCount - The number of rows to create the table with.
|
|
32
|
-
* @param columnCount - The number of columns to create the table with.
|
|
33
|
-
*/
|
|
34
|
-
static create(rowCount: number, columnCount: number) {
|
|
35
|
-
const rows = Array.from({ length: rowCount }, () => ({
|
|
36
|
-
cells: Array.from({ length: columnCount }, () => null),
|
|
37
|
-
}));
|
|
38
|
-
return new TableWrapper(rows);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Converts a table element into a table wrapper.
|
|
43
|
-
* @param element - The table element to convert into a table wrapper.
|
|
44
|
-
* @throws TableValidationError if element is not a valid {@link TableElement}.
|
|
45
|
-
*/
|
|
46
|
-
static fromElement(element: TableElement) {
|
|
47
|
-
if (element.type !== "table") {
|
|
48
|
-
throw new TableValidationError(
|
|
49
|
-
`Cannot convert element of type ${element.type} to a table wrapper.`,
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
if (!Array.isArray(element.rows)) {
|
|
53
|
-
throw new TableValidationError(
|
|
54
|
-
`Invalid table element: expected an array of rows, got ${element.rows}`,
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
const rows = element.rows.map((row) => ({
|
|
58
|
-
cells: row.cells.map(
|
|
59
|
-
(cell) =>
|
|
60
|
-
cell && {
|
|
61
|
-
...cell,
|
|
62
|
-
attributes: cell.attributes ? { ...cell.attributes } : undefined,
|
|
63
|
-
},
|
|
64
|
-
),
|
|
65
|
-
}));
|
|
66
|
-
return new TableWrapper(rows);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Return a table element that can be passed into the `addElementAtPoint` or `addElementAtCursor` method.
|
|
71
|
-
* @returns A table element.
|
|
72
|
-
*/
|
|
73
|
-
toElement(): TableElement {
|
|
74
|
-
return {
|
|
75
|
-
type: "table",
|
|
76
|
-
rows: this.rows,
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Adds a row to the table after the specified row.
|
|
82
|
-
* @param afterRowPos The position of the new row. A value of `0` adds a row before the first row, a
|
|
83
|
-
* value of `1` adds a row after the first row, etc.
|
|
84
|
-
* @remarks
|
|
85
|
-
* If the row above and below the new row both have the same properties, the properties will be
|
|
86
|
-
* copied to the new row. For example, if there are two rows with the same background color and a
|
|
87
|
-
* row is inserted between them, the new row will also have the same background color.
|
|
88
|
-
*/
|
|
89
|
-
addRow(afterRowPos: number) {
|
|
90
|
-
if (afterRowPos < 0 || afterRowPos > this.rows.length) {
|
|
91
|
-
throw new TableValidationError(
|
|
92
|
-
`New row position must be between 0 and ${this.rows.length}.`,
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
this.validateRowColumn(1, 0);
|
|
97
|
-
|
|
98
|
-
const columnLength = this.rows[0]?.cells.length || 0;
|
|
99
|
-
const newRow = {
|
|
100
|
-
cells: Array.from({ length: columnLength }, () => ({}) as Cell),
|
|
101
|
-
};
|
|
102
|
-
this.rows.splice(afterRowPos, 0, newRow);
|
|
103
|
-
|
|
104
|
-
const newMergeCells: MetaCell[] = Array.from(
|
|
105
|
-
{ length: columnLength },
|
|
106
|
-
() => ({}),
|
|
107
|
-
);
|
|
108
|
-
this.metaCells.splice(afterRowPos, 0, newMergeCells);
|
|
109
|
-
|
|
110
|
-
if (0 < afterRowPos && afterRowPos < this.rows.length) {
|
|
111
|
-
// Insert in between rows
|
|
112
|
-
for (let i = 0; i < newRow.cells.length; i++) {
|
|
113
|
-
this.mayCopyStyles({
|
|
114
|
-
frontRowIdx: afterRowPos - 1,
|
|
115
|
-
frontColumnIdx: i,
|
|
116
|
-
currentRowIdx: afterRowPos,
|
|
117
|
-
currentColumnIdx: i,
|
|
118
|
-
behindRowIdx: afterRowPos + 1,
|
|
119
|
-
behindColumnIdx: i,
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
this.syncCellSpansFromMetaCells();
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Adds a column to the table after the specified column.
|
|
128
|
-
* @param afterColumnPos The position of the new column. A value of `0` adds a column before the first
|
|
129
|
-
* column, a value of `1` adds a column after the first column, etc.
|
|
130
|
-
* @remarks
|
|
131
|
-
* If the column before and after the new column both have the same properties, the properties will be
|
|
132
|
-
* copied to the new column. For example, if there are two columns with the same background color and a
|
|
133
|
-
* column is inserted between them, the new column will also have the same background color.
|
|
134
|
-
*/
|
|
135
|
-
addColumn(afterColumnPos: number) {
|
|
136
|
-
const columnLength = this.rows[0]?.cells.length || 0;
|
|
137
|
-
if (afterColumnPos < 0 || afterColumnPos > columnLength) {
|
|
138
|
-
throw new TableValidationError(
|
|
139
|
-
`New column position must be between 0 and ${columnLength}.`,
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
this.validateRowColumn(0, 1);
|
|
144
|
-
|
|
145
|
-
this.rows.forEach((row) => row.cells.splice(afterColumnPos, 0, null));
|
|
146
|
-
const newColumnLength = this.rows[0]?.cells.length || 0;
|
|
147
|
-
|
|
148
|
-
const newMergeCell: MetaCell = {};
|
|
149
|
-
this.metaCells.forEach((row) =>
|
|
150
|
-
row.splice(afterColumnPos, 0, newMergeCell),
|
|
151
|
-
);
|
|
152
|
-
|
|
153
|
-
if (0 < afterColumnPos && afterColumnPos < newColumnLength) {
|
|
154
|
-
// Insert in between columns
|
|
155
|
-
for (let i = 0; i < this.rows.length; i++) {
|
|
156
|
-
this.mayCopyStyles({
|
|
157
|
-
frontRowIdx: i,
|
|
158
|
-
frontColumnIdx: afterColumnPos - 1,
|
|
159
|
-
currentRowIdx: i,
|
|
160
|
-
currentColumnIdx: afterColumnPos,
|
|
161
|
-
behindRowIdx: i,
|
|
162
|
-
behindColumnIdx: afterColumnPos + 1,
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
this.syncCellSpansFromMetaCells();
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Checks if the specified cell is a *ghost cell*.
|
|
171
|
-
* @param rowPos The row number of the cell, starting from `1`.
|
|
172
|
-
* @param columnPos The column number of the cell, starting from `1`.
|
|
173
|
-
* @remarks
|
|
174
|
-
* A ghost cell is a cell that can not be interacted as it is hidden by a row or column spanning
|
|
175
|
-
* cell. For example, imagine a row where the first cell has a `colSpan` of `2`. In this case, the
|
|
176
|
-
* second cell in the row is hidden and is therefore a ghost cell.
|
|
177
|
-
*/
|
|
178
|
-
isGhostCell(rowPos: number, columnPos: number): boolean {
|
|
179
|
-
this.validateCellBoundaries(rowPos, columnPos);
|
|
180
|
-
const rowIndex = rowPos - 1;
|
|
181
|
-
const columnIndex = columnPos - 1;
|
|
182
|
-
const { mergedInto } = this.getMetaCell(rowIndex, columnIndex);
|
|
183
|
-
if (!mergedInto) {
|
|
184
|
-
// Not belongs to any merged cell
|
|
185
|
-
return false;
|
|
186
|
-
}
|
|
187
|
-
// Not a ghost cell if it's merged into itself
|
|
188
|
-
return mergedInto.row !== rowIndex || mergedInto.column !== columnIndex;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Returns information about the specified cell.
|
|
193
|
-
* @param rowPos The row number of the cell, starting from `1`.
|
|
194
|
-
* @param columnPos The column number of the cell, starting from `1`.
|
|
195
|
-
* @throws TableValidationError if the cell is a ghost cell. To learn more, see {@link isGhostCell}.
|
|
196
|
-
*/
|
|
197
|
-
getCellDetails(rowPos: number, columnPos: number) {
|
|
198
|
-
if (this.isGhostCell(rowPos, columnPos)) {
|
|
199
|
-
throw new TableValidationError(
|
|
200
|
-
`The cell at ${rowPos},${columnPos} is squashed into another cell`,
|
|
201
|
-
);
|
|
202
|
-
}
|
|
203
|
-
return this.getCellInternal(rowPos - 1, columnPos - 1);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
getCellInternal(rowIdx: number, columnIdx: number) {
|
|
207
|
-
const row = this.rows[rowIdx];
|
|
208
|
-
if (!row) {
|
|
209
|
-
throw new Error("validateCellBoundaries should be called first");
|
|
210
|
-
}
|
|
211
|
-
return row.cells[columnIdx];
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Sets the details of the specified cell, including its content and appearance.
|
|
216
|
-
* @param rowPos The row number of the cell, starting from `1`.
|
|
217
|
-
* @param columnPos The column number of the cell, starting from `1`.
|
|
218
|
-
* @param details The new details for the cell.
|
|
219
|
-
* @throws TableValidationError if the cell is a ghost cell. To learn more, see {@link isGhostCell}.
|
|
220
|
-
*/
|
|
221
|
-
setCellDetails(rowPos: number, columnPos: number, details: Cell) {
|
|
222
|
-
const rowSpan = details.rowSpan ?? 1;
|
|
223
|
-
const colSpan = details.colSpan ?? 1;
|
|
224
|
-
this.validateCellBoundaries(rowPos, columnPos, rowSpan, colSpan);
|
|
225
|
-
if (this.isGhostCell(rowPos, columnPos)) {
|
|
226
|
-
throw new TableValidationError(
|
|
227
|
-
`The cell at ${rowPos},${columnPos} is squashed into another cell`,
|
|
228
|
-
);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const rowIndex = rowPos - 1;
|
|
232
|
-
const columnIndex = columnPos - 1;
|
|
233
|
-
const { rowSpan: oldRowSpan, colSpan: oldColSpan } =
|
|
234
|
-
this.getCellInternal(rowIndex, columnIndex) || {};
|
|
235
|
-
|
|
236
|
-
const row = this.rows[rowIndex];
|
|
237
|
-
if (row) {
|
|
238
|
-
row.cells[columnIndex] = details;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
if (oldRowSpan !== rowSpan || oldColSpan !== colSpan) {
|
|
242
|
-
this.syncMergedCellsFromRows();
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
private validateRowColumn(toBeAddedRow = 0, toBeAddedColumn = 0) {
|
|
247
|
-
const rowCount = this.rows.length + toBeAddedRow;
|
|
248
|
-
const row = this.rows[0];
|
|
249
|
-
if (rowCount === 0 || !row) {
|
|
250
|
-
throw new TableValidationError("Table must have at least one row.");
|
|
251
|
-
}
|
|
252
|
-
const columnCount = row.cells.length + toBeAddedColumn;
|
|
253
|
-
if (columnCount === 0) {
|
|
254
|
-
throw new TableValidationError("Table must have at least one column.");
|
|
255
|
-
}
|
|
256
|
-
for (const row of this.rows) {
|
|
257
|
-
if (row.cells.length + toBeAddedColumn !== columnCount) {
|
|
258
|
-
throw new TableValidationError(
|
|
259
|
-
"All rows must have the same number of columns.",
|
|
260
|
-
);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
const cellCount = rowCount * columnCount;
|
|
264
|
-
if (cellCount > MAX_CELL_COUNT) {
|
|
265
|
-
throw new TableValidationError(
|
|
266
|
-
`Table cannot have more than ${MAX_CELL_COUNT} cells. Actual: ${rowCount}x${columnCount} = ${cellCount}`,
|
|
267
|
-
);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Read all cell's colSpans and rowSpans and update the merged cells accordingly.
|
|
273
|
-
* This is opposite sync of {@link syncCellSpansFromMetaCells}.
|
|
274
|
-
*/
|
|
275
|
-
private syncMergedCellsFromRows(): void {
|
|
276
|
-
// First, reset metaCells to unmerged state
|
|
277
|
-
this.metaCells.forEach((cells) =>
|
|
278
|
-
cells.forEach((c) => (c.mergedInto = undefined)),
|
|
279
|
-
);
|
|
280
|
-
|
|
281
|
-
// Then loop through this.rows to find any merged cells
|
|
282
|
-
for (let rowIndex = 0; rowIndex < this.rows.length; rowIndex++) {
|
|
283
|
-
const row = this.rows[rowIndex];
|
|
284
|
-
if (!row) {
|
|
285
|
-
continue;
|
|
286
|
-
}
|
|
287
|
-
for (let columnIndex = 0; columnIndex < row.cells.length; columnIndex++) {
|
|
288
|
-
const cell = row.cells[columnIndex] || { colSpan: 1, rowSpan: 1 };
|
|
289
|
-
const colSpan = cell.colSpan || 1;
|
|
290
|
-
const rowSpan = cell.rowSpan || 1;
|
|
291
|
-
if (colSpan !== 1 || rowSpan !== 1) {
|
|
292
|
-
this.validateCellBoundaries(
|
|
293
|
-
rowIndex + 1,
|
|
294
|
-
columnIndex + 1,
|
|
295
|
-
rowSpan,
|
|
296
|
-
colSpan,
|
|
297
|
-
);
|
|
298
|
-
this.setMergedCellsByBoundary(
|
|
299
|
-
rowIndex,
|
|
300
|
-
columnIndex,
|
|
301
|
-
rowIndex + rowSpan - 1,
|
|
302
|
-
columnIndex + colSpan - 1,
|
|
303
|
-
);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* Update mergeCells array in accordance with the span boundary
|
|
311
|
-
* @param fromRow Top most row index
|
|
312
|
-
* @param fromColumn Left most column index
|
|
313
|
-
* @param toRow Bottom most row index
|
|
314
|
-
* @param toColumn Right most column index
|
|
315
|
-
*/
|
|
316
|
-
private setMergedCellsByBoundary(
|
|
317
|
-
fromRow: number,
|
|
318
|
-
fromColumn: number,
|
|
319
|
-
toRow: number,
|
|
320
|
-
toColumn: number,
|
|
321
|
-
) {
|
|
322
|
-
for (let row = fromRow; row <= toRow; row++) {
|
|
323
|
-
for (let column = fromColumn; column <= toColumn; column++) {
|
|
324
|
-
const metaCell = this.getMetaCell(row, column);
|
|
325
|
-
|
|
326
|
-
if (metaCell.mergedInto) {
|
|
327
|
-
// This cell may be squashed by another merge cell
|
|
328
|
-
const { row: originalRow, column: originalColumn } =
|
|
329
|
-
metaCell.mergedInto;
|
|
330
|
-
if (originalRow !== fromRow && originalColumn !== fromColumn) {
|
|
331
|
-
// And the old origin cell is mismatched with the new origin,
|
|
332
|
-
// this mean the current meta cell is merged into 2 different origin cells,
|
|
333
|
-
// which is forbidden.
|
|
334
|
-
throw new TableValidationError(
|
|
335
|
-
`Expanding the cell at ${fromRow},${fromColumn} collides with another merged cell from ${originalRow},${originalColumn}`,
|
|
336
|
-
);
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
metaCell.mergedInto = {
|
|
341
|
-
row: fromRow,
|
|
342
|
-
column: fromColumn,
|
|
343
|
-
};
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
private mayCopyStyles({
|
|
349
|
-
frontRowIdx,
|
|
350
|
-
frontColumnIdx,
|
|
351
|
-
behindRowIdx,
|
|
352
|
-
behindColumnIdx,
|
|
353
|
-
currentRowIdx,
|
|
354
|
-
currentColumnIdx,
|
|
355
|
-
}: {
|
|
356
|
-
frontRowIdx: number;
|
|
357
|
-
frontColumnIdx: number;
|
|
358
|
-
behindRowIdx: number;
|
|
359
|
-
behindColumnIdx: number;
|
|
360
|
-
currentRowIdx: number;
|
|
361
|
-
currentColumnIdx: number;
|
|
362
|
-
}) {
|
|
363
|
-
// Continue span if both front and behind cells belong to a same merged cell
|
|
364
|
-
const frontMergedCell = this.getMetaCell(
|
|
365
|
-
frontRowIdx,
|
|
366
|
-
frontColumnIdx,
|
|
367
|
-
).mergedInto;
|
|
368
|
-
const behindMergedCell = this.getMetaCell(
|
|
369
|
-
behindRowIdx,
|
|
370
|
-
behindColumnIdx,
|
|
371
|
-
).mergedInto;
|
|
372
|
-
if (
|
|
373
|
-
frontMergedCell &&
|
|
374
|
-
frontMergedCell.row === behindMergedCell?.row &&
|
|
375
|
-
frontMergedCell.column === behindMergedCell?.column
|
|
376
|
-
) {
|
|
377
|
-
this.getMetaCell(currentRowIdx, currentColumnIdx).mergedInto = {
|
|
378
|
-
...frontMergedCell,
|
|
379
|
-
};
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
// Copy attributes if both front and behind cells are the same
|
|
383
|
-
const frontCell = this.getCellInternal(frontRowIdx, frontColumnIdx);
|
|
384
|
-
const behindCell = this.getCellInternal(behindRowIdx, behindColumnIdx);
|
|
385
|
-
if (
|
|
386
|
-
frontCell != null &&
|
|
387
|
-
behindCell != null &&
|
|
388
|
-
frontCell.attributes &&
|
|
389
|
-
behindCell.attributes
|
|
390
|
-
) {
|
|
391
|
-
let currentCell = this.getCellInternal(currentRowIdx, currentColumnIdx);
|
|
392
|
-
const attrs = Object.keys(
|
|
393
|
-
frontCell.attributes,
|
|
394
|
-
) as (keyof Cell["attributes"])[];
|
|
395
|
-
for (const key of attrs) {
|
|
396
|
-
if (frontCell.attributes[key] === behindCell.attributes[key]) {
|
|
397
|
-
currentCell = currentCell || { type: "empty" };
|
|
398
|
-
currentCell.attributes = currentCell.attributes || {};
|
|
399
|
-
currentCell.attributes[key] = frontCell.attributes[key];
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
const targetRow = this.rows[currentRowIdx];
|
|
403
|
-
if (currentCell && targetRow) {
|
|
404
|
-
targetRow.cells[currentColumnIdx] = currentCell;
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
private getMetaCell(rowIdx: number, columnIdx: number): MetaCell {
|
|
410
|
-
const metaCell = this.metaCells[rowIdx]?.[columnIdx];
|
|
411
|
-
if (!metaCell) {
|
|
412
|
-
throw new Error("MetaCells does not match the table dimension");
|
|
413
|
-
}
|
|
414
|
-
return metaCell;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
/**
|
|
418
|
-
* Loop through meta cells and update rowSpan and colSpan of each cell accordingly.
|
|
419
|
-
* This is opposite sync of {@link syncMergedCellsFromRows}
|
|
420
|
-
*/
|
|
421
|
-
private syncCellSpansFromMetaCells() {
|
|
422
|
-
const groups = new Map<string, { row: number; column: number }[]>();
|
|
423
|
-
for (let row = 0; row < this.metaCells.length; row++) {
|
|
424
|
-
const metaRow = this.metaCells[row];
|
|
425
|
-
if (!metaRow) {
|
|
426
|
-
continue;
|
|
427
|
-
}
|
|
428
|
-
for (let column = 0; column < metaRow.length; column++) {
|
|
429
|
-
// Reset all rowSpans and colSpans
|
|
430
|
-
const currentCell = this.getCellInternal(row, column);
|
|
431
|
-
currentCell && delete currentCell.rowSpan;
|
|
432
|
-
currentCell && delete currentCell.colSpan;
|
|
433
|
-
|
|
434
|
-
const mergedCell = this.getMetaCell(row, column);
|
|
435
|
-
if (!mergedCell.mergedInto) {
|
|
436
|
-
continue;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
const { row: actualRow, column: actualColumn } = mergedCell.mergedInto;
|
|
440
|
-
const key = `${actualRow},${actualColumn}`;
|
|
441
|
-
if (!groups.has(key)) {
|
|
442
|
-
groups.set(key, []);
|
|
443
|
-
}
|
|
444
|
-
groups.get(key)?.push({ row, column });
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
groups.forEach((cells, key) => {
|
|
449
|
-
const { minRow, maxRow, minColumn, maxColumn } = cells.reduce(
|
|
450
|
-
(prev, { row, column }) => {
|
|
451
|
-
return {
|
|
452
|
-
minRow: Math.min(prev.minRow, row),
|
|
453
|
-
maxRow: Math.max(prev.maxRow, row),
|
|
454
|
-
minColumn: Math.min(prev.minColumn, column),
|
|
455
|
-
maxColumn: Math.max(prev.maxColumn, column),
|
|
456
|
-
};
|
|
457
|
-
},
|
|
458
|
-
{ minRow: Infinity, maxRow: -1, minColumn: Infinity, maxColumn: -1 },
|
|
459
|
-
);
|
|
460
|
-
if (
|
|
461
|
-
!isFinite(minRow) ||
|
|
462
|
-
!isFinite(minColumn) ||
|
|
463
|
-
maxRow < 0 ||
|
|
464
|
-
maxColumn < 0
|
|
465
|
-
) {
|
|
466
|
-
throw new TableValidationError(`Invalid merged cell started at ${key}`);
|
|
467
|
-
}
|
|
468
|
-
const rowSpan = maxRow - minRow + 1;
|
|
469
|
-
const columnSpan = maxColumn - minColumn + 1;
|
|
470
|
-
if (rowSpan > 1 || columnSpan > 1) {
|
|
471
|
-
const currentCell = this.getCellInternal(minRow, minColumn) || {
|
|
472
|
-
type: "empty",
|
|
473
|
-
};
|
|
474
|
-
currentCell.rowSpan = rowSpan;
|
|
475
|
-
currentCell.colSpan = columnSpan;
|
|
476
|
-
const targetRow = this.rows[minRow];
|
|
477
|
-
if (targetRow) {
|
|
478
|
-
targetRow.cells[minColumn] = currentCell;
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
});
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
private validateCellBoundaries(
|
|
485
|
-
rowPos: number,
|
|
486
|
-
columnPos: number,
|
|
487
|
-
rowSpan = 1,
|
|
488
|
-
columnSpan = 1,
|
|
489
|
-
) {
|
|
490
|
-
if (rowPos < 1 || rowPos > this.rows.length) {
|
|
491
|
-
throw new TableValidationError(
|
|
492
|
-
`Row position must be between 1 and ${this.rows.length} (number of rows).`,
|
|
493
|
-
);
|
|
494
|
-
}
|
|
495
|
-
const columnLength = this.rows[0]?.cells.length || 0;
|
|
496
|
-
if (columnPos < 1 || columnPos > columnLength) {
|
|
497
|
-
throw new TableValidationError(
|
|
498
|
-
`Column position must be between 1 and ${columnLength} (number of columns).`,
|
|
499
|
-
);
|
|
500
|
-
}
|
|
501
|
-
if (rowSpan < 1) {
|
|
502
|
-
throw new TableValidationError(`Row span must be greater than 0.`);
|
|
503
|
-
}
|
|
504
|
-
if (columnSpan < 1) {
|
|
505
|
-
throw new TableValidationError(`Column span must be greater than 0.`);
|
|
506
|
-
}
|
|
507
|
-
if (rowPos + rowSpan - 1 > this.rows.length) {
|
|
508
|
-
throw new TableValidationError(
|
|
509
|
-
`Cannot expand ${rowSpan} rows from the cell at ${rowPos},${columnPos}. Table has ${this.rows.length} rows.`,
|
|
510
|
-
);
|
|
511
|
-
}
|
|
512
|
-
if (columnPos + columnSpan - 1 > columnLength) {
|
|
513
|
-
throw new TableValidationError(
|
|
514
|
-
`Cannot expand ${columnSpan} columns from the cell at ${rowPos},${columnPos}. Table has ${columnLength} columns.`,
|
|
515
|
-
);
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
class TableValidationError extends Error {}
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
EmbedElement,
|
|
3
|
-
ImageElement,
|
|
4
|
-
RichtextElement,
|
|
5
|
-
TableElement,
|
|
6
|
-
TextElement,
|
|
7
|
-
VideoElement,
|
|
8
|
-
} from "@canva/design";
|
|
9
|
-
import { addElementAtCursor, addElementAtPoint } from "@canva/design";
|
|
10
|
-
import { features } from "@canva/platform";
|
|
11
|
-
import { useEffect, useState } from "react";
|
|
12
|
-
import { useFeatureSupport } from "./use_feature_support";
|
|
13
|
-
|
|
14
|
-
type AddElementParams =
|
|
15
|
-
| ImageElement
|
|
16
|
-
| VideoElement
|
|
17
|
-
| EmbedElement
|
|
18
|
-
| TextElement
|
|
19
|
-
| RichtextElement
|
|
20
|
-
| TableElement;
|
|
21
|
-
|
|
22
|
-
export const useAddElement = () => {
|
|
23
|
-
const isSupported = useFeatureSupport();
|
|
24
|
-
|
|
25
|
-
// Store a wrapped addElement function that checks feature support
|
|
26
|
-
const [addElement, setAddElement] = useState(() => {
|
|
27
|
-
return (element: AddElementParams) => {
|
|
28
|
-
if (features.isSupported(addElementAtPoint)) {
|
|
29
|
-
return addElementAtPoint(element);
|
|
30
|
-
} else if (features.isSupported(addElementAtCursor)) {
|
|
31
|
-
return addElementAtCursor(element);
|
|
32
|
-
}
|
|
33
|
-
// eslint-disable-next-line no-console
|
|
34
|
-
console.warn(
|
|
35
|
-
"Neither addElementAtPoint nor addElementAtCursor are supported",
|
|
36
|
-
);
|
|
37
|
-
return Promise.resolve();
|
|
38
|
-
};
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
useEffect(() => {
|
|
42
|
-
const addElement = (element: AddElementParams) => {
|
|
43
|
-
if (isSupported(addElementAtPoint)) {
|
|
44
|
-
return addElementAtPoint(element);
|
|
45
|
-
} else if (isSupported(addElementAtCursor)) {
|
|
46
|
-
return addElementAtCursor(element);
|
|
47
|
-
}
|
|
48
|
-
// eslint-disable-next-line no-console
|
|
49
|
-
console.warn(
|
|
50
|
-
"Neither addElementAtPoint nor addElementAtCursor are supported",
|
|
51
|
-
);
|
|
52
|
-
return Promise.resolve();
|
|
53
|
-
};
|
|
54
|
-
setAddElement(() => addElement);
|
|
55
|
-
}, [isSupported]);
|
|
56
|
-
|
|
57
|
-
return addElement;
|
|
58
|
-
};
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { features } from "@canva/platform";
|
|
2
|
-
import type { Feature } from "@canva/platform";
|
|
3
|
-
import { useEffect, useState } from "react";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* This hook allows re-rendering of a React component whenever
|
|
7
|
-
* the state of feature support changes in Canva.
|
|
8
|
-
*
|
|
9
|
-
* @returns isSupported - callback to inspect a Canva SDK method.
|
|
10
|
-
**/
|
|
11
|
-
export function useFeatureSupport() {
|
|
12
|
-
// Store a wrapped function that checks feature support
|
|
13
|
-
const [isSupported, setIsSupported] = useState(() => {
|
|
14
|
-
return (...args: Feature[]) => features.isSupported(...args);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
useEffect(() => {
|
|
18
|
-
// create new function ref when feature support changes to trigger
|
|
19
|
-
// re-render
|
|
20
|
-
return features.registerOnSupportChange(() => {
|
|
21
|
-
setIsSupported(() => {
|
|
22
|
-
return (...args: Feature[]) => features.isSupported(...args);
|
|
23
|
-
});
|
|
24
|
-
});
|
|
25
|
-
}, []);
|
|
26
|
-
|
|
27
|
-
return isSupported;
|
|
28
|
-
}
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
AppProcessId,
|
|
3
|
-
OverlayOpenableEvent,
|
|
4
|
-
OverlayTarget,
|
|
5
|
-
} from "@canva/design";
|
|
6
|
-
import { overlay as designOverlay } from "@canva/design";
|
|
7
|
-
import type { CloseParams } from "@canva/platform";
|
|
8
|
-
import { appProcess } from "@canva/platform";
|
|
9
|
-
import { useEffect, useState } from "react";
|
|
10
|
-
|
|
11
|
-
const initialOverlayEvent: OverlayOpenableEvent<OverlayTarget> = {
|
|
12
|
-
canOpen: false,
|
|
13
|
-
reason: "",
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Returns an object which contains the following field:
|
|
18
|
-
* 1. canOpen - a boolean indicate whether the overlay can be opened on the specified target.
|
|
19
|
-
* 2. isOpen - a boolean indicate whether the overlay is currently open.
|
|
20
|
-
* 3. open - a function to open an overlay on the specified target.
|
|
21
|
-
* 4. close - a function close the currently opened overlay.
|
|
22
|
-
* @param target The overlay target to register for whether we can open an overlay.
|
|
23
|
-
*/
|
|
24
|
-
export function useOverlay<
|
|
25
|
-
T extends OverlayTarget,
|
|
26
|
-
C extends CloseParams = CloseParams,
|
|
27
|
-
>(
|
|
28
|
-
target: T,
|
|
29
|
-
): {
|
|
30
|
-
canOpen: boolean;
|
|
31
|
-
isOpen: boolean;
|
|
32
|
-
open: (opts?: {
|
|
33
|
-
launchParameters?: unknown;
|
|
34
|
-
}) => Promise<AppProcessId | undefined>;
|
|
35
|
-
close: (opts: C) => Promise<void>;
|
|
36
|
-
} {
|
|
37
|
-
const [overlay, setOverlay] =
|
|
38
|
-
useState<OverlayOpenableEvent<T>>(initialOverlayEvent);
|
|
39
|
-
const [overlayId, setOverlayId] = useState<AppProcessId>();
|
|
40
|
-
const [isOpen, setIsOpen] = useState<boolean>(false);
|
|
41
|
-
|
|
42
|
-
useEffect(() => {
|
|
43
|
-
return designOverlay.registerOnCanOpen({
|
|
44
|
-
target,
|
|
45
|
-
onCanOpen: setOverlay,
|
|
46
|
-
});
|
|
47
|
-
}, []);
|
|
48
|
-
|
|
49
|
-
useEffect(() => {
|
|
50
|
-
if (overlayId) {
|
|
51
|
-
appProcess.registerOnStateChange(overlayId, ({ state }) =>
|
|
52
|
-
setIsOpen(state === "open"),
|
|
53
|
-
);
|
|
54
|
-
}
|
|
55
|
-
}, [overlayId]);
|
|
56
|
-
|
|
57
|
-
const open = async (
|
|
58
|
-
opts: { launchParameters?: unknown } = {},
|
|
59
|
-
): Promise<AppProcessId | undefined> => {
|
|
60
|
-
if (!overlay || !overlay.canOpen) {
|
|
61
|
-
return undefined;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const overlayId = await overlay.open(opts);
|
|
65
|
-
setOverlayId(overlayId);
|
|
66
|
-
return overlayId;
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
const close = async (opts: C) => {
|
|
70
|
-
if (overlayId) {
|
|
71
|
-
appProcess.requestClose<C>(overlayId, opts);
|
|
72
|
-
}
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
return { canOpen: overlay.canOpen, isOpen, open, close };
|
|
76
|
-
}
|