@codeauradev/ngx-wind-editor 1.0.0 → 1.0.1

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.
@@ -1,184 +0,0 @@
1
- :host {
2
- display: block;
3
- min-width: 0;
4
- }
5
-
6
- .wind-frame {
7
- position: relative;
8
- min-width: 0;
9
- }
10
-
11
- .wind-doc {
12
- min-height: 12rem;
13
- padding: 1rem 1.15rem 1.5rem;
14
- outline: none;
15
- color: var(--color-stone-900, #1c1917);
16
- font-family: var(--font-serif, Georgia, "Iowan Old Style", "Palatino Linotype", serif);
17
- font-size: 1.0625rem;
18
- line-height: 1.65;
19
- caret-color: var(--color-teal-700, #0f766e);
20
- overflow-wrap: anywhere;
21
- }
22
-
23
- .wind-doc > * + * {
24
- margin-top: 0.75rem;
25
- }
26
-
27
- .wind-doc p,
28
- .wind-doc h1,
29
- .wind-doc h2,
30
- .wind-doc h3,
31
- .wind-doc blockquote,
32
- .wind-doc pre,
33
- .wind-doc li {
34
- margin: 0;
35
- }
36
-
37
- .wind-doc h1,
38
- .wind-doc h2,
39
- .wind-doc h3 {
40
- font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif);
41
- line-height: 1.2;
42
- letter-spacing: -0.03em;
43
- color: var(--color-stone-950, #0c0a09);
44
- }
45
-
46
- .wind-doc h1 { font-size: 1.875rem; font-weight: 600; }
47
- .wind-doc h2 { font-size: 1.5rem; font-weight: 600; }
48
- .wind-doc h3 { font-size: 1.25rem; font-weight: 600; }
49
-
50
- .wind-doc blockquote {
51
- border-left: 2px solid var(--color-teal-700, #0f766e);
52
- padding-left: 0.9rem;
53
- color: var(--color-stone-600, #57534e);
54
- }
55
-
56
- .wind-doc ul,
57
- .wind-doc ol {
58
- margin: 0;
59
- padding-left: 1.25rem;
60
- }
61
-
62
- .wind-doc li + li {
63
- margin-top: 0.25rem;
64
- }
65
-
66
- .wind-doc pre {
67
- overflow-x: auto;
68
- border-radius: 0.6rem;
69
- background: color-mix(in oklab, var(--color-stone-900, #1c1917) 6%, transparent);
70
- padding: 0.8rem 0.9rem;
71
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
72
- font-size: 0.875rem;
73
- line-height: 1.55;
74
- white-space: pre-wrap;
75
- }
76
-
77
- .wind-doc pre code {
78
- font: inherit;
79
- background: none;
80
- padding: 0;
81
- }
82
-
83
- .wind-doc a {
84
- color: var(--color-teal-800, #115e59);
85
- text-decoration: underline;
86
- text-underline-offset: 2px;
87
- }
88
-
89
- .wind-doc code {
90
- border-radius: 0.3rem;
91
- background: color-mix(in oklab, var(--color-stone-900, #1c1917) 8%, transparent);
92
- padding: 0.05em 0.3em;
93
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
94
- font-size: 0.88em;
95
- }
96
-
97
- .wind-doc img {
98
- display: block;
99
- max-width: 100%;
100
- border-radius: 0.6rem;
101
- }
102
-
103
- .wind-doc figure {
104
- margin: 0;
105
- }
106
-
107
- .wind-doc hr {
108
- border: 0;
109
- border-top: 1px solid var(--color-stone-300, #d6d3d1);
110
- margin: 0.25rem 0;
111
- }
112
-
113
- .wind-doc [data-atomic].is-selected {
114
- outline: 2px solid var(--color-teal-700, #0f766e);
115
- outline-offset: 3px;
116
- border-radius: 0.4rem;
117
- }
118
-
119
- .wind-doc[data-empty='true'] > p[data-placeholder]:before {
120
- content: attr(data-placeholder);
121
- float: left;
122
- height: 0;
123
- pointer-events: none;
124
- color: var(--color-stone-400, #a8a29e);
125
- }
126
-
127
- .wind-carets {
128
- position: absolute;
129
- inset: 0;
130
- pointer-events: none;
131
- overflow: hidden;
132
- }
133
-
134
- .wind-caret,
135
- .wind-remote {
136
- position: absolute;
137
- pointer-events: none;
138
- }
139
-
140
- .wind-caret {
141
- width: 2px;
142
- background: var(--peer);
143
- border-radius: 1px;
144
- }
145
-
146
- .wind-caret i {
147
- position: absolute;
148
- top: -1.15rem;
149
- left: -1px;
150
- border-radius: 0.3rem;
151
- background: var(--peer);
152
- color: white;
153
- padding: 0.05rem 0.3rem;
154
- font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif);
155
- font-size: 0.65rem;
156
- font-style: normal;
157
- line-height: 1.3;
158
- letter-spacing: 0.01em;
159
- white-space: nowrap;
160
- }
161
-
162
- .wind-remote {
163
- background: color-mix(in oklab, var(--peer) 32%, transparent);
164
- border-radius: 0.1rem;
165
- }
166
-
167
- .wind-frame.is-readonly .wind-doc {
168
- caret-color: transparent;
169
- }
170
-
171
- .dark .wind-doc {
172
- color: var(--color-stone-100, #f5f5f4);
173
- caret-color: var(--color-teal-300, #5eead4);
174
- }
175
- .dark .wind-doc h1,
176
- .dark .wind-doc h2,
177
- .dark .wind-doc h3 { color: var(--color-stone-50, #fafaf9); }
178
- .dark .wind-doc blockquote { color: var(--color-stone-400, #a8a29e); }
179
- .dark .wind-doc a { color: var(--color-teal-300, #5eead4); }
180
- .dark .wind-doc hr { border-top-color: var(--color-stone-700, #44403c); }
181
- .dark .wind-doc pre,
182
- .dark .wind-doc code {
183
- background: color-mix(in oklab, white 8%, transparent);
184
- }
package/src/surface.ts DELETED
@@ -1,499 +0,0 @@
1
- import * as Y from 'yjs';
2
- import { deltaToText } from './delta';
3
- import { safeUrl } from './html';
4
- import { loadHtml, readBlocks, WindSession } from './session';
5
- import type { SlashChoice } from './session';
6
- import type { BlockData, BlockType, Delta, EditorSnapshot, MarkName, Point, Selection } from './types';
7
-
8
- export interface SlashPoint {
9
- top: number;
10
- left: number;
11
- }
12
-
13
- export interface SurfaceChange extends EditorSnapshot {
14
- slashRect: SlashPoint | null;
15
- }
16
-
17
- export type WindCommand =
18
- | { type: 'mark'; mark: MarkName }
19
- | { type: 'link'; href: string | null }
20
- | { type: 'clear' }
21
- | { type: 'block'; block: BlockType; level?: number }
22
- | { type: 'divider' }
23
- | { type: 'image'; src: string; alt?: string }
24
- | { type: 'undo' }
25
- | { type: 'redo' }
26
- | { type: 'slash'; choice: SlashChoice };
27
-
28
- export interface WindMount {
29
- destroy(): void;
30
- focus(): void;
31
- exec(command: WindCommand): void;
32
- setReadonly(readonly: boolean): void;
33
- setPlaceholder(value: string): void;
34
- setHtml(html: string): void;
35
- getHtml(): string;
36
- session: WindSession;
37
- }
38
-
39
- export interface SurfaceOptions {
40
- html?: string;
41
- placeholder?: string;
42
- readonly?: boolean;
43
- onChange?: (change: SurfaceChange) => void;
44
- /** Return true to swallow the key (slash menu, for example). */
45
- interceptKey?: (event: KeyboardEvent) => boolean;
46
- onLink?: () => void;
47
- }
48
-
49
- interface DomPoint {
50
- node: Node;
51
- offset: number;
52
- }
53
-
54
- export function mountWindSurface(host: HTMLElement, options: SurfaceOptions): WindMount {
55
- const doc = new Y.Doc();
56
- if (options.html) loadHtml(doc, options.html);
57
- const session = new WindSession(doc);
58
- const frame = host;
59
- frame.classList.add('wind-frame');
60
- const docEl = document.createElement('div');
61
- docEl.className = 'wind-doc';
62
- docEl.setAttribute('role', 'textbox');
63
- docEl.setAttribute('aria-multiline', 'true');
64
- docEl.setAttribute('aria-label', 'Document');
65
- docEl.spellcheck = true;
66
- frame.replaceChildren(docEl);
67
-
68
- let placeholder = options.placeholder ?? 'Write something…';
69
- let readonly = options.readonly ?? false;
70
- let silent = 0;
71
- let composing = false;
72
- let pending = false;
73
- let destroyed = false;
74
-
75
- const applyEditable = () => {
76
- docEl.contentEditable = readonly ? 'false' : 'true';
77
- frame.classList.toggle('is-readonly', readonly);
78
- };
79
- applyEditable();
80
-
81
- const focused = () => docEl === document.activeElement || docEl.contains(document.activeElement);
82
-
83
- const blockEl = (id: string) => docEl.querySelector<HTMLElement>(`[data-id="${CSS.escape(id)}"]`);
84
-
85
- const pointToDom = (point: Point): DomPoint | null => {
86
- const el = blockEl(point.blockId);
87
- if (!el) return null;
88
- if (el.dataset['atomic'] === 'true') return { node: el, offset: 0 };
89
- const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
90
- let left = point.offset;
91
- let last: Text | null = null;
92
- while (walker.nextNode()) {
93
- const text = walker.currentNode as Text;
94
- last = text;
95
- if (left <= text.length) return { node: text, offset: left };
96
- left -= text.length;
97
- }
98
- if (last) return { node: last, offset: last.length };
99
- return { node: el, offset: 0 };
100
- };
101
-
102
- const pointFromNode = (node: Node, offset: number): Point | null => {
103
- const element = node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : node.parentElement;
104
- const block = element?.closest<HTMLElement>('[data-id]');
105
- if (!block || !docEl.contains(block)) return null;
106
- const blockId = block.dataset['id'] ?? '';
107
- if (block.dataset['atomic'] === 'true') return { blockId, offset: 0 };
108
- const range = document.createRange();
109
- range.selectNodeContents(block);
110
- try {
111
- range.setEnd(node, offset);
112
- } catch {
113
- return { blockId, offset: 0 };
114
- }
115
- return { blockId, offset: range.toString().length };
116
- };
117
-
118
- const readSelection = (): Selection | null => {
119
- const sel = document.getSelection();
120
- if (!sel || sel.rangeCount === 0) return null;
121
- if (!sel.anchorNode || !docEl.contains(sel.anchorNode)) return null;
122
- const anchor = pointFromNode(sel.anchorNode, sel.anchorOffset);
123
- const headNode = sel.focusNode ?? sel.anchorNode;
124
- const head = pointFromNode(headNode, sel.focusOffset);
125
- if (!anchor || !head) return null;
126
- return { anchor, head };
127
- };
128
-
129
- const samePoint = (a: Point, b: Point) => a.blockId === b.blockId && a.offset === b.offset;
130
- const sameSelection = (a: Selection, b: Selection) => samePoint(a.anchor, b.anchor) && samePoint(a.head, b.head);
131
-
132
- const applySelection = (selection: Selection) => {
133
- const anchor = pointToDom(selection.anchor);
134
- const head = pointToDom(selection.head);
135
- if (!anchor || !head) return;
136
- const sel = document.getSelection();
137
- if (!sel) return;
138
- silent += 1;
139
- try {
140
- sel.setBaseAndExtent(anchor.node, anchor.offset, head.node, head.offset);
141
- } catch {
142
- /* The node may have been removed mid-update. */
143
- }
144
- queueMicrotask(() => {
145
- silent = Math.max(0, silent - 1);
146
- });
147
- };
148
-
149
- const fillInline = (el: HTMLElement, delta: Delta) => {
150
- el.replaceChildren();
151
- if (deltaToText(delta).length === 0) {
152
- el.append(document.createElement('br'));
153
- return;
154
- }
155
- for (const op of delta) {
156
- let node: Node = document.createTextNode(op.insert);
157
- const attrs = op.attributes ?? {};
158
- const wrap = (tag: string, child: Node) => {
159
- const wrapped = document.createElement(tag);
160
- wrapped.append(child);
161
- return wrapped;
162
- };
163
- if (attrs.code) node = wrap('code', node);
164
- if (attrs.strike) node = wrap('s', node);
165
- if (attrs.underline) node = wrap('u', node);
166
- if (attrs.italic) node = wrap('em', node);
167
- if (attrs.bold) node = wrap('strong', node);
168
- if (attrs.href) {
169
- const href = safeUrl(attrs.href);
170
- if (href) {
171
- const link = document.createElement('a');
172
- link.href = href;
173
- link.rel = 'noopener noreferrer';
174
- link.append(node);
175
- node = link;
176
- }
177
- }
178
- el.append(node);
179
- }
180
- };
181
-
182
- const bind = (el: HTMLElement, block: BlockData) => {
183
- el.dataset['id'] = block.id;
184
- el.dataset['type'] = block.type;
185
- };
186
-
187
- const draw = () => {
188
- const blocks = readBlocks(session.doc);
189
- const fragment = document.createDocumentFragment();
190
- let index = 0;
191
- while (index < blocks.length) {
192
- const block = blocks[index];
193
- if (!block) break;
194
- if (block.type === 'bullet' || block.type === 'ordered') {
195
- const list = document.createElement(block.type === 'bullet' ? 'ul' : 'ol');
196
- while (index < blocks.length && blocks[index]?.type === block.type) {
197
- const item = blocks[index] as BlockData;
198
- const li = document.createElement('li');
199
- bind(li, item);
200
- fillInline(li, item.delta);
201
- list.append(li);
202
- index += 1;
203
- }
204
- fragment.append(list);
205
- continue;
206
- }
207
- if (block.type === 'image') {
208
- const figure = document.createElement('figure');
209
- bind(figure, block);
210
- figure.dataset['atomic'] = 'true';
211
- figure.contentEditable = 'false';
212
- const image = document.createElement('img');
213
- const src = safeUrl(block.src);
214
- if (src) image.src = src;
215
- image.alt = block.alt;
216
- image.draggable = false;
217
- figure.append(image);
218
- fragment.append(figure);
219
- index += 1;
220
- continue;
221
- }
222
- if (block.type === 'rule') {
223
- const rule = document.createElement('hr');
224
- bind(rule, block);
225
- rule.dataset['atomic'] = 'true';
226
- fragment.append(rule);
227
- index += 1;
228
- continue;
229
- }
230
- const tag = block.type === 'heading' ? `h${Math.min(3, Math.max(1, block.level || 1))}` : block.type === 'blockquote' ? 'blockquote' : block.type === 'code' ? 'pre' : 'p';
231
- const el = document.createElement(tag);
232
- bind(el, block);
233
- if (block.type === 'code') {
234
- const code = document.createElement('code');
235
- fillInline(code, block.delta);
236
- el.append(code);
237
- } else {
238
- fillInline(el, block.delta);
239
- }
240
- if (block.type === 'paragraph' && deltaToText(block.delta).length === 0) el.dataset['placeholder'] = placeholder;
241
- fragment.append(el);
242
- index += 1;
243
- }
244
- const empty = blocks.length === 1 && blocks[0]?.type === 'paragraph' && deltaToText(blocks[0].delta).length === 0;
245
- docEl.dataset['empty'] = empty ? 'true' : 'false';
246
- docEl.replaceChildren(fragment);
247
- const head = blockEl(session.selection.head.blockId);
248
- if (head?.dataset['atomic'] === 'true') head.classList.add('is-selected');
249
- if (focused()) applySelection(session.selection);
250
- };
251
-
252
- const slashRect = (): SlashPoint | null => {
253
- if (session.snapshot().slash === null) return null;
254
- const sel = document.getSelection();
255
- if (!sel || sel.rangeCount === 0 || !sel.anchorNode || !docEl.contains(sel.anchorNode)) return null;
256
- const rect = sel.getRangeAt(0).getBoundingClientRect();
257
- return { top: rect.bottom + 6, left: Math.max(8, rect.left) };
258
- };
259
-
260
- const publish = () => {
261
- const snap = session.snapshot();
262
- const blocks = readBlocks(session.doc);
263
- const text = blocks
264
- .map((block) => (block.type === 'image' ? block.alt || block.src : block.type === 'rule' ? '' : deltaToText(block.delta)))
265
- .join('\n');
266
- const words = text.trim() ? text.trim().split(/\s+/).length : 0;
267
- options.onChange?.({
268
- html: session.html,
269
- text,
270
- words,
271
- characters: text.replace(/\s/g, '').length,
272
- slashRect: slashRect(),
273
- ...snap,
274
- });
275
- };
276
-
277
- const redraw = (structural: boolean) => {
278
- if (destroyed) return;
279
- if (composing && structural) {
280
- pending = true;
281
- publish();
282
- return;
283
- }
284
- if (structural) draw();
285
- publish();
286
- };
287
-
288
- const unsession = session.onChange(redraw);
289
-
290
- const reconcileDom = () => {
291
- const elements = [...docEl.querySelectorAll<HTMLElement>('[data-id]')];
292
- const model = readBlocks(session.doc);
293
- if (elements.length !== model.length || model.some((block) => !elements.some((el) => el.dataset['id'] === block.id))) {
294
- session.replaceHtml(docEl.innerHTML);
295
- return;
296
- }
297
- for (const el of elements) {
298
- const id = el.dataset['id'];
299
- if (!id || el.dataset['atomic'] === 'true') continue;
300
- session.reconcileText(id, el.textContent ?? '');
301
- }
302
- };
303
-
304
- docEl.addEventListener('beforeinput', (event) => {
305
- if (readonly) {
306
- event.preventDefault();
307
- return;
308
- }
309
- if (composing) return;
310
- const input = event as InputEvent;
311
- const type = input.inputType;
312
- if (type === 'insertText') {
313
- event.preventDefault();
314
- session.insertText(input.data ?? '');
315
- } else if (type === 'insertParagraph') {
316
- event.preventDefault();
317
- session.split('enter');
318
- } else if (type === 'insertLineBreak') {
319
- event.preventDefault();
320
- session.split('hard');
321
- } else if (type === 'deleteContentBackward') {
322
- event.preventDefault();
323
- session.deleteBackward();
324
- } else if (type === 'deleteContentForward') {
325
- event.preventDefault();
326
- session.deleteForward();
327
- } else if (type === 'insertFromPaste' || type === 'insertFromDrop') {
328
- event.preventDefault();
329
- const html = input.dataTransfer?.getData('text/html') || null;
330
- const text = input.dataTransfer?.getData('text/plain') || input.data || null;
331
- session.paste(html, text);
332
- } else if (type === 'historyUndo') {
333
- event.preventDefault();
334
- session.undo();
335
- } else if (type === 'historyRedo') {
336
- event.preventDefault();
337
- session.redo();
338
- } else if (type === 'formatBold') {
339
- event.preventDefault();
340
- session.toggleMark('bold');
341
- } else if (type === 'formatItalic') {
342
- event.preventDefault();
343
- session.toggleMark('italic');
344
- } else if (type === 'formatUnderline') {
345
- event.preventDefault();
346
- session.toggleMark('underline');
347
- } else if (type === 'formatStrikeThrough') {
348
- event.preventDefault();
349
- session.toggleMark('strike');
350
- } else if (type.startsWith('delete')) {
351
- event.preventDefault();
352
- session.deleteBackward();
353
- }
354
- });
355
-
356
- docEl.addEventListener('input', () => {
357
- if (composing || silent > 0) return;
358
- reconcileDom();
359
- });
360
-
361
- docEl.addEventListener('compositionstart', () => {
362
- composing = true;
363
- });
364
- docEl.addEventListener('compositionend', () => {
365
- composing = false;
366
- const sel = readSelection();
367
- if (sel) {
368
- const el = blockEl(sel.head.blockId);
369
- if (el && el.dataset['atomic'] !== 'true') session.reconcileText(sel.head.blockId, el.textContent ?? '');
370
- }
371
- if (pending) {
372
- pending = false;
373
- draw();
374
- }
375
- publish();
376
- });
377
-
378
- const onSelectionChange = () => {
379
- if (silent > 0 || composing || !focused()) return;
380
- const next = readSelection();
381
- if (!next || sameSelection(next, session.selection)) return;
382
- session.select(next);
383
- };
384
- document.addEventListener('selectionchange', onSelectionChange);
385
-
386
- docEl.addEventListener('keydown', (event) => {
387
- if (options.interceptKey?.(event)) return;
388
- if (readonly) return;
389
- const meta = event.metaKey || event.ctrlKey;
390
- const key = event.key.toLowerCase();
391
- if (meta && key === 'b') {
392
- event.preventDefault();
393
- session.toggleMark('bold');
394
- } else if (meta && key === 'i') {
395
- event.preventDefault();
396
- session.toggleMark('italic');
397
- } else if (meta && key === 'u') {
398
- event.preventDefault();
399
- session.toggleMark('underline');
400
- } else if (meta && key === 'e') {
401
- event.preventDefault();
402
- session.toggleMark('code');
403
- } else if (meta && event.shiftKey && (key === 's' || key === 'x')) {
404
- event.preventDefault();
405
- session.toggleMark('strike');
406
- } else if (meta && key === 'k') {
407
- event.preventDefault();
408
- options.onLink?.();
409
- } else if (meta && key === 'z') {
410
- event.preventDefault();
411
- if (event.shiftKey) session.redo();
412
- else session.undo();
413
- } else if (meta && key === 'y') {
414
- event.preventDefault();
415
- session.redo();
416
- } else if (event.key === 'Enter' && meta) {
417
- event.preventDefault();
418
- session.split('exit');
419
- } else if (event.key === 'Tab') {
420
- event.preventDefault();
421
- session.insertText(' ');
422
- }
423
- });
424
-
425
- docEl.addEventListener('mousedown', (event) => {
426
- const target = event.target instanceof Element ? event.target : null;
427
- const atomic = target?.closest<HTMLElement>('[data-atomic]');
428
- if (!atomic || !docEl.contains(atomic)) return;
429
- event.preventDefault();
430
- const id = atomic.dataset['id'];
431
- if (!id) return;
432
- docEl.focus();
433
- session.focusPoint(id, 0);
434
- draw();
435
- });
436
-
437
- docEl.addEventListener('click', (event) => {
438
- const target = event.target instanceof Element ? event.target : null;
439
- const link = target?.closest('a');
440
- if (!link || !docEl.contains(link)) return;
441
- if (!event.metaKey && !event.ctrlKey) event.preventDefault();
442
- });
443
-
444
- docEl.addEventListener('cut', (event) => {
445
- const sel = document.getSelection();
446
- if (!sel || sel.isCollapsed || !sel.anchorNode || !docEl.contains(sel.anchorNode)) return;
447
- event.preventDefault();
448
- const range = sel.getRangeAt(0);
449
- const holder = document.createElement('div');
450
- holder.append(range.cloneContents());
451
- event.clipboardData?.setData('text/plain', range.toString());
452
- event.clipboardData?.setData('text/html', holder.innerHTML);
453
- session.deleteBackward();
454
- });
455
-
456
- draw();
457
- publish();
458
-
459
- return {
460
- session,
461
- destroy() {
462
- destroyed = true;
463
- document.removeEventListener('selectionchange', onSelectionChange);
464
- unsession();
465
- frame.replaceChildren();
466
- },
467
- focus() {
468
- docEl.focus();
469
- },
470
- exec(command) {
471
- if (readonly && command.type !== 'undo' && command.type !== 'redo') return;
472
- if (command.type === 'mark') session.toggleMark(command.mark);
473
- else if (command.type === 'link') session.setLink(command.href);
474
- else if (command.type === 'clear') session.clearMarks();
475
- else if (command.type === 'block') session.setBlockType(command.block, command.level ?? 1);
476
- else if (command.type === 'divider') session.insertDivider();
477
- else if (command.type === 'image') session.insertImage(command.src, command.alt ?? '');
478
- else if (command.type === 'undo') session.undo();
479
- else if (command.type === 'redo') session.redo();
480
- else if (command.type === 'slash') session.applySlash(command.choice);
481
- if (!readonly) docEl.focus();
482
- },
483
- setReadonly(value) {
484
- readonly = value;
485
- applyEditable();
486
- },
487
- setPlaceholder(value) {
488
- placeholder = value;
489
- draw();
490
- },
491
- setHtml(html) {
492
- if (html === session.html) return;
493
- session.replaceHtml(html);
494
- },
495
- getHtml() {
496
- return session.html;
497
- },
498
- };
499
- }