autumnnote 1.0.8 → 1.1.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.
Files changed (41) hide show
  1. package/README.md +401 -628
  2. package/dist/autumnnote.css +169 -0
  3. package/dist/autumnnote.es.js +3669 -179
  4. package/dist/autumnnote.es.js.map +1 -1
  5. package/dist/autumnnote.umd.js +3668 -178
  6. package/dist/autumnnote.umd.js.map +1 -1
  7. package/package.json +3 -2
  8. package/src/js/Context.js +18 -2
  9. package/src/js/core/func.js +5 -5
  10. package/src/js/i18n/de.js +320 -0
  11. package/src/js/i18n/en.js +328 -0
  12. package/src/js/i18n/es.js +320 -0
  13. package/src/js/i18n/fr.js +321 -0
  14. package/src/js/i18n/index.js +59 -0
  15. package/src/js/i18n/ja.js +321 -0
  16. package/src/js/i18n/ko.js +320 -0
  17. package/src/js/i18n/vi.js +321 -0
  18. package/src/js/i18n/zh.js +321 -0
  19. package/src/js/index.js +2 -1
  20. package/src/js/module/AutoSaveRestore.js +126 -0
  21. package/src/js/module/BubbleToolbar.js +243 -0
  22. package/src/js/module/CodeTooltip.js +12 -9
  23. package/src/js/module/ContextMenu.js +41 -37
  24. package/src/js/module/EmojiDialog.js +8 -7
  25. package/src/js/module/FindReplace.js +14 -13
  26. package/src/js/module/IconDialog.js +14 -13
  27. package/src/js/module/ImageDialog.js +17 -16
  28. package/src/js/module/ImageTooltip.js +13 -12
  29. package/src/js/module/LinkDialog.js +10 -9
  30. package/src/js/module/LinkTooltip.js +6 -5
  31. package/src/js/module/MarkdownShortcuts.js +253 -0
  32. package/src/js/module/Mention.js +337 -0
  33. package/src/js/module/ShortcutsDialog.js +5 -4
  34. package/src/js/module/Statusbar.js +6 -5
  35. package/src/js/module/TableTooltip.js +22 -19
  36. package/src/js/module/Toolbar.js +21 -15
  37. package/src/js/module/VideoDialog.js +10 -9
  38. package/src/js/module/VideoTooltip.js +12 -11
  39. package/src/js/settings.js +23 -0
  40. package/src/styles/autumnnote.scss +191 -0
  41. package/types/index.d.ts +114 -1
@@ -332,11 +332,12 @@ export class IconDialog {
332
332
  // ---------------------------------------------------------------------------
333
333
 
334
334
  _buildDialog() {
335
+ const L = this.context.locale.iconDialog;
335
336
  const overlay = createElement('div', {
336
337
  class: 'an-dialog-overlay',
337
338
  role: 'dialog',
338
339
  'aria-modal': 'true',
339
- 'aria-label': 'Insert FA icon',
340
+ 'aria-label': L.ariaLabel,
340
341
  });
341
342
 
342
343
  const box = createElement('div', { class: 'an-dialog-box an-icon-box' });
@@ -344,8 +345,8 @@ export class IconDialog {
344
345
  // Title row
345
346
  const titleRow = createElement('div', { class: 'an-icon-title-row' });
346
347
  const title = createElement('h3', { class: 'an-dialog-title' });
347
- title.textContent = 'Insert FA Icon';
348
- const closeBtn = createElement('button', { type: 'button', class: 'an-icon-close', 'aria-label': 'Close' });
348
+ title.textContent = L.title;
349
+ const closeBtn = createElement('button', { type: 'button', class: 'an-icon-close', 'aria-label': L.close });
349
350
  closeBtn.innerHTML = '×';
350
351
  titleRow.append(title, closeBtn);
351
352
 
@@ -353,7 +354,7 @@ export class IconDialog {
353
354
  const searchInput = createElement('input', {
354
355
  type: 'search',
355
356
  class: 'an-input an-icon-search',
356
- placeholder: 'Search icons…',
357
+ placeholder: L.searchPlaceholder,
357
358
  autocomplete: 'off',
358
359
  });
359
360
  this._searchInput = searchInput;
@@ -361,11 +362,11 @@ export class IconDialog {
361
362
  // Category tabs
362
363
  const catBar = createElement('div', { class: 'an-icon-cats' });
363
364
  const allTab = createElement('button', { type: 'button', class: 'an-icon-cat active', 'data-cat': 'all' });
364
- allTab.textContent = 'All';
365
+ allTab.textContent = L.all;
365
366
  catBar.appendChild(allTab);
366
367
  ICON_CATEGORIES.forEach(({ id, label }) => {
367
368
  const tab = createElement('button', { type: 'button', class: 'an-icon-cat', 'data-cat': id });
368
- tab.textContent = label;
369
+ tab.textContent = (L.categories && L.categories[id]) || label;
369
370
  catBar.appendChild(tab);
370
371
  });
371
372
  this._catBar = catBar;
@@ -386,7 +387,7 @@ export class IconDialog {
386
387
  const optRow = createElement('div', { class: 'an-icon-options' });
387
388
 
388
389
  const styleLabel = createElement('label', { class: 'an-label' });
389
- styleLabel.textContent = 'Style';
390
+ styleLabel.textContent = L.style;
390
391
  const styleSelect = createElement('select', { class: 'an-input an-icon-option-select' });
391
392
  [['fa-solid', 'Solid'], ['fa-regular', 'Regular'], ['fa-light', 'Light (Pro)']].forEach(([v, t]) => {
392
393
  const opt = createElement('option', { value: v });
@@ -397,7 +398,7 @@ export class IconDialog {
397
398
  this._styleSelect = styleSelect;
398
399
 
399
400
  const sizeLabel = createElement('label', { class: 'an-label' });
400
- sizeLabel.textContent = 'Size';
401
+ sizeLabel.textContent = L.size;
401
402
  const sizeSelect = createElement('select', { class: 'an-input an-icon-option-select' });
402
403
  [['', 'Inherit'], ['0.75em', '0.75em'], ['1em', '1em'], ['1.25em', '1.25em'], ['1.5em', '1.5em'], ['2em', '2em'], ['3em', '3em']].forEach(([v, t]) => {
403
404
  const opt = createElement('option', { value: v });
@@ -408,30 +409,30 @@ export class IconDialog {
408
409
  this._sizeSelect = sizeSelect;
409
410
 
410
411
  const colorLabel = createElement('label', { class: 'an-label' });
411
- colorLabel.textContent = 'Color';
412
+ colorLabel.textContent = L.color;
412
413
  const colorInput = createElement('input', { type: 'color', class: 'an-icon-color', value: '#000000' });
413
414
  this._colorInput = colorInput;
414
415
 
415
416
  const useColorLabel = createElement('label', { class: 'an-label an-label-inline an-icon-use-color' });
416
417
  const useColorCb = createElement('input', { type: 'checkbox', checked: '' });
417
418
  this._useColorCb = useColorCb;
418
- useColorLabel.append(useColorCb, document.createTextNode(' Use color'));
419
+ useColorLabel.append(useColorCb, document.createTextNode(L.useColor));
419
420
 
420
421
  optRow.append(styleLabel, styleSelect, sizeLabel, sizeSelect, colorLabel, colorInput, useColorLabel);
421
422
 
422
423
  // Preview
423
424
  const preview = createElement('div', { class: 'an-icon-preview' });
424
425
  const previewHint = createElement('span', { class: 'an-icon-preview-hint' });
425
- previewHint.textContent = 'Select an icon';
426
+ previewHint.textContent = L.selectHint;
426
427
  preview.appendChild(previewHint);
427
428
  this._preview = preview;
428
429
 
429
430
  // Actions
430
431
  const btnRow = createElement('div', { class: 'an-dialog-actions' });
431
432
  const insertBtn = createElement('button', { type: 'button', class: 'an-btn an-btn-primary', disabled: '' });
432
- insertBtn.textContent = 'Insert FA Icon';
433
+ insertBtn.textContent = L.insertBtn;
433
434
  const cancelBtn = createElement('button', { type: 'button', class: 'an-btn' });
434
- cancelBtn.textContent = 'Cancel';
435
+ cancelBtn.textContent = L.cancelBtn;
435
436
  btnRow.append(insertBtn, cancelBtn);
436
437
  this._insertBtn = insertBtn;
437
438
 
@@ -57,35 +57,36 @@ export class ImageDialog {
57
57
  // ---------------------------------------------------------------------------
58
58
 
59
59
  _buildDialog() {
60
+ const L = this.context.locale.imageDialog;
60
61
  const overlay = createElement('div', {
61
62
  class: 'an-dialog-overlay',
62
63
  role: 'dialog',
63
64
  'aria-modal': 'true',
64
- 'aria-label': 'Insert image',
65
+ 'aria-label': L.ariaLabel,
65
66
  });
66
67
  const box = createElement('div', { class: 'an-dialog-box' });
67
68
 
68
69
  const title = createElement('h3', { class: 'an-dialog-title' });
69
- title.textContent = 'Insert Image';
70
+ title.textContent = L.title;
70
71
 
71
72
  // URL tab
72
73
  const urlLabel = createElement('label', { class: 'an-label' });
73
- urlLabel.textContent = 'Image URL';
74
+ urlLabel.textContent = L.imageUrl;
74
75
  const urlInput = createElement('input', {
75
76
  type: 'url',
76
77
  class: 'an-input',
77
- placeholder: 'https://example.com/image.png',
78
+ placeholder: L.urlPlaceholder,
78
79
  autocomplete: 'off',
79
80
  });
80
81
  this._urlInput = urlInput;
81
82
 
82
83
  // Alt text
83
84
  const altLabel = createElement('label', { class: 'an-label' });
84
- altLabel.textContent = 'Alt Text';
85
+ altLabel.textContent = L.altText;
85
86
  const altInput = createElement('input', {
86
87
  type: 'text',
87
88
  class: 'an-input',
88
- placeholder: 'Describe the image',
89
+ placeholder: L.altPlaceholder,
89
90
  autocomplete: 'off',
90
91
  });
91
92
  this._altInput = altInput;
@@ -94,13 +95,13 @@ export class ImageDialog {
94
95
 
95
96
  // Alignment
96
97
  const alignLabel = createElement('label', { class: 'an-label' });
97
- alignLabel.textContent = 'Alignment';
98
+ alignLabel.textContent = L.alignment;
98
99
  const alignRow = createElement('div', { class: 'an-align-row' });
99
100
  const alignOptions = [
100
- { value: '', label: 'None' },
101
- { value: 'left', label: 'Left' },
102
- { value: 'center', label: 'Center' },
103
- { value: 'right', label: 'Right' },
101
+ { value: '', label: L.alignNone },
102
+ { value: 'left', label: L.alignLeft },
103
+ { value: 'center', label: L.alignCenter },
104
+ { value: 'right', label: L.alignRight },
104
105
  ];
105
106
  alignOptions.forEach(({ value, label }) => {
106
107
  const radioId = `an-align-${value || 'none'}`;
@@ -114,7 +115,7 @@ export class ImageDialog {
114
115
  box.append(alignLabel, alignRow);
115
116
  if (this.options.allowImageUpload !== false) {
116
117
  const fileLabel = createElement('label', { class: 'an-label' });
117
- fileLabel.textContent = 'Or upload a file';
118
+ fileLabel.textContent = L.uploadLabel;
118
119
  const fileInput = createElement('input', {
119
120
  type: 'file',
120
121
  class: 'an-input',
@@ -132,9 +133,9 @@ export class ImageDialog {
132
133
  // Buttons
133
134
  const btnRow = createElement('div', { class: 'an-dialog-actions' });
134
135
  const insertBtn = createElement('button', { type: 'button', class: 'an-btn an-btn-primary' });
135
- insertBtn.textContent = 'Insert';
136
+ insertBtn.textContent = L.insertBtn;
136
137
  const cancelBtn = createElement('button', { type: 'button', class: 'an-btn' });
137
- cancelBtn.textContent = 'Cancel';
138
+ cancelBtn.textContent = L.cancelBtn;
138
139
  btnRow.appendChild(insertBtn);
139
140
  btnRow.appendChild(cancelBtn);
140
141
 
@@ -166,7 +167,7 @@ export class ImageDialog {
166
167
  'image/webp', 'image/svg+xml', 'image/avif',
167
168
  ]);
168
169
  if (!SUPPORTED.has(file.type)) {
169
- const message = `Format "${file.type}" is not supported for display in web browsers. Please convert to JPEG, PNG, or WebP first.`;
170
+ const message = this.context.locale.errors.imageFormat(file.type);
170
171
  if (this._fileHint) this._fileHint.textContent = message;
171
172
  this.context.triggerEvent('imageError', { file, message });
172
173
  this._fileInput.value = '';
@@ -177,7 +178,7 @@ export class ImageDialog {
177
178
  // Validate file size (max 5 MB by default)
178
179
  const maxSize = (this.options.maxImageSize || 5) * 1024 * 1024;
179
180
  if (file.size > maxSize) {
180
- const message = `Image file is too large. Maximum allowed size is ${this.options.maxImageSize || 5} MB.`;
181
+ const message = this.context.locale.errors.imageSize(this.options.maxImageSize || 5);
181
182
  if (this._fileHint) this._fileHint.textContent = message;
182
183
  console.warn('[AutumnNote] ImageDialog:', message);
183
184
  this.context.triggerEvent('imageError', { file, message });
@@ -75,24 +75,25 @@ export class ImageTooltip {
75
75
  // ---------------------------------------------------------------------------
76
76
 
77
77
  _buildTooltip() {
78
+ const L = this.context.locale.tooltips.image;
78
79
  const el = createElement('div', {
79
80
  class: 'an-link-tooltip an-image-tooltip',
80
81
  role: 'toolbar',
81
- 'aria-label': 'Image actions',
82
+ 'aria-label': L.ariaLabel,
82
83
  });
83
84
  el.style.display = 'none';
84
85
 
85
86
  // Label showing "Image"
86
87
  this._label = createElement('span', { class: 'an-link-tooltip-url' });
87
- this._label.textContent = 'Image';
88
+ this._label.textContent = L.label;
88
89
  el.appendChild(this._label);
89
90
 
90
91
  el.appendChild(createElement('div', { class: 'an-link-tooltip-sep' }));
91
92
 
92
- this._floatLeftBtn = this._makeBtn(ICONS.floatLeft, 'Float Left', () => this._setFloat('left'));
93
- this._floatNoneBtn = this._makeBtn(ICONS.floatNone, 'No Float', () => this._setFloat(''));
94
- this._alignCenterBtn = this._makeBtn(ICONS.alignCenter, 'Align Center', () => this._setCenter());
95
- this._floatRightBtn = this._makeBtn(ICONS.floatRight, 'Float Right', () => this._setFloat('right'));
93
+ this._floatLeftBtn = this._makeBtn(ICONS.floatLeft, L.floatLeft, () => this._setFloat('left'));
94
+ this._floatNoneBtn = this._makeBtn(ICONS.floatNone, L.noFloat, () => this._setFloat(''));
95
+ this._alignCenterBtn = this._makeBtn(ICONS.alignCenter, L.alignCenter, () => this._setCenter());
96
+ this._floatRightBtn = this._makeBtn(ICONS.floatRight, L.floatRight, () => this._setFloat('right'));
96
97
 
97
98
  el.appendChild(this._floatLeftBtn);
98
99
  el.appendChild(this._floatNoneBtn);
@@ -101,28 +102,28 @@ export class ImageTooltip {
101
102
 
102
103
  el.appendChild(createElement('div', { class: 'an-link-tooltip-sep' }));
103
104
 
104
- this._originalBtn = this._makeBtn(ICONS.originalSize, 'Original Size', () => this._resetSize());
105
+ this._originalBtn = this._makeBtn(ICONS.originalSize, L.originalSize, () => this._resetSize());
105
106
 
106
107
  el.appendChild(this._originalBtn);
107
108
 
108
109
  el.appendChild(createElement('div', { class: 'an-link-tooltip-sep' }));
109
110
 
110
- el.appendChild(this._makeBtn(ICONS.rotateLeft, 'Rotate Left', () => this._rotate(-90)));
111
- el.appendChild(this._makeBtn(ICONS.rotateRight, 'Rotate Right', () => this._rotate(90)));
111
+ el.appendChild(this._makeBtn(ICONS.rotateLeft, L.rotateLeft, () => this._rotate(-90)));
112
+ el.appendChild(this._makeBtn(ICONS.rotateRight, L.rotateRight, () => this._rotate(90)));
112
113
 
113
114
  el.appendChild(createElement('div', { class: 'an-link-tooltip-sep' }));
114
115
 
115
- this._cropBtn = this._makeBtn(ICONS.crop, 'Crop Image', () => this._crop());
116
+ this._cropBtn = this._makeBtn(ICONS.crop, L.cropImage, () => this._crop());
116
117
  el.appendChild(this._cropBtn);
117
118
 
118
119
  el.appendChild(createElement('div', { class: 'an-link-tooltip-sep' }));
119
120
 
120
- this._captionBtn = this._makeBtn(ICONS.caption, 'Add / Edit Caption', () => this._toggleCaption());
121
+ this._captionBtn = this._makeBtn(ICONS.caption, L.addCaption, () => this._toggleCaption());
121
122
  el.appendChild(this._captionBtn);
122
123
 
123
124
  el.appendChild(createElement('div', { class: 'an-link-tooltip-sep' }));
124
125
 
125
- this._deleteBtn = this._makeBtn(ICONS.deleteImg, 'Delete Image', () => this._delete(), true);
126
+ this._deleteBtn = this._makeBtn(ICONS.deleteImg, L.deleteImage, () => this._delete(), true);
126
127
 
127
128
  el.appendChild(this._deleteBtn);
128
129
 
@@ -59,19 +59,20 @@ export class LinkDialog {
59
59
  // ---------------------------------------------------------------------------
60
60
 
61
61
  _buildDialog() {
62
- const overlay = createElement('div', { class: 'an-dialog-overlay', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Insert link' });
62
+ const L = this.context.locale.linkDialog;
63
+ const overlay = createElement('div', { class: 'an-dialog-overlay', role: 'dialog', 'aria-modal': 'true', 'aria-label': L.ariaLabel });
63
64
  const box = createElement('div', { class: 'an-dialog-box' });
64
65
 
65
66
  const title = createElement('h3', { class: 'an-dialog-title' });
66
- title.textContent = 'Insert Link';
67
+ title.textContent = L.title;
67
68
 
68
69
  // URL field
69
70
  const urlLabel = createElement('label', { class: 'an-label' });
70
- urlLabel.textContent = 'URL';
71
+ urlLabel.textContent = L.url;
71
72
  const urlInput = createElement('input', {
72
73
  type: 'url',
73
74
  class: 'an-input',
74
- placeholder: 'https://',
75
+ placeholder: L.urlPlaceholder,
75
76
  id: 'an-link-url',
76
77
  name: 'url',
77
78
  autocomplete: 'off',
@@ -80,11 +81,11 @@ export class LinkDialog {
80
81
 
81
82
  // Text field
82
83
  const textLabel = createElement('label', { class: 'an-label' });
83
- textLabel.textContent = 'Display Text';
84
+ textLabel.textContent = L.displayText;
84
85
  const textInput = createElement('input', {
85
86
  type: 'text',
86
87
  class: 'an-input',
87
- placeholder: 'Link text',
88
+ placeholder: L.textPlaceholder,
88
89
  id: 'an-link-text',
89
90
  name: 'linkText',
90
91
  autocomplete: 'off',
@@ -100,14 +101,14 @@ export class LinkDialog {
100
101
  });
101
102
  this._tabCheckbox = tabCheckbox;
102
103
  tabLabel.appendChild(tabCheckbox);
103
- tabLabel.appendChild(document.createTextNode(' Open in new tab'));
104
+ tabLabel.appendChild(document.createTextNode(' ' + L.openInNewTab));
104
105
 
105
106
  // Buttons
106
107
  const btnRow = createElement('div', { class: 'an-dialog-actions' });
107
108
  const insertBtn = createElement('button', { type: 'button', class: 'an-btn an-btn-primary' });
108
- insertBtn.textContent = 'Insert';
109
+ insertBtn.textContent = L.insertBtn;
109
110
  const cancelBtn = createElement('button', { type: 'button', class: 'an-btn' });
110
- cancelBtn.textContent = 'Cancel';
111
+ cancelBtn.textContent = L.cancelBtn;
111
112
  btnRow.appendChild(insertBtn);
112
113
  btnRow.appendChild(cancelBtn);
113
114
 
@@ -63,7 +63,8 @@ export class LinkTooltip {
63
63
  // ---------------------------------------------------------------------------
64
64
 
65
65
  _buildTooltip() {
66
- const el = createElement('div', { class: 'an-link-tooltip', role: 'toolbar', 'aria-label': 'Link actions' });
66
+ const L = this.context.locale.tooltips.link;
67
+ const el = createElement('div', { class: 'an-link-tooltip', role: 'toolbar', 'aria-label': L.ariaLabel });
67
68
  el.style.display = 'none';
68
69
 
69
70
  // URL preview text (truncated)
@@ -74,10 +75,10 @@ export class LinkTooltip {
74
75
  el.appendChild(createElement('div', { class: 'an-link-tooltip-sep' }));
75
76
 
76
77
  // Action buttons
77
- this._openBtn = this._makeBtn(ICONS.open, 'Open link', () => this._openLink());
78
- this._copyBtn = this._makeBtn(ICONS.copy, 'Copy URL', () => this._copyLink());
79
- this._editBtn = this._makeBtn(ICONS.edit, 'Edit link', () => this._editLink());
80
- this._unlinkBtn = this._makeBtn(ICONS.unlink, 'Remove link', () => this._unlink());
78
+ this._openBtn = this._makeBtn(ICONS.open, L.openLink, () => this._openLink());
79
+ this._copyBtn = this._makeBtn(ICONS.copy, L.copyUrl, () => this._copyLink());
80
+ this._editBtn = this._makeBtn(ICONS.edit, L.editLink, () => this._editLink());
81
+ this._unlinkBtn = this._makeBtn(ICONS.unlink, L.removeLink, () => this._unlink());
81
82
 
82
83
  el.appendChild(this._openBtn);
83
84
  el.appendChild(this._copyBtn);
@@ -0,0 +1,253 @@
1
+ /**
2
+ * MarkdownShortcuts.js — Convert Markdown-style syntax typed directly in the
3
+ * editor into rich HTML elements (input rules / auto-format).
4
+ *
5
+ * Activated when `markdownShortcuts: true` (the default).
6
+ *
7
+ * Block rules — triggered by Space or Enter at the start of a line:
8
+ * #[#[#]]· → H1 / H2 / H3
9
+ * >· → blockquote
10
+ * -· or *· → unordered list item
11
+ * 1.· → ordered list item
12
+ * [ ]· → checklist item
13
+ * --- → horizontal rule (on Enter)
14
+ * ``` → code block (on Enter)
15
+ *
16
+ * Inline rules — triggered when the closing marker is typed:
17
+ * **text** → <strong>
18
+ * *text* → <em>
19
+ * ~~text~~ → <s>
20
+ * `code` → <code>
21
+ */
22
+
23
+ import { on } from '../core/dom.js';
24
+
25
+ export class MarkdownShortcuts {
26
+ /** @param {import('../Context.js').Context} context */
27
+ constructor(context) {
28
+ this.context = context;
29
+ this.options = context.options;
30
+ this._disposers = [];
31
+ }
32
+
33
+ initialize() {
34
+ if (!this.options.markdownShortcuts) return this;
35
+ const editable = this.context.layoutInfo.editable;
36
+ const d1 = on(editable, 'keydown', /** @param {KeyboardEvent} e */ (e) => this._onKeydown(e));
37
+ const d2 = on(editable, 'input', () => this._onInput());
38
+ this._disposers.push(d1, d2);
39
+ return this;
40
+ }
41
+
42
+ destroy() {
43
+ this._disposers.forEach((d) => d());
44
+ this._disposers = [];
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Block rules (Space / Enter)
49
+ // ---------------------------------------------------------------------------
50
+
51
+ _onKeydown(e) {
52
+ if (e.key === ' ') {
53
+ if (this._applyBlockRule()) e.preventDefault();
54
+ } else if (e.key === 'Enter') {
55
+ if (this._applyEnterRule()) e.preventDefault();
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Returns the plain text of the line the cursor is on, up to the cursor.
61
+ * @returns {{ text: string, range: Range, lineNode: Node } | null}
62
+ */
63
+ _getLineContext() {
64
+ const sel = window.getSelection();
65
+ if (!sel || !sel.rangeCount) return null;
66
+ const range = sel.getRangeAt(0);
67
+ if (!range.collapsed) return null;
68
+
69
+ const editable = this.context.layoutInfo.editable;
70
+ if (!editable.contains(range.startContainer)) return null;
71
+
72
+ // Walk up to find the block-level ancestor inside the editable
73
+ let node = range.startContainer;
74
+ while (node && node !== editable && !this._isBlock(node)) {
75
+ node = node.parentNode;
76
+ }
77
+ if (!node || node === editable) node = range.startContainer;
78
+
79
+ // Collect all text content before the cursor within that block
80
+ const tmpRange = document.createRange();
81
+ tmpRange.setStart(node, 0);
82
+ tmpRange.setEnd(range.startContainer, range.startOffset);
83
+ const text = tmpRange.toString();
84
+
85
+ return { text, range, lineNode: node };
86
+ }
87
+
88
+ _isBlock(node) {
89
+ if (node.nodeType !== Node.ELEMENT_NODE) return false;
90
+ const display = window.getComputedStyle(node).display;
91
+ return display === 'block' || display === 'list-item' || display === 'table-cell';
92
+ }
93
+
94
+ /** Applies block rule on Space key. Returns true if a rule fired. */
95
+ _applyBlockRule() {
96
+ const ctx = this._getLineContext();
97
+ if (!ctx) return false;
98
+ const { text } = ctx;
99
+
100
+ const blockPatterns = [
101
+ { re: /^(#{1,3})$/, handler: (m) => this._convertToHeading(m[1].length) },
102
+ { re: /^>$/, handler: () => this._convertToBlockquote() },
103
+ { re: /^[-*]$/, handler: () => this._convertToList('ul') },
104
+ { re: /^1\.$/, handler: () => this._convertToList('ol') },
105
+ { re: /^\[ \]$/, handler: () => this._convertToChecklist() },
106
+ ];
107
+
108
+ for (const { re, handler } of blockPatterns) {
109
+ const m = text.match(re);
110
+ if (m) {
111
+ handler(m);
112
+ return true;
113
+ }
114
+ }
115
+ return false;
116
+ }
117
+
118
+ /** Applies block rule on Enter key (---, ```). Returns true if a rule fired. */
119
+ _applyEnterRule() {
120
+ const ctx = this._getLineContext();
121
+ if (!ctx) return false;
122
+ const { text } = ctx;
123
+
124
+ if (/^-{3,}$/.test(text)) {
125
+ this._convertToHr();
126
+ return true;
127
+ }
128
+ if (/^`{3}/.test(text)) {
129
+ this._convertToCodeBlock();
130
+ return true;
131
+ }
132
+ return false;
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Block converters
137
+ // ---------------------------------------------------------------------------
138
+
139
+ _selectLineAndDelete() {
140
+ const sel = window.getSelection();
141
+ if (!sel || !sel.rangeCount) return;
142
+ const range = sel.getRangeAt(0);
143
+ // Select from the start of the block to the cursor and delete
144
+ const startRange = document.createRange();
145
+ startRange.setStart(range.startContainer.parentNode || range.startContainer, 0);
146
+ startRange.setEnd(range.startContainer, range.startOffset);
147
+ startRange.deleteContents();
148
+ }
149
+
150
+ _convertToHeading(level) {
151
+ this._selectLineAndDelete();
152
+ document.execCommand('formatBlock', false, `h${level}`);
153
+ this.context.triggerEvent('change', this.context.getHTML());
154
+ }
155
+
156
+ _convertToBlockquote() {
157
+ this._selectLineAndDelete();
158
+ document.execCommand('formatBlock', false, 'blockquote');
159
+ this.context.triggerEvent('change', this.context.getHTML());
160
+ }
161
+
162
+ _convertToList(type) {
163
+ this._selectLineAndDelete();
164
+ document.execCommand(type === 'ul' ? 'insertUnorderedList' : 'insertOrderedList');
165
+ this.context.triggerEvent('change', this.context.getHTML());
166
+ }
167
+
168
+ _convertToChecklist() {
169
+ this._selectLineAndDelete();
170
+ this.context.invoke('editor.checklist');
171
+ this.context.triggerEvent('change', this.context.getHTML());
172
+ }
173
+
174
+ _convertToHr() {
175
+ this._selectLineAndDelete();
176
+ this.context.invoke('editor.insertHR');
177
+ this.context.triggerEvent('change', this.context.getHTML());
178
+ }
179
+
180
+ _convertToCodeBlock() {
181
+ this._selectLineAndDelete();
182
+ document.execCommand('formatBlock', false, 'pre');
183
+ this.context.triggerEvent('change', this.context.getHTML());
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Inline rules (input event)
188
+ // ---------------------------------------------------------------------------
189
+
190
+ _onInput() {
191
+ const sel = window.getSelection();
192
+ if (!sel || !sel.rangeCount) return;
193
+ const range = sel.getRangeAt(0);
194
+ if (!range.collapsed) return;
195
+
196
+ const editable = this.context.layoutInfo.editable;
197
+ if (!editable.contains(range.startContainer)) return;
198
+
199
+ const node = range.startContainer;
200
+ if (node.nodeType !== Node.TEXT_NODE) return;
201
+
202
+ const text = node.textContent;
203
+ const offset = range.startOffset;
204
+
205
+ const inlineRules = [
206
+ // **bold**
207
+ { re: /\*\*(.+?)\*\*$/, tag: 'strong' },
208
+ // *italic* (not part of **)
209
+ { re: /(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)$/, tag: 'em' },
210
+ // ~~strike~~
211
+ { re: /~~(.+?)~~$/, tag: 's' },
212
+ // `code`
213
+ { re: /`([^`]+)`$/, tag: 'code' },
214
+ ];
215
+
216
+ const upToCursor = text.slice(0, offset);
217
+ for (const { re, tag } of inlineRules) {
218
+ const m = upToCursor.match(re);
219
+ if (!m) continue;
220
+
221
+ const matchStart = upToCursor.length - m[0].length;
222
+ const matchEnd = offset;
223
+ const innerText = m[1];
224
+
225
+ // Replace matched text with formatted element
226
+ const before = text.slice(0, matchStart);
227
+ const after = text.slice(matchEnd);
228
+
229
+ const el = document.createElement(tag);
230
+ el.textContent = innerText;
231
+
232
+ // Rebuild the text node and insert the element
233
+ const beforeNode = document.createTextNode(before);
234
+ const afterNode = document.createTextNode('​' + after);
235
+
236
+ const parent = node.parentNode;
237
+ parent.insertBefore(beforeNode, node);
238
+ parent.insertBefore(el, node);
239
+ parent.insertBefore(afterNode, node);
240
+ parent.removeChild(node);
241
+
242
+ // Place cursor after the element (after the ZWS)
243
+ const newRange = document.createRange();
244
+ newRange.setStart(afterNode, 1);
245
+ newRange.collapse(true);
246
+ sel.removeAllRanges();
247
+ sel.addRange(newRange);
248
+
249
+ this.context.triggerEvent('change', this.context.getHTML());
250
+ break;
251
+ }
252
+ }
253
+ }