editor-ts 0.0.10 → 0.0.12
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 +197 -318
- package/index.ts +70 -0
- package/package.json +31 -10
- package/src/core/ComponentManager.ts +697 -6
- package/src/core/ComponentPalette.ts +109 -0
- package/src/core/CustomComponentRegistry.ts +74 -0
- package/src/core/KeyboardShortcuts.ts +220 -0
- package/src/core/LayerManager.ts +378 -0
- package/src/core/Page.ts +24 -5
- package/src/core/StorageManager.ts +447 -0
- package/src/core/StyleManager.ts +38 -2
- package/src/core/VersionControl.ts +189 -0
- package/src/core/aiChat.ts +427 -0
- package/src/core/iframeCanvas.ts +672 -0
- package/src/core/init.ts +3081 -248
- package/src/server/bun_server.ts +155 -0
- package/src/server/cf_worker.ts +225 -0
- package/src/server/schema.ts +21 -0
- package/src/server/sync.ts +195 -0
- package/src/types/sqlocal.d.ts +6 -0
- package/src/types.ts +591 -18
- package/src/utils/toolbar.ts +15 -1
|
@@ -1,27 +1,78 @@
|
|
|
1
1
|
import type { PageBody, Component, ComponentQuery } from '../types';
|
|
2
|
+
import { sanitizeHTML, cssStringToObject } from '../utils/helpers';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Manager for handling component operations
|
|
5
6
|
*/
|
|
7
|
+
export type DomAdapter = {
|
|
8
|
+
createTemplate(): HTMLTemplateElement;
|
|
9
|
+
};
|
|
10
|
+
|
|
6
11
|
export class ComponentManager {
|
|
12
|
+
private static readonly voidTags = new Set([
|
|
13
|
+
'area',
|
|
14
|
+
'base',
|
|
15
|
+
'br',
|
|
16
|
+
'col',
|
|
17
|
+
'embed',
|
|
18
|
+
'hr',
|
|
19
|
+
'img',
|
|
20
|
+
'input',
|
|
21
|
+
'link',
|
|
22
|
+
'meta',
|
|
23
|
+
'param',
|
|
24
|
+
'source',
|
|
25
|
+
'track',
|
|
26
|
+
'wbr',
|
|
27
|
+
]);
|
|
28
|
+
|
|
7
29
|
private body: PageBody;
|
|
8
30
|
private parsedComponents: Component[];
|
|
31
|
+
private dom: DomAdapter | null;
|
|
9
32
|
|
|
10
|
-
constructor(body: PageBody) {
|
|
33
|
+
constructor(body: PageBody, options?: { dom?: DomAdapter | null }) {
|
|
11
34
|
this.body = body;
|
|
12
35
|
this.parsedComponents = this.parse();
|
|
36
|
+
|
|
37
|
+
this.dom = options?.dom ?? (typeof document !== 'undefined'
|
|
38
|
+
? {
|
|
39
|
+
createTemplate: () => document.createElement('template'),
|
|
40
|
+
}
|
|
41
|
+
: null);
|
|
42
|
+
|
|
43
|
+
// If we were given HTML without components, derive components from HTML.
|
|
44
|
+
if (this.parsedComponents.length === 0 && typeof this.body.html === 'string' && this.body.html.trim() !== '') {
|
|
45
|
+
this.setFromHTML(this.body.html);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Keep html in sync when components are available.
|
|
49
|
+
if (this.parsedComponents.length > 0) {
|
|
50
|
+
this.syncHtmlFromComponents();
|
|
51
|
+
}
|
|
13
52
|
}
|
|
14
53
|
|
|
15
54
|
/**
|
|
16
55
|
* Parse components from JSON string
|
|
17
56
|
*/
|
|
18
57
|
private parse(): Component[] {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
58
|
+
const raw = this.body.components;
|
|
59
|
+
|
|
60
|
+
if (Array.isArray(raw)) {
|
|
61
|
+
return raw;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (typeof raw === 'string') {
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(raw) as Component[];
|
|
67
|
+
} catch (error: unknown) {
|
|
68
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
69
|
+
console.error('Failed to parse components:', message);
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
|
|
24
73
|
}
|
|
74
|
+
|
|
75
|
+
return [];
|
|
25
76
|
}
|
|
26
77
|
|
|
27
78
|
/**
|
|
@@ -163,6 +214,47 @@ export class ComponentManager {
|
|
|
163
214
|
return false;
|
|
164
215
|
}
|
|
165
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Update a component's text content
|
|
219
|
+
*/
|
|
220
|
+
updateTextContent(id: string, content: string): boolean {
|
|
221
|
+
const component = this.findById(id);
|
|
222
|
+
if (component) {
|
|
223
|
+
component.content = content;
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Update image src for a component (handles img tags and components with nested images)
|
|
231
|
+
*/
|
|
232
|
+
updateImageSrc(id: string, src: string): boolean {
|
|
233
|
+
const component = this.findById(id);
|
|
234
|
+
if (component) {
|
|
235
|
+
// If component is an image type or has tagName img
|
|
236
|
+
if (component.tagName === 'img' || component.type === 'image') {
|
|
237
|
+
component.attributes = component.attributes || {};
|
|
238
|
+
component.attributes.src = src;
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
// Check if component has nested image in its content
|
|
242
|
+
if (component.content && component.content.includes('<img')) {
|
|
243
|
+
// Update src in content HTML
|
|
244
|
+
component.content = component.content.replace(
|
|
245
|
+
/(<img[^>]*src=["'])[^"']*["']/i,
|
|
246
|
+
`$1${src}"`
|
|
247
|
+
);
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
// Store as a generic image src attribute
|
|
251
|
+
component.attributes = component.attributes || {};
|
|
252
|
+
component.attributes.src = src;
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
|
|
166
258
|
/**
|
|
167
259
|
* Get all components
|
|
168
260
|
*/
|
|
@@ -190,11 +282,499 @@ export class ComponentManager {
|
|
|
190
282
|
return count;
|
|
191
283
|
}
|
|
192
284
|
|
|
285
|
+
/**
|
|
286
|
+
* Convert the current component tree to HTML.
|
|
287
|
+
*/
|
|
288
|
+
toHTML(): string {
|
|
289
|
+
return this.componentsToHTML(this.parsedComponents);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Convert the current component tree to JSX/TSX source.
|
|
294
|
+
*
|
|
295
|
+
* Notes:
|
|
296
|
+
* - This is a best-effort export for round-tripping.
|
|
297
|
+
* - Attributes are emitted as JSX props; `class` becomes `className`.
|
|
298
|
+
* - Inline style strings are converted to an object expression.
|
|
299
|
+
*/
|
|
300
|
+
toJSX(options?: { pretty?: boolean; indent?: string }): string {
|
|
301
|
+
const pretty = options?.pretty ?? true;
|
|
302
|
+
const indent = options?.indent ?? ' ';
|
|
303
|
+
|
|
304
|
+
const newline = pretty ? '\n' : '';
|
|
305
|
+
|
|
306
|
+
const toComponentName = (id: string): string => {
|
|
307
|
+
const cleaned = id
|
|
308
|
+
.replace(/[^a-zA-Z0-9_\-]/g, ' ')
|
|
309
|
+
.trim()
|
|
310
|
+
.split(/\s+|\-/g)
|
|
311
|
+
.filter(Boolean)
|
|
312
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
313
|
+
.join('');
|
|
314
|
+
|
|
315
|
+
return cleaned.match(/^[A-Z]/) ? cleaned : `C${cleaned}`;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const definitions: string[] = [];
|
|
319
|
+
|
|
320
|
+
const exportComponent = (component: Component) => {
|
|
321
|
+
const id = typeof component.attributes?.id === 'string' ? component.attributes.id : null;
|
|
322
|
+
if (!id) return;
|
|
323
|
+
|
|
324
|
+
const name = toComponentName(id);
|
|
325
|
+
const body = this.componentToJSX(component, 2, { pretty, indent, newline });
|
|
326
|
+
|
|
327
|
+
definitions.push(
|
|
328
|
+
`export function ${name}() {${newline}` +
|
|
329
|
+
`${indent}return (${newline}` +
|
|
330
|
+
`${body}${newline}` +
|
|
331
|
+
`${indent});${newline}` +
|
|
332
|
+
`}${newline}`
|
|
333
|
+
);
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
this.parsedComponents.forEach(exportComponent);
|
|
337
|
+
|
|
338
|
+
if (definitions.length === 0) {
|
|
339
|
+
// No ids to create named components; fall back to inline JSX.
|
|
340
|
+
const inline = this.parsedComponents
|
|
341
|
+
.map((component) => this.componentToJSX(component, 0, { pretty, indent, newline }))
|
|
342
|
+
.join(newline);
|
|
343
|
+
|
|
344
|
+
return `export function Template() {${newline}` +
|
|
345
|
+
`${indent}return (${newline}` +
|
|
346
|
+
`${pretty ? inline.split('\n').map((l) => (l ? indent + l : l)).join('\n') : inline}${newline}` +
|
|
347
|
+
`${indent});${newline}` +
|
|
348
|
+
`}`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return definitions.join(newline);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Replace current components by parsing the provided HTML.
|
|
356
|
+
*/
|
|
357
|
+
setFromHTML(html: string): void {
|
|
358
|
+
if (!this.dom) {
|
|
359
|
+
console.warn('EditorTs: ComponentManager.setFromHTML() requires DOM; provide a dom adapter when running server-side.');
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
this.parsedComponents = this.htmlToComponents(html);
|
|
364
|
+
this.sync();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Replace current components by parsing the provided JSX/TSX.
|
|
369
|
+
*
|
|
370
|
+
* This is intended for server/build-time usage. It uses `typescript` (peer dep)
|
|
371
|
+
* and does not require DOM.
|
|
372
|
+
*/
|
|
373
|
+
async setFromJSX(source: string): Promise<void> {
|
|
374
|
+
const components = await this.jsxToComponents(source);
|
|
375
|
+
if (components.length === 0) return;
|
|
376
|
+
|
|
377
|
+
this.parsedComponents = components;
|
|
378
|
+
this.sync();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Sync HTML from components back to body.html.
|
|
383
|
+
*/
|
|
384
|
+
syncHtmlFromComponents(): void {
|
|
385
|
+
this.body.html = `<body>${this.toHTML()}</body>`;
|
|
386
|
+
}
|
|
387
|
+
|
|
193
388
|
/**
|
|
194
389
|
* Sync changes back to page body
|
|
195
390
|
*/
|
|
196
391
|
sync(): void {
|
|
197
392
|
this.body.components = JSON.stringify(this.parsedComponents);
|
|
393
|
+
if (this.parsedComponents.length > 0) {
|
|
394
|
+
this.syncHtmlFromComponents();
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private componentsToHTML(components: Component[]): string {
|
|
399
|
+
return components.map((component) => this.componentToHTML(component)).join('');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private componentToHTML(component: Component): string {
|
|
403
|
+
const tagName = component.tagName ?? 'div';
|
|
404
|
+
const attributes = this.attributesToString(component.attributes);
|
|
405
|
+
const style = typeof component.style === 'string' && component.style.trim() !== '' ? ` style="${sanitizeHTML(component.style)}"` : '';
|
|
406
|
+
|
|
407
|
+
const contentText = typeof component.content === 'string' ? sanitizeHTML(component.content) : '';
|
|
408
|
+
const childrenHtml = component.components ? this.componentsToHTML(component.components) : '';
|
|
409
|
+
|
|
410
|
+
const isVoid = component.void === true || ComponentManager.voidTags.has(tagName.toLowerCase());
|
|
411
|
+
|
|
412
|
+
if (isVoid) {
|
|
413
|
+
return `<${tagName}${attributes}${style} />`;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return `<${tagName}${attributes}${style}>${contentText}${childrenHtml}</${tagName}>`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private attributesToString(attributes: Component['attributes']): string {
|
|
420
|
+
if (!attributes) return '';
|
|
421
|
+
|
|
422
|
+
const parts: string[] = [];
|
|
423
|
+
|
|
424
|
+
Object.entries(attributes).forEach(([key, value]) => {
|
|
425
|
+
if (value === undefined || value === null) return;
|
|
426
|
+
if (key === 'style') return;
|
|
427
|
+
|
|
428
|
+
if (typeof value === 'boolean') {
|
|
429
|
+
if (value) parts.push(`${key}`);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
|
|
434
|
+
parts.push(`${key}="${sanitizeHTML(stringValue)}"`);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
return parts.length > 0 ? ` ${parts.join(' ')}` : '';
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private componentToJSX(
|
|
441
|
+
component: Component,
|
|
442
|
+
depth: number,
|
|
443
|
+
options: { pretty: boolean; indent: string; newline: string }
|
|
444
|
+
): string {
|
|
445
|
+
const tagName = component.tagName ?? 'div';
|
|
446
|
+
|
|
447
|
+
const { pretty, indent, newline } = options;
|
|
448
|
+
const leading = pretty ? indent.repeat(depth) : '';
|
|
449
|
+
|
|
450
|
+
const isVoid = component.void === true || ComponentManager.voidTags.has(tagName.toLowerCase());
|
|
451
|
+
|
|
452
|
+
const props = this.attributesToJSXProps(component);
|
|
453
|
+
const open = `<${tagName}${props}>`;
|
|
454
|
+
|
|
455
|
+
if (isVoid) {
|
|
456
|
+
return `${leading}<${tagName}${props} />`;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const children: string[] = [];
|
|
460
|
+
|
|
461
|
+
if (typeof component.content === 'string' && component.content.trim() !== '') {
|
|
462
|
+
children.push(this.escapeJsxText(component.content));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (component.components && component.components.length > 0) {
|
|
466
|
+
const rendered = component.components.map((c) => this.componentToJSX(c, depth + 1, options));
|
|
467
|
+
children.push(rendered.join(newline));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (children.length === 0) {
|
|
471
|
+
return `${leading}${open}</${tagName}>`;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (!pretty) {
|
|
475
|
+
return `${leading}${open}${children.join('')}</${tagName}>`;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const inner = children
|
|
479
|
+
.map((child) => {
|
|
480
|
+
// If the child already has indentation (nested JSX), keep it as-is.
|
|
481
|
+
if (child.startsWith(indent.repeat(depth + 1))) return child;
|
|
482
|
+
return `${indent.repeat(depth + 1)}${child}`;
|
|
483
|
+
})
|
|
484
|
+
.join(newline);
|
|
485
|
+
|
|
486
|
+
return `${leading}${open}${newline}${inner}${newline}${leading}</${tagName}>`;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private escapeJsxText(text: string): string {
|
|
490
|
+
// Escape braces so the output remains valid JSX text.
|
|
491
|
+
return text.replace(/\{/g, '{').replace(/\}/g, '}');
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
private cssKeyToJsx(key: string): string {
|
|
495
|
+
return key.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
private toJsxStyleObject(styleText: string): Record<string, string> {
|
|
499
|
+
const raw = cssStringToObject(styleText);
|
|
500
|
+
const out: Record<string, string> = {};
|
|
501
|
+
|
|
502
|
+
Object.entries(raw).forEach(([key, value]) => {
|
|
503
|
+
out[this.cssKeyToJsx(key)] = value;
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
return out;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
private attributesToJSXProps(component: Component): string {
|
|
510
|
+
const attributes = component.attributes;
|
|
511
|
+
const props: string[] = [];
|
|
512
|
+
|
|
513
|
+
if (attributes) {
|
|
514
|
+
Object.entries(attributes).forEach(([rawKey, value]) => {
|
|
515
|
+
if (value === undefined || value === null) return;
|
|
516
|
+
|
|
517
|
+
const key = rawKey === 'class' ? 'className' : rawKey;
|
|
518
|
+
|
|
519
|
+
if (typeof value === 'string') {
|
|
520
|
+
props.push(`${key}=${JSON.stringify(value)}`);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
525
|
+
props.push(`${key}={${String(value)}}`);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Fallback: JSON
|
|
530
|
+
props.push(`${key}={${JSON.stringify(value)}}`);
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (typeof component.style === 'string' && component.style.trim() !== '') {
|
|
535
|
+
const styleObj = this.toJsxStyleObject(component.style);
|
|
536
|
+
props.push(`style={${JSON.stringify(styleObj)}}`);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return props.length > 0 ? ` ${props.join(' ')}` : '';
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private async jsxToComponents(source: string): Promise<Component[]> {
|
|
543
|
+
const ts = await this.loadTypeScript();
|
|
544
|
+
if (!ts) return [];
|
|
545
|
+
|
|
546
|
+
const file = ts.createSourceFile('editorts.tsx', source, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX);
|
|
547
|
+
|
|
548
|
+
const roots: Component[] = [];
|
|
549
|
+
|
|
550
|
+
const visit = (node: import('typescript').Node) => {
|
|
551
|
+
if (ts.isJsxElement(node)) {
|
|
552
|
+
const component = this.jsxElementToComponent(ts, node);
|
|
553
|
+
if (component) roots.push(component);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (ts.isJsxSelfClosingElement(node)) {
|
|
558
|
+
const component = this.jsxSelfClosingElementToComponent(ts, node);
|
|
559
|
+
if (component) roots.push(component);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
ts.forEachChild(node, visit);
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
ts.forEachChild(file, visit);
|
|
567
|
+
|
|
568
|
+
return roots;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
private async loadTypeScript(): Promise<typeof import('typescript') | null> {
|
|
572
|
+
try {
|
|
573
|
+
return await import('typescript');
|
|
574
|
+
} catch (err: unknown) {
|
|
575
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
576
|
+
console.warn('EditorTs: setFromJSX() requires optional peer dependency typescript:', message);
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
private jsxElementToComponent(
|
|
582
|
+
ts: typeof import('typescript'),
|
|
583
|
+
node: import('typescript').JsxElement
|
|
584
|
+
): Component | null {
|
|
585
|
+
const opening = node.openingElement;
|
|
586
|
+
|
|
587
|
+
const tagName = this.jsxTagName(ts, opening.tagName);
|
|
588
|
+
if (!tagName) return null;
|
|
589
|
+
|
|
590
|
+
const component: Component = {
|
|
591
|
+
type: tagName,
|
|
592
|
+
tagName,
|
|
593
|
+
attributes: this.jsxAttributesToRecord(ts, opening.attributes),
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
const children = this.jsxChildrenToComponents(ts, node.children);
|
|
597
|
+
if (children.length > 0) {
|
|
598
|
+
component.components = children;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Prefer textContent only when there are no nested JSX elements.
|
|
602
|
+
const textContent = node.children
|
|
603
|
+
.filter((c) => ts.isJsxText(c))
|
|
604
|
+
.map((c) => c.getText())
|
|
605
|
+
.join('')
|
|
606
|
+
.trim();
|
|
607
|
+
|
|
608
|
+
if (children.length === 0 && textContent !== '') {
|
|
609
|
+
component.content = textContent;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
return component;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
private jsxSelfClosingElementToComponent(
|
|
616
|
+
ts: typeof import('typescript'),
|
|
617
|
+
node: import('typescript').JsxSelfClosingElement
|
|
618
|
+
): Component | null {
|
|
619
|
+
const tagName = this.jsxTagName(ts, node.tagName);
|
|
620
|
+
if (!tagName) return null;
|
|
621
|
+
|
|
622
|
+
const component: Component = {
|
|
623
|
+
type: tagName,
|
|
624
|
+
tagName,
|
|
625
|
+
attributes: this.jsxAttributesToRecord(ts, node.attributes),
|
|
626
|
+
void: true,
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
return component;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
private jsxChildrenToComponents(
|
|
633
|
+
ts: typeof import('typescript'),
|
|
634
|
+
children: readonly import('typescript').JsxChild[]
|
|
635
|
+
): Component[] {
|
|
636
|
+
const out: Component[] = [];
|
|
637
|
+
|
|
638
|
+
children.forEach((child) => {
|
|
639
|
+
if (ts.isJsxElement(child)) {
|
|
640
|
+
const next = this.jsxElementToComponent(ts, child);
|
|
641
|
+
if (next) out.push(next);
|
|
642
|
+
} else if (ts.isJsxSelfClosingElement(child)) {
|
|
643
|
+
const next = this.jsxSelfClosingElementToComponent(ts, child);
|
|
644
|
+
if (next) out.push(next);
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
return out;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
private jsxTagName(
|
|
652
|
+
ts: typeof import('typescript'),
|
|
653
|
+
tagName: import('typescript').JsxTagNameExpression
|
|
654
|
+
): string | null {
|
|
655
|
+
if (ts.isIdentifier(tagName)) {
|
|
656
|
+
// Only allow intrinsic tags here.
|
|
657
|
+
return tagName.text;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (ts.isPropertyAccessExpression(tagName)) {
|
|
661
|
+
return tagName.getText();
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
return tagName.getText();
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
private jsxAttributesToRecord(
|
|
668
|
+
ts: typeof import('typescript'),
|
|
669
|
+
attrs: import('typescript').JsxAttributes
|
|
670
|
+
): Component['attributes'] {
|
|
671
|
+
const out: Component['attributes'] = {};
|
|
672
|
+
|
|
673
|
+
attrs.properties.forEach((prop) => {
|
|
674
|
+
if (ts.isJsxAttribute(prop)) {
|
|
675
|
+
const key = ts.isIdentifier(prop.name) ? prop.name.text : prop.name.getText();
|
|
676
|
+
|
|
677
|
+
if (!prop.initializer) {
|
|
678
|
+
out[key] = true;
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (ts.isStringLiteral(prop.initializer)) {
|
|
683
|
+
out[key] = prop.initializer.text;
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (ts.isJsxExpression(prop.initializer)) {
|
|
688
|
+
const expr = prop.initializer.expression;
|
|
689
|
+
if (!expr) return;
|
|
690
|
+
|
|
691
|
+
if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) {
|
|
692
|
+
out[key] = expr.text;
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (ts.isNumericLiteral(expr)) {
|
|
697
|
+
out[key] = Number(expr.text);
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (expr.kind === ts.SyntaxKind.TrueKeyword) {
|
|
702
|
+
out[key] = true;
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (expr.kind === ts.SyntaxKind.FalseKeyword) {
|
|
707
|
+
out[key] = false;
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// For now, fall back to source string.
|
|
712
|
+
out[key] = expr.getText();
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (ts.isJsxSpreadAttribute(prop)) {
|
|
718
|
+
// Spread props are not representable in JSON; ignore for now.
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
return out;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
private htmlToComponents(html: string): Component[] {
|
|
727
|
+
// Strip outer <body> wrapper if present.
|
|
728
|
+
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
|
729
|
+
const bodyHtml = bodyMatch ? bodyMatch[1]! : html;
|
|
730
|
+
|
|
731
|
+
if (!this.dom) {
|
|
732
|
+
console.warn('EditorTs: ComponentManager.htmlToComponents() requires DOM; provide a dom adapter when running server-side.');
|
|
733
|
+
return [];
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const template = this.dom.createTemplate();
|
|
737
|
+
template.innerHTML = bodyHtml;
|
|
738
|
+
|
|
739
|
+
const elements = Array.from(template.content.children) as HTMLElement[];
|
|
740
|
+
return elements.map((el) => this.elementToComponent(el));
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
private elementToComponent(el: HTMLElement): Component {
|
|
744
|
+
const tagName = el.tagName.toLowerCase();
|
|
745
|
+
|
|
746
|
+
const attributes: Component['attributes'] = {};
|
|
747
|
+
Array.from(el.attributes).forEach((attr) => {
|
|
748
|
+
if (attributes) {
|
|
749
|
+
attributes[attr.name] = attr.value;
|
|
750
|
+
}
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
const childElements = Array.from(el.children) as HTMLElement[];
|
|
754
|
+
|
|
755
|
+
const component: Component = {
|
|
756
|
+
type: tagName,
|
|
757
|
+
tagName,
|
|
758
|
+
attributes,
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
// Only treat as text content when there are no nested elements.
|
|
762
|
+
if (childElements.length === 0) {
|
|
763
|
+
const text = el.textContent ?? '';
|
|
764
|
+
if (text.trim() !== '') {
|
|
765
|
+
component.content = text;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (childElements.length > 0) {
|
|
770
|
+
component.components = childElements.map((child) => this.elementToComponent(child));
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (ComponentManager.voidTags.has(tagName)) {
|
|
774
|
+
component.void = true;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
return component;
|
|
198
778
|
}
|
|
199
779
|
|
|
200
780
|
/**
|
|
@@ -203,4 +783,115 @@ export class ComponentManager {
|
|
|
203
783
|
replaceAll(components: Component[]): void {
|
|
204
784
|
this.parsedComponents = components;
|
|
205
785
|
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Move a component to a new position
|
|
789
|
+
* @param componentId - The ID of the component to move
|
|
790
|
+
* @param newParentId - The ID of the new parent (null for root level)
|
|
791
|
+
* @param newIndex - The index position within the new parent
|
|
792
|
+
*/
|
|
793
|
+
moveComponent(componentId: string, newParentId: string | null, newIndex: number): boolean {
|
|
794
|
+
// Find and remove the component from its current location
|
|
795
|
+
const component = this.findById(componentId);
|
|
796
|
+
if (!component) return false;
|
|
797
|
+
|
|
798
|
+
// Remove from current location
|
|
799
|
+
if (!this.removeComponent(componentId)) return false;
|
|
800
|
+
|
|
801
|
+
// Add to new location
|
|
802
|
+
if (newParentId === null) {
|
|
803
|
+
// Move to root level
|
|
804
|
+
const insertIndex = Math.min(newIndex, this.parsedComponents.length);
|
|
805
|
+
this.parsedComponents.splice(insertIndex, 0, component);
|
|
806
|
+
} else {
|
|
807
|
+
// Move to a parent component
|
|
808
|
+
const parent = this.findById(newParentId);
|
|
809
|
+
if (!parent) {
|
|
810
|
+
// Restore component to root if parent not found
|
|
811
|
+
this.parsedComponents.push(component);
|
|
812
|
+
return false;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
if (!parent.components) {
|
|
816
|
+
parent.components = [];
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const insertIndex = Math.min(newIndex, parent.components.length);
|
|
820
|
+
parent.components.splice(insertIndex, 0, component);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
return true;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Reorder a component within its current parent
|
|
828
|
+
* @param componentId - The ID of the component to reorder
|
|
829
|
+
* @param newIndex - The new index position
|
|
830
|
+
*/
|
|
831
|
+
reorderComponent(componentId: string, newIndex: number): boolean {
|
|
832
|
+
// Find the parent that contains this component
|
|
833
|
+
const result = this.findParentAndIndex(componentId);
|
|
834
|
+
if (!result) return false;
|
|
835
|
+
|
|
836
|
+
const { parent, index } = result;
|
|
837
|
+
const components = parent ? parent.components! : this.parsedComponents;
|
|
838
|
+
|
|
839
|
+
// Remove from current position
|
|
840
|
+
const [component] = components.splice(index, 1);
|
|
841
|
+
|
|
842
|
+
// Insert at new position (adjust for removal)
|
|
843
|
+
const adjustedIndex = newIndex > index ? newIndex - 1 : newIndex;
|
|
844
|
+
const insertIndex = Math.min(Math.max(0, adjustedIndex), components.length);
|
|
845
|
+
components.splice(insertIndex, 0, component!);
|
|
846
|
+
|
|
847
|
+
return true;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Get the parent component ID and index for a component.
|
|
852
|
+
* Returns { parentId: null } when the component is at the root.
|
|
853
|
+
*/
|
|
854
|
+
getParentAndIndex(componentId: string): { parentId: string | null; index: number } | null {
|
|
855
|
+
const result = this.findParentAndIndex(componentId);
|
|
856
|
+
if (!result) return null;
|
|
857
|
+
|
|
858
|
+
return {
|
|
859
|
+
parentId: result.parent?.attributes?.id ?? null,
|
|
860
|
+
index: result.index,
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Find the parent component and index of a component
|
|
866
|
+
*/
|
|
867
|
+
private findParentAndIndex(componentId: string): { parent: Component | null; index: number } | null {
|
|
868
|
+
// Check root level
|
|
869
|
+
for (let i = 0; i < this.parsedComponents.length; i++) {
|
|
870
|
+
if (this.parsedComponents[i]?.attributes?.id === componentId) {
|
|
871
|
+
return { parent: null, index: i };
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Search recursively
|
|
876
|
+
return this.findParentAndIndexInTree(this.parsedComponents, componentId);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Recursively search for parent and index
|
|
881
|
+
*/
|
|
882
|
+
private findParentAndIndexInTree(components: Component[], componentId: string): { parent: Component | null; index: number } | null {
|
|
883
|
+
for (const component of components) {
|
|
884
|
+
if (component.components) {
|
|
885
|
+
for (let i = 0; i < component.components.length; i++) {
|
|
886
|
+
if (component.components[i]?.attributes?.id === componentId) {
|
|
887
|
+
return { parent: component, index: i };
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
// Search deeper
|
|
891
|
+
const result = this.findParentAndIndexInTree(component.components, componentId);
|
|
892
|
+
if (result) return result;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
return null;
|
|
896
|
+
}
|
|
206
897
|
}
|