overtype 1.2.7 → 2.0.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/src/toolbar.js CHANGED
@@ -1,351 +1,286 @@
1
1
  /**
2
2
  * Toolbar component for OverType editor
3
- * Provides markdown formatting buttons with icons
3
+ * Provides markdown formatting buttons with support for custom buttons
4
4
  */
5
5
 
6
- import * as icons from './icons.js';
7
6
  import * as markdownActions from 'markdown-actions';
8
7
 
9
8
  export class Toolbar {
10
- constructor(editor, buttonConfig = null) {
9
+ constructor(editor, options = {}) {
11
10
  this.editor = editor;
12
11
  this.container = null;
13
12
  this.buttons = {};
14
- this.buttonConfig = buttonConfig;
15
- }
16
13
 
14
+ // Get toolbar buttons array
15
+ this.toolbarButtons = options.toolbarButtons || [];
16
+ }
17
17
 
18
18
  /**
19
- * Create and attach toolbar to editor
19
+ * Create and render toolbar
20
20
  */
21
21
  create() {
22
- // Create toolbar container
23
22
  this.container = document.createElement('div');
24
23
  this.container.className = 'overtype-toolbar';
25
24
  this.container.setAttribute('role', 'toolbar');
26
- this.container.setAttribute('aria-label', 'Text formatting');
27
-
28
- // Define toolbar buttons
29
- const buttonConfig = this.buttonConfig ?? [
30
- { name: 'bold', icon: icons.boldIcon, title: 'Bold (Ctrl+B)', action: 'toggleBold' },
31
- { name: 'italic', icon: icons.italicIcon, title: 'Italic (Ctrl+I)', action: 'toggleItalic' },
32
- { separator: true },
33
- { name: 'h1', icon: icons.h1Icon, title: 'Heading 1', action: 'insertH1' },
34
- { name: 'h2', icon: icons.h2Icon, title: 'Heading 2', action: 'insertH2' },
35
- { name: 'h3', icon: icons.h3Icon, title: 'Heading 3', action: 'insertH3' },
36
- { separator: true },
37
- { name: 'link', icon: icons.linkIcon, title: 'Insert Link (Ctrl+K)', action: 'insertLink' },
38
- { name: 'code', icon: icons.codeIcon, title: 'Code (Ctrl+`)', action: 'toggleCode' },
39
- { separator: true },
40
- { name: 'quote', icon: icons.quoteIcon, title: 'Quote', action: 'toggleQuote' },
41
- { separator: true },
42
- { name: 'bulletList', icon: icons.bulletListIcon, title: 'Bullet List', action: 'toggleBulletList' },
43
- { name: 'orderedList', icon: icons.orderedListIcon, title: 'Numbered List', action: 'toggleNumberedList' },
44
- { name: 'taskList', icon: icons.taskListIcon, title: 'Task List', action: 'toggleTaskList' },
45
- { separator: true },
46
- { name: 'viewMode', icon: icons.eyeIcon, title: 'View mode', action: 'toggle-view-menu', hasDropdown: true }
47
- ];
25
+ this.container.setAttribute('aria-label', 'Formatting toolbar');
48
26
 
49
- // Create buttons
50
- buttonConfig.forEach(config => {
51
- if (config.separator) {
52
- const separator = document.createElement('div');
53
- separator.className = 'overtype-toolbar-separator';
54
- separator.setAttribute('role', 'separator');
27
+ // Create buttons from toolbarButtons array
28
+ this.toolbarButtons.forEach(buttonConfig => {
29
+ if (buttonConfig.name === 'separator') {
30
+ const separator = this.createSeparator();
55
31
  this.container.appendChild(separator);
56
32
  } else {
57
- const button = this.createButton(config);
58
- this.buttons[config.name] = button;
33
+ const button = this.createButton(buttonConfig);
34
+ this.buttons[buttonConfig.name] = button;
59
35
  this.container.appendChild(button);
60
36
  }
61
37
  });
62
38
 
63
- // Insert toolbar into container before editor wrapper
64
- const container = this.editor.element.querySelector('.overtype-container');
65
- const wrapper = this.editor.element.querySelector('.overtype-wrapper');
66
- if (container && wrapper) {
67
- container.insertBefore(this.container, wrapper);
68
- }
39
+ this.editor.wrapper.insertBefore(this.container, this.editor.wrapper.firstChild);
40
+ }
69
41
 
70
- return this.container;
42
+ /**
43
+ * Create a toolbar separator
44
+ */
45
+ createSeparator() {
46
+ const separator = document.createElement('div');
47
+ separator.className = 'overtype-toolbar-separator';
48
+ separator.setAttribute('role', 'separator');
49
+ return separator;
71
50
  }
72
51
 
73
52
  /**
74
- * Create individual toolbar button
53
+ * Create a toolbar button
75
54
  */
76
- createButton(config) {
55
+ createButton(buttonConfig) {
77
56
  const button = document.createElement('button');
78
57
  button.className = 'overtype-toolbar-button';
79
58
  button.type = 'button';
80
- button.title = config.title;
81
- button.setAttribute('aria-label', config.title);
82
- button.setAttribute('data-action', config.action);
83
- button.innerHTML = config.icon;
59
+ button.setAttribute('data-button', buttonConfig.name);
60
+ button.title = buttonConfig.title || '';
61
+ button.setAttribute('aria-label', buttonConfig.title || buttonConfig.name);
62
+ button.innerHTML = this.sanitizeSVG(buttonConfig.icon || '');
84
63
 
85
- // Add dropdown if needed
86
- if (config.hasDropdown) {
64
+ // Special handling for viewMode dropdown
65
+ if (buttonConfig.name === 'viewMode') {
87
66
  button.classList.add('has-dropdown');
88
- // Store reference for dropdown
89
- if (config.name === 'viewMode') {
90
- this.viewModeButton = button;
91
- }
67
+ button.dataset.dropdown = 'true';
68
+ button.addEventListener('click', (e) => {
69
+ e.preventDefault();
70
+ this.toggleViewModeDropdown(button);
71
+ });
72
+ return button;
92
73
  }
93
74
 
94
- // Add click handler
95
- button.addEventListener('click', (e) => {
75
+ // Standard button click handler
76
+ button._clickHandler = async (e) => {
96
77
  e.preventDefault();
97
- this.handleAction(config.action, button);
98
- });
99
-
100
- return button;
101
- }
102
78
 
103
- /**
104
- * Handle toolbar button actions
105
- */
106
- async handleAction(action, button) {
107
- const textarea = this.editor.textarea;
108
- if (!textarea) return;
79
+ // Focus textarea before action
80
+ this.editor.textarea.focus();
109
81
 
110
- // Handle dropdown toggle
111
- if (action === 'toggle-view-menu') {
112
- this.toggleViewDropdown(button);
113
- return;
114
- }
82
+ try {
83
+ if (buttonConfig.action) {
84
+ // Call action with consistent context object
85
+ await buttonConfig.action({
86
+ editor: this.editor,
87
+ getValue: () => this.editor.getValue(),
88
+ setValue: (value) => this.editor.setValue(value),
89
+ event: e
90
+ });
91
+ }
92
+ } catch (error) {
93
+ console.error(`Button "${buttonConfig.name}" error:`, error);
115
94
 
116
- // Focus textarea for other actions
117
- textarea.focus();
95
+ // Dispatch error event
96
+ this.editor.wrapper.dispatchEvent(new CustomEvent('button-error', {
97
+ detail: { buttonName: buttonConfig.name, error }
98
+ }));
118
99
 
119
- try {
120
-
121
- switch (action) {
122
- case 'toggleBold':
123
- markdownActions.toggleBold(textarea);
124
- break;
125
- case 'toggleItalic':
126
- markdownActions.toggleItalic(textarea);
127
- break;
128
- case 'insertH1':
129
- markdownActions.toggleH1(textarea);
130
- break;
131
- case 'insertH2':
132
- markdownActions.toggleH2(textarea);
133
- break;
134
- case 'insertH3':
135
- markdownActions.toggleH3(textarea);
136
- break;
137
- case 'insertLink':
138
- markdownActions.insertLink(textarea);
139
- break;
140
- case 'toggleCode':
141
- markdownActions.toggleCode(textarea);
142
- break;
143
- case 'toggleBulletList':
144
- markdownActions.toggleBulletList(textarea);
145
- break;
146
- case 'toggleNumberedList':
147
- markdownActions.toggleNumberedList(textarea);
148
- break;
149
- case 'toggleQuote':
150
- markdownActions.toggleQuote(textarea);
151
- break;
152
- case 'toggleTaskList':
153
- markdownActions.toggleTaskList(textarea);
154
- break;
155
- case 'toggle-plain':
156
- // Toggle between plain textarea and overlay mode
157
- const isPlain = this.editor.container.classList.contains('plain-mode');
158
- this.editor.showPlainTextarea(!isPlain);
159
- break;
100
+ // Visual feedback
101
+ button.classList.add('button-error');
102
+ button.style.animation = 'buttonError 0.3s';
103
+ setTimeout(() => {
104
+ button.classList.remove('button-error');
105
+ button.style.animation = '';
106
+ }, 300);
160
107
  }
108
+ };
161
109
 
162
- // Trigger input event to update preview
163
- textarea.dispatchEvent(new Event('input', { bubbles: true }));
164
- } catch (error) {
165
- console.error('Error loading markdown-actions:', error);
166
- }
110
+ button.addEventListener('click', button._clickHandler);
111
+ return button;
167
112
  }
168
113
 
169
114
  /**
170
- * Update toolbar button states based on current selection
115
+ * Sanitize SVG to prevent XSS
171
116
  */
172
- async updateButtonStates() {
173
- const textarea = this.editor.textarea;
174
- if (!textarea) return;
117
+ sanitizeSVG(svg) {
118
+ if (typeof svg !== 'string') return '';
175
119
 
176
- try {
177
- const activeFormats = markdownActions.getActiveFormats(textarea);
120
+ // Remove script tags and on* event handlers
121
+ const cleaned = svg
122
+ .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
123
+ .replace(/\son\w+\s*=\s*["'][^"']*["']/gi, '')
124
+ .replace(/\son\w+\s*=\s*[^\s>]*/gi, '');
178
125
 
179
- // Update button states
180
- Object.entries(this.buttons).forEach(([name, button]) => {
181
- let isActive = false;
182
-
183
- switch (name) {
184
- case 'bold':
185
- isActive = activeFormats.includes('bold');
186
- break;
187
- case 'italic':
188
- isActive = activeFormats.includes('italic');
189
- break;
190
- case 'code':
191
- // Disabled: code detection is unreliable in code blocks
192
- // isActive = activeFormats.includes('code');
193
- isActive = false;
194
- break;
195
- case 'bulletList':
196
- isActive = activeFormats.includes('bullet-list');
197
- break;
198
- case 'orderedList':
199
- isActive = activeFormats.includes('numbered-list');
200
- break;
201
- case 'quote':
202
- isActive = activeFormats.includes('quote');
203
- break;
204
- case 'taskList':
205
- isActive = activeFormats.includes('task-list');
206
- break;
207
- case 'h1':
208
- isActive = activeFormats.includes('header');
209
- break;
210
- case 'h2':
211
- isActive = activeFormats.includes('header-2');
212
- break;
213
- case 'h3':
214
- isActive = activeFormats.includes('header-3');
215
- break;
216
- case 'togglePlain':
217
- // Button is active when in overlay mode (not plain mode)
218
- isActive = !this.editor.container.classList.contains('plain-mode');
219
- break;
220
- }
221
-
222
- button.classList.toggle('active', isActive);
223
- button.setAttribute('aria-pressed', isActive.toString());
224
- });
225
- } catch (error) {
226
- // Silently fail if markdown-actions not available
227
- }
126
+ return cleaned;
228
127
  }
229
128
 
230
129
  /**
231
- * Toggle view mode dropdown menu
130
+ * Toggle view mode dropdown (internal implementation)
131
+ * Not exposed to users - viewMode button behavior is fixed
232
132
  */
233
- toggleViewDropdown(button) {
133
+ toggleViewModeDropdown(button) {
234
134
  // Close any existing dropdown
235
135
  const existingDropdown = document.querySelector('.overtype-dropdown-menu');
236
136
  if (existingDropdown) {
237
137
  existingDropdown.remove();
238
138
  button.classList.remove('dropdown-active');
239
- document.removeEventListener('click', this.handleDocumentClick);
240
139
  return;
241
140
  }
242
141
 
243
- // Create dropdown menu
244
- const dropdown = this.createViewDropdown();
245
-
142
+ button.classList.add('dropdown-active');
143
+
144
+ const dropdown = this.createViewModeDropdown(button);
145
+
246
146
  // Position dropdown relative to button
247
147
  const rect = button.getBoundingClientRect();
248
- dropdown.style.top = `${rect.bottom + 4}px`;
148
+ dropdown.style.position = 'absolute';
149
+ dropdown.style.top = `${rect.bottom + 5}px`;
249
150
  dropdown.style.left = `${rect.left}px`;
250
-
251
- // Append to body instead of button
151
+
252
152
  document.body.appendChild(dropdown);
253
- button.classList.add('dropdown-active');
254
-
255
- // Store reference for document click handler
153
+
154
+ // Click outside to close
256
155
  this.handleDocumentClick = (e) => {
257
- if (!button.contains(e.target) && !dropdown.contains(e.target)) {
156
+ if (!dropdown.contains(e.target) && !button.contains(e.target)) {
258
157
  dropdown.remove();
259
158
  button.classList.remove('dropdown-active');
260
159
  document.removeEventListener('click', this.handleDocumentClick);
261
160
  }
262
161
  };
263
-
264
- // Close on click outside
162
+
265
163
  setTimeout(() => {
266
164
  document.addEventListener('click', this.handleDocumentClick);
267
165
  }, 0);
268
166
  }
269
167
 
270
168
  /**
271
- * Create view mode dropdown menu
169
+ * Create view mode dropdown menu (internal implementation)
272
170
  */
273
- createViewDropdown() {
171
+ createViewModeDropdown(button) {
274
172
  const dropdown = document.createElement('div');
275
173
  dropdown.className = 'overtype-dropdown-menu';
276
-
277
- // Determine current mode
278
- const isPlain = this.editor.container.classList.contains('plain-mode');
279
- const isPreview = this.editor.container.classList.contains('preview-mode');
280
- const currentMode = isPreview ? 'preview' : (isPlain ? 'plain' : 'normal');
281
-
282
- // Create menu items
283
- const modes = [
174
+
175
+ const items = [
284
176
  { id: 'normal', label: 'Normal Edit', icon: '✓' },
285
177
  { id: 'plain', label: 'Plain Textarea', icon: '✓' },
286
178
  { id: 'preview', label: 'Preview Mode', icon: '✓' }
287
179
  ];
288
-
289
- modes.forEach(mode => {
290
- const item = document.createElement('button');
291
- item.className = 'overtype-dropdown-item';
292
- item.type = 'button';
293
-
294
- const check = document.createElement('span');
295
- check.className = 'overtype-dropdown-check';
296
- check.textContent = currentMode === mode.id ? mode.icon : '';
297
-
298
- const label = document.createElement('span');
299
- label.textContent = mode.label;
300
-
301
- item.appendChild(check);
302
- item.appendChild(label);
303
-
304
- if (currentMode === mode.id) {
305
- item.classList.add('active');
180
+
181
+ const currentMode = this.editor.container.dataset.mode || 'normal';
182
+
183
+ items.forEach(item => {
184
+ const menuItem = document.createElement('button');
185
+ menuItem.className = 'overtype-dropdown-item';
186
+ menuItem.type = 'button';
187
+ menuItem.textContent = item.label;
188
+
189
+ if (item.id === currentMode) {
190
+ menuItem.classList.add('active');
191
+ menuItem.setAttribute('aria-current', 'true');
192
+ const checkmark = document.createElement('span');
193
+ checkmark.className = 'overtype-dropdown-icon';
194
+ checkmark.textContent = item.icon;
195
+ menuItem.prepend(checkmark);
306
196
  }
307
-
308
- item.addEventListener('click', (e) => {
309
- e.stopPropagation();
310
- this.setViewMode(mode.id);
197
+
198
+ menuItem.addEventListener('click', (e) => {
199
+ e.preventDefault();
200
+
201
+ // Handle view mode changes
202
+ switch(item.id) {
203
+ case 'plain':
204
+ this.editor.showPlainTextarea();
205
+ break;
206
+ case 'preview':
207
+ this.editor.showPreviewMode();
208
+ break;
209
+ case 'normal':
210
+ default:
211
+ this.editor.showNormalEditMode();
212
+ break;
213
+ }
214
+
311
215
  dropdown.remove();
312
- this.viewModeButton.classList.remove('dropdown-active');
216
+ button.classList.remove('dropdown-active');
313
217
  document.removeEventListener('click', this.handleDocumentClick);
314
218
  });
315
-
316
- dropdown.appendChild(item);
219
+
220
+ dropdown.appendChild(menuItem);
317
221
  });
318
-
222
+
319
223
  return dropdown;
320
224
  }
321
225
 
322
226
  /**
323
- * Set view mode
227
+ * Update active states of toolbar buttons
324
228
  */
325
- setViewMode(mode) {
326
- // Clear all mode classes
327
- this.editor.container.classList.remove('plain-mode', 'preview-mode');
328
-
329
- switch(mode) {
330
- case 'plain':
331
- this.editor.showPlainTextarea(true);
332
- break;
333
- case 'preview':
334
- this.editor.showPreviewMode(true);
335
- break;
336
- case 'normal':
337
- default:
338
- // Normal edit mode
339
- this.editor.showPlainTextarea(false);
340
- if (typeof this.editor.showPreviewMode === 'function') {
341
- this.editor.showPreviewMode(false);
229
+ updateButtonStates() {
230
+ try {
231
+ const activeFormats = markdownActions.getActiveFormats?.(
232
+ this.editor.textarea,
233
+ this.editor.textarea.selectionStart
234
+ ) || [];
235
+
236
+ Object.entries(this.buttons).forEach(([name, button]) => {
237
+ if (name === 'viewMode') return; // Skip dropdown button
238
+
239
+ let isActive = false;
240
+
241
+ switch(name) {
242
+ case 'bold':
243
+ isActive = activeFormats.includes('bold');
244
+ break;
245
+ case 'italic':
246
+ isActive = activeFormats.includes('italic');
247
+ break;
248
+ case 'code':
249
+ isActive = false; // Disabled: unreliable in code blocks
250
+ break;
251
+ case 'bulletList':
252
+ isActive = activeFormats.includes('bullet-list');
253
+ break;
254
+ case 'orderedList':
255
+ isActive = activeFormats.includes('numbered-list');
256
+ break;
257
+ case 'taskList':
258
+ isActive = activeFormats.includes('task-list');
259
+ break;
260
+ case 'quote':
261
+ isActive = activeFormats.includes('quote');
262
+ break;
263
+ case 'h1':
264
+ isActive = activeFormats.includes('header');
265
+ break;
266
+ case 'h2':
267
+ isActive = activeFormats.includes('header-2');
268
+ break;
269
+ case 'h3':
270
+ isActive = activeFormats.includes('header-3');
271
+ break;
342
272
  }
343
- break;
273
+
274
+ button.classList.toggle('active', isActive);
275
+ button.setAttribute('aria-pressed', isActive.toString());
276
+ });
277
+ } catch (error) {
278
+ // Silently fail if markdown-actions not available
344
279
  }
345
280
  }
346
281
 
347
282
  /**
348
- * Destroy toolbar
283
+ * Destroy toolbar and cleanup
349
284
  */
350
285
  destroy() {
351
286
  if (this.container) {
@@ -353,9 +288,19 @@ export class Toolbar {
353
288
  if (this.handleDocumentClick) {
354
289
  document.removeEventListener('click', this.handleDocumentClick);
355
290
  }
291
+
292
+ // Clean up button listeners
293
+ Object.values(this.buttons).forEach(button => {
294
+ if (button._clickHandler) {
295
+ button.removeEventListener('click', button._clickHandler);
296
+ delete button._clickHandler;
297
+ }
298
+ });
299
+
300
+ // Remove container
356
301
  this.container.remove();
357
302
  this.container = null;
358
303
  this.buttons = {};
359
304
  }
360
305
  }
361
- }
306
+ }
package/diagram.png DELETED
Binary file