editor-ts 0.0.1 → 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.
@@ -0,0 +1,447 @@
1
+ /**
2
+ * StorageManager - Handles saving/loading page data and assets
3
+ * Supports pluggable adapters: localStorage (default) or remote server
4
+ */
5
+
6
+ export interface StorageAdapter {
7
+ /** Save page data */
8
+ savePage(key: string, data: string): Promise<void>;
9
+ /** Load page data */
10
+ loadPage(key: string): Promise<string | null>;
11
+ /** Delete page data */
12
+ deletePage(key: string): Promise<void>;
13
+ /** Upload an image and return the URL */
14
+ uploadImage(file: File | Blob, filename?: string): Promise<string>;
15
+ /** Delete an image */
16
+ deleteImage(url: string): Promise<void>;
17
+ /** List all saved pages */
18
+ listPages(): Promise<string[]>;
19
+ }
20
+
21
+ export interface LocalStorageConfig {
22
+ /**
23
+ * Local storage is the default when `storage` is omitted.
24
+ *
25
+ * This field is optional to allow concise configs like:
26
+ * { prefix: 'myapp_' }
27
+ */
28
+ type?: 'local';
29
+ prefix?: string; // Key prefix for localStorage, default: 'editorts_'
30
+ }
31
+
32
+ export interface RemoteStorageConfig {
33
+ type: 'remote';
34
+ baseUrl: string;
35
+ /** How to send images: 'form' (multipart/form-data) or 'json' (base64 in JSON body) */
36
+ imageUploadMethod?: 'form' | 'json';
37
+ /** Custom headers for requests (e.g., Authorization) */
38
+ headers?: Record<string, string>;
39
+ /** Endpoint paths (relative to baseUrl) */
40
+ endpoints?: {
41
+ savePage?: string; // Default: '/pages'
42
+ loadPage?: string; // Default: '/pages/:key'
43
+ deletePage?: string; // Default: '/pages/:key'
44
+ uploadImage?: string; // Default: '/images'
45
+ deleteImage?: string; // Default: '/images/:id'
46
+ listPages?: string; // Default: '/pages'
47
+ };
48
+ }
49
+
50
+ export type SqlocalClient = {
51
+ sql: (strings: TemplateStringsArray, ...values: unknown[]) => Promise<Array<Record<string, unknown>>>;
52
+ };
53
+
54
+ export interface SqlocalStorageConfig {
55
+ type: 'sqlocal';
56
+ /** SQLite database file name stored in OPFS (used when `client` is not provided). */
57
+ databaseName?: string;
58
+ /** Pre-initialized SQLocal client (avoids dynamic import). */
59
+ client?: SqlocalClient;
60
+ }
61
+
62
+ export type StorageConfig = LocalStorageConfig | RemoteStorageConfig | SqlocalStorageConfig;
63
+
64
+ /**
65
+ * LocalStorage Adapter - Stores data in browser localStorage
66
+ */
67
+ export class LocalStorageAdapter implements StorageAdapter {
68
+ private prefix: string;
69
+
70
+ constructor(config?: LocalStorageConfig) {
71
+ this.prefix = config?.prefix || 'editorts_';
72
+ }
73
+
74
+ async savePage(key: string, data: string): Promise<void> {
75
+ localStorage.setItem(this.prefix + 'page_' + key, data);
76
+ }
77
+
78
+ async loadPage(key: string): Promise<string | null> {
79
+ return localStorage.getItem(this.prefix + 'page_' + key);
80
+ }
81
+
82
+ async deletePage(key: string): Promise<void> {
83
+ localStorage.removeItem(this.prefix + 'page_' + key);
84
+ }
85
+
86
+ async uploadImage(file: File | Blob, filename?: string): Promise<string> {
87
+ // Convert to data URL and store in localStorage
88
+ return new Promise((resolve, reject) => {
89
+ const reader = new FileReader();
90
+ reader.onload = () => {
91
+ const dataUrl = reader.result as string;
92
+ const imageKey = this.prefix + 'img_' + (filename || Date.now().toString());
93
+ localStorage.setItem(imageKey, dataUrl);
94
+ // Return the key as the "URL" - can be retrieved later
95
+ resolve(dataUrl);
96
+ };
97
+ reader.onerror = () => reject(new Error('Failed to read image file'));
98
+ reader.readAsDataURL(file);
99
+ });
100
+ }
101
+
102
+ async deleteImage(url: string): Promise<void> {
103
+ // If it's a localStorage key, remove it
104
+ if (url.startsWith(this.prefix + 'img_')) {
105
+ localStorage.removeItem(url);
106
+ }
107
+ // If it's a data URL stored with a key, we can't easily find it
108
+ // Data URLs are self-contained, so nothing to clean up
109
+ }
110
+
111
+ async listPages(): Promise<string[]> {
112
+ const pages: string[] = [];
113
+ const prefix = this.prefix + 'page_';
114
+ for (let i = 0; i < localStorage.length; i++) {
115
+ const key = localStorage.key(i);
116
+ if (key && key.startsWith(prefix)) {
117
+ pages.push(key.substring(prefix.length));
118
+ }
119
+ }
120
+ return pages;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Remote Storage Adapter - Stores data on a remote server
126
+ */
127
+ export class RemoteStorageAdapter implements StorageAdapter {
128
+ private baseUrl: string;
129
+ private imageUploadMethod: 'form' | 'json';
130
+ private headers: Record<string, string>;
131
+ private endpoints: Required<NonNullable<RemoteStorageConfig['endpoints']>>;
132
+
133
+ constructor(config: RemoteStorageConfig) {
134
+ this.baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
135
+ this.imageUploadMethod = config.imageUploadMethod || 'form';
136
+ this.headers = config.headers || {};
137
+ this.endpoints = {
138
+ savePage: config.endpoints?.savePage || '/pages',
139
+ loadPage: config.endpoints?.loadPage || '/pages/:key',
140
+ deletePage: config.endpoints?.deletePage || '/pages/:key',
141
+ uploadImage: config.endpoints?.uploadImage || '/images',
142
+ deleteImage: config.endpoints?.deleteImage || '/images/:id',
143
+ listPages: config.endpoints?.listPages || '/pages',
144
+ };
145
+ }
146
+
147
+ private buildUrl(endpoint: string, params?: Record<string, string>): string {
148
+ let url = this.baseUrl + endpoint;
149
+ if (params) {
150
+ Object.entries(params).forEach(([key, value]) => {
151
+ url = url.replace(`:${key}`, encodeURIComponent(value));
152
+ });
153
+ }
154
+ return url;
155
+ }
156
+
157
+ async savePage(key: string, data: string): Promise<void> {
158
+ const response = await fetch(this.buildUrl(this.endpoints.savePage), {
159
+ method: 'POST',
160
+ headers: {
161
+ 'Content-Type': 'application/json',
162
+ ...this.headers,
163
+ },
164
+ body: JSON.stringify({ key, data }),
165
+ });
166
+ if (!response.ok) {
167
+ throw new Error(`Failed to save page: ${response.statusText}`);
168
+ }
169
+ }
170
+
171
+ async loadPage(key: string): Promise<string | null> {
172
+ const response = await fetch(this.buildUrl(this.endpoints.loadPage, { key }), {
173
+ method: 'GET',
174
+ headers: this.headers,
175
+ });
176
+ if (response.status === 404) {
177
+ return null;
178
+ }
179
+ if (!response.ok) {
180
+ throw new Error(`Failed to load page: ${response.statusText}`);
181
+ }
182
+ const result = await response.json();
183
+ return result.data || null;
184
+ }
185
+
186
+ async deletePage(key: string): Promise<void> {
187
+ const response = await fetch(this.buildUrl(this.endpoints.deletePage, { key }), {
188
+ method: 'DELETE',
189
+ headers: this.headers,
190
+ });
191
+ if (!response.ok) {
192
+ throw new Error(`Failed to delete page: ${response.statusText}`);
193
+ }
194
+ }
195
+
196
+ async uploadImage(file: File | Blob, filename?: string): Promise<string> {
197
+ let response: Response;
198
+
199
+ if (this.imageUploadMethod === 'form') {
200
+ // Multipart form data upload
201
+ const formData = new FormData();
202
+ formData.append('image', file, filename || 'image');
203
+
204
+ response = await fetch(this.buildUrl(this.endpoints.uploadImage), {
205
+ method: 'POST',
206
+ headers: this.headers, // Don't set Content-Type for FormData
207
+ body: formData,
208
+ });
209
+ } else {
210
+ // JSON with base64 upload
211
+ const base64 = await this.fileToBase64(file);
212
+ response = await fetch(this.buildUrl(this.endpoints.uploadImage), {
213
+ method: 'POST',
214
+ headers: {
215
+ 'Content-Type': 'application/json',
216
+ ...this.headers,
217
+ },
218
+ body: JSON.stringify({
219
+ filename: filename || 'image',
220
+ data: base64,
221
+ contentType: file.type,
222
+ }),
223
+ });
224
+ }
225
+
226
+ if (!response.ok) {
227
+ throw new Error(`Failed to upload image: ${response.statusText}`);
228
+ }
229
+
230
+ const result = await response.json();
231
+ return result.url;
232
+ }
233
+
234
+ async deleteImage(url: string): Promise<void> {
235
+ // Extract ID from URL (assumes URL ends with /images/:id or similar)
236
+ const parts = url.split('/');
237
+ const id = parts[parts.length - 1] || '';
238
+
239
+ const response = await fetch(this.buildUrl(this.endpoints.deleteImage, { id }), {
240
+ method: 'DELETE',
241
+ headers: this.headers,
242
+ });
243
+ if (!response.ok) {
244
+ throw new Error(`Failed to delete image: ${response.statusText}`);
245
+ }
246
+ }
247
+
248
+ async listPages(): Promise<string[]> {
249
+ const response = await fetch(this.buildUrl(this.endpoints.listPages), {
250
+ method: 'GET',
251
+ headers: this.headers,
252
+ });
253
+ if (!response.ok) {
254
+ throw new Error(`Failed to list pages: ${response.statusText}`);
255
+ }
256
+ const result = await response.json();
257
+ return result.pages || [];
258
+ }
259
+
260
+ private fileToBase64(file: File | Blob): Promise<string> {
261
+ return new Promise((resolve, reject) => {
262
+ const reader = new FileReader();
263
+ reader.onload = () => {
264
+ const dataUrl = reader.result as string;
265
+ // Extract base64 part (remove data:mime;base64, prefix)
266
+ const base64 = dataUrl.split(',')[1] || '';
267
+ resolve(base64);
268
+ };
269
+ reader.onerror = () => reject(new Error('Failed to read file'));
270
+ reader.readAsDataURL(file);
271
+ });
272
+ }
273
+ }
274
+
275
+ type SqlocalModule = {
276
+ SQLocal: new (databaseName: string) => SqlocalClient;
277
+ };
278
+
279
+
280
+ export class SqlocalStorageAdapter implements StorageAdapter {
281
+ private databaseName: string;
282
+ private client: SqlocalClient | null;
283
+ private sqlocalPromise: Promise<SqlocalClient> | null = null;
284
+
285
+ constructor(config?: SqlocalStorageConfig) {
286
+ this.databaseName = config?.databaseName || 'editorts.sqlite';
287
+ this.client = config?.client ?? null;
288
+ }
289
+
290
+ private async ensureSchema(client: SqlocalClient): Promise<void> {
291
+ await client.sql`
292
+ CREATE TABLE IF NOT EXISTS editor_pages (
293
+ key TEXT PRIMARY KEY,
294
+ data TEXT NOT NULL
295
+ )
296
+ `;
297
+ await client.sql`
298
+ CREATE TABLE IF NOT EXISTS editor_images (
299
+ key TEXT PRIMARY KEY,
300
+ data TEXT NOT NULL
301
+ )
302
+ `;
303
+ }
304
+
305
+ private async loadClient(): Promise<SqlocalClient> {
306
+ if (this.client) {
307
+ await this.ensureSchema(this.client);
308
+ return this.client;
309
+ }
310
+
311
+ if (!this.sqlocalPromise) {
312
+ this.sqlocalPromise = (async () => {
313
+ try {
314
+ const module = (await import('sqlocal')) as unknown as SqlocalModule;
315
+ const { SQLocal } = module;
316
+ const client = new SQLocal(this.databaseName);
317
+ await this.ensureSchema(client);
318
+ return client;
319
+ } catch (error: unknown) {
320
+ const message = error instanceof Error ? error.message : 'Unknown error';
321
+ throw new Error(`Failed to load sqlocal: ${message}`);
322
+ }
323
+ })();
324
+ }
325
+
326
+ return this.sqlocalPromise;
327
+ }
328
+
329
+ async savePage(key: string, data: string): Promise<void> {
330
+ const { sql } = await this.loadClient();
331
+ await sql`
332
+ INSERT INTO editor_pages (key, data)
333
+ VALUES (${key}, ${data})
334
+ ON CONFLICT(key) DO UPDATE SET data = excluded.data
335
+ `;
336
+ }
337
+
338
+ async loadPage(key: string): Promise<string | null> {
339
+ const { sql } = await this.loadClient();
340
+ const rows = await sql`
341
+ SELECT data FROM editor_pages WHERE key = ${key} LIMIT 1
342
+ `;
343
+ const result = rows[0] as { data?: unknown } | undefined;
344
+ return typeof result?.data === 'string' ? result.data : null;
345
+ }
346
+
347
+ async deletePage(key: string): Promise<void> {
348
+ const { sql } = await this.loadClient();
349
+ await sql`
350
+ DELETE FROM editor_pages WHERE key = ${key}
351
+ `;
352
+ }
353
+
354
+ async uploadImage(file: File | Blob, filename?: string): Promise<string> {
355
+ const dataUrl = await new Promise<string>((resolve, reject) => {
356
+ const reader = new FileReader();
357
+ reader.onload = () => resolve(reader.result as string);
358
+ reader.onerror = () => reject(new Error('Failed to read image file'));
359
+ reader.readAsDataURL(file);
360
+ });
361
+
362
+ const { sql } = await this.loadClient();
363
+ const imageKey = filename || `${Date.now()}`;
364
+ await sql`
365
+ INSERT INTO editor_images (key, data)
366
+ VALUES (${imageKey}, ${dataUrl})
367
+ ON CONFLICT(key) DO UPDATE SET data = excluded.data
368
+ `;
369
+ return dataUrl;
370
+ }
371
+
372
+ async deleteImage(url: string): Promise<void> {
373
+ const { sql } = await this.loadClient();
374
+ await sql`
375
+ DELETE FROM editor_images WHERE data = ${url}
376
+ `;
377
+ }
378
+
379
+ async listPages(): Promise<string[]> {
380
+ const { sql } = await this.loadClient();
381
+ const rows = await sql`
382
+ SELECT key FROM editor_pages ORDER BY key
383
+ `;
384
+ return rows
385
+ .map((row) => (row as { key?: unknown }).key)
386
+ .filter((key): key is string => typeof key === 'string');
387
+ }
388
+ }
389
+
390
+ /**
391
+ * StorageManager - Main class for managing storage
392
+ */
393
+ export class StorageManager {
394
+ private adapter: StorageAdapter;
395
+
396
+ constructor(config?: StorageConfig) {
397
+ // Local storage is the default.
398
+ // Only use remote storage when explicitly requested.
399
+ if (!config || config.type === 'local') {
400
+ this.adapter = new LocalStorageAdapter(config as LocalStorageConfig | undefined);
401
+ } else if (config.type === 'remote') {
402
+ this.adapter = new RemoteStorageAdapter(config);
403
+ } else {
404
+ this.adapter = new SqlocalStorageAdapter(config as SqlocalStorageConfig);
405
+ }
406
+ }
407
+
408
+ /** Save page data */
409
+ async savePage(key: string, data: string): Promise<void> {
410
+ return this.adapter.savePage(key, data);
411
+ }
412
+
413
+ /** Load page data */
414
+ async loadPage(key: string): Promise<string | null> {
415
+ return this.adapter.loadPage(key);
416
+ }
417
+
418
+ /** Delete page data */
419
+ async deletePage(key: string): Promise<void> {
420
+ return this.adapter.deletePage(key);
421
+ }
422
+
423
+ /** Upload an image and return the URL */
424
+ async uploadImage(file: File | Blob, filename?: string): Promise<string> {
425
+ return this.adapter.uploadImage(file, filename);
426
+ }
427
+
428
+ /** Delete an image */
429
+ async deleteImage(url: string): Promise<void> {
430
+ return this.adapter.deleteImage(url);
431
+ }
432
+
433
+ /** List all saved pages */
434
+ async listPages(): Promise<string[]> {
435
+ return this.adapter.listPages();
436
+ }
437
+
438
+ /** Get the underlying adapter */
439
+ getAdapter(): StorageAdapter {
440
+ return this.adapter;
441
+ }
442
+
443
+ /** Set a custom adapter */
444
+ setAdapter(adapter: StorageAdapter): void {
445
+ this.adapter = adapter;
446
+ }
447
+ }
@@ -6,6 +6,7 @@ import type { PageBody, Style, StyleQuery, CSSProperties } from '../types';
6
6
  export class StyleManager {
7
7
  private body: PageBody;
8
8
  private styles: Style[];
9
+ private compiledCSSOverride: string | null = null;
9
10
 
10
11
  constructor(body: PageBody) {
11
12
  this.body = body;
@@ -65,6 +66,7 @@ export class StyleManager {
65
66
  * Add a new style rule
66
67
  */
67
68
  addStyle(style: Style): void {
69
+ this.compiledCSSOverride = null;
68
70
  this.styles.push(style);
69
71
  }
70
72
 
@@ -72,6 +74,7 @@ export class StyleManager {
72
74
  * Remove styles by selector
73
75
  */
74
76
  removeBySelector(selector: string): number {
77
+ this.compiledCSSOverride = null;
75
78
  const initialLength = this.styles.length;
76
79
  this.styles = this.styles.filter((style) => {
77
80
  const hasSelector = style.selectors.some((sel) => {
@@ -89,6 +92,8 @@ export class StyleManager {
89
92
  * Update styles for a selector
90
93
  */
91
94
  updateStyle(selector: string, properties: CSSProperties, options?: { mediaText?: string; state?: string }): boolean {
95
+ this.compiledCSSOverride = null;
96
+
92
97
  const matchingStyles = this.styles.filter((style) => {
93
98
  const hasSelector = style.selectors.some((sel) => {
94
99
  if (typeof sel === 'string') {
@@ -147,6 +152,22 @@ export class StyleManager {
147
152
  return this.styles.length;
148
153
  }
149
154
 
155
+ /**
156
+ * Override the compiled CSS string.
157
+ * Useful when you want to edit raw CSS instead of structured Style[] rules.
158
+ */
159
+ setCompiledCSS(css: string): void {
160
+ this.compiledCSSOverride = css;
161
+ this.body.css = css;
162
+ }
163
+
164
+ /**
165
+ * Clear any compiled CSS override (returns to Style[] -> CSS compilation).
166
+ */
167
+ clearCompiledCSSOverride(): void {
168
+ this.compiledCSSOverride = null;
169
+ }
170
+
150
171
  /**
151
172
  * Compile styles to CSS string
152
173
  */
@@ -184,7 +205,21 @@ export class StyleManager {
184
205
  if (typeof sel === 'string') {
185
206
  return sel;
186
207
  }
187
- return sel.name;
208
+
209
+ // EditorTs stores selector objects as component IDs by default.
210
+ // Compile them as ID selectors unless the user provided a full selector.
211
+ const name = sel.name.trim();
212
+ const looksLikeSelector =
213
+ name.startsWith('#') ||
214
+ name.startsWith('.') ||
215
+ name.startsWith('[') ||
216
+ name.includes(' ') ||
217
+ name.includes('>') ||
218
+ name.includes('+') ||
219
+ name.includes('~') ||
220
+ name.includes(':');
221
+
222
+ return looksLikeSelector ? name : `#${name}`;
188
223
  });
189
224
 
190
225
  let selector = selectors.join(', ');
@@ -209,13 +244,14 @@ export class StyleManager {
209
244
  */
210
245
  sync(): void {
211
246
  this.body.styles = this.styles;
212
- this.body.css = this.compileToCSS();
247
+ this.body.css = this.compiledCSSOverride ?? this.compileToCSS();
213
248
  }
214
249
 
215
250
  /**
216
251
  * Replace all styles
217
252
  */
218
253
  replaceAll(styles: Style[]): void {
254
+ this.compiledCSSOverride = null;
219
255
  this.styles = styles;
220
256
  }
221
257
  }