autumnnote 1.0.1 → 1.0.3

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.
@@ -5,6 +5,12 @@
5
5
 
6
6
  import { createElement, on } from '../core/dom.js';
7
7
 
8
+ // Module-level cache for FontAwesome detection.
9
+ // Evaluated once per page load so all Toolbar instances on the same page agree
10
+ // on whether the HOST PAGE included FA — regardless of whether IconDialog later
11
+ // auto-injects its own FA <link> for the icon-picker glyph rendering.
12
+ let _faPageLevelReady = null;
13
+
8
14
  export class Toolbar {
9
15
  /**
10
16
  * @param {import('../Context.js').Context} context
@@ -16,6 +22,8 @@ export class Toolbar {
16
22
  this.el = null;
17
23
  /** @type {Array<() => void>} disposers */
18
24
  this._disposers = [];
25
+ /** @type {Array<() => void>} closers for all open color picker popups */
26
+ this._colorPickerClosers = [];
19
27
  }
20
28
 
21
29
  // ---------------------------------------------------------------------------
@@ -253,9 +261,36 @@ export class Toolbar {
253
261
 
254
262
  // ---- State ----
255
263
  let isOpen = false;
264
+ /** @type {Range|null} saved selection range before popup opens */
265
+ let savedRange = null;
266
+
267
+ const saveSelection = () => {
268
+ const sel = window.getSelection();
269
+ savedRange = (sel && sel.rangeCount) ? sel.getRangeAt(0).cloneRange() : null;
270
+ };
271
+
272
+ const restoreSelection = () => {
273
+ if (!savedRange) return;
274
+ const sel = window.getSelection();
275
+ if (sel) {
276
+ sel.removeAllRanges();
277
+ sel.addRange(savedRange);
278
+ }
279
+ };
256
280
 
257
281
  const openPopup = () => {
282
+ // Close any other open color picker before opening this one
283
+ this._colorPickerClosers.forEach((fn) => { if (fn !== closePopup) fn(); });
284
+ saveSelection();
258
285
  isOpen = true;
286
+ // Use fixed positioning so the popup escapes any overflow-clipping ancestor
287
+ // (notably toolbar scroll mode, where overflow-x:auto coerces overflow-y)
288
+ const rect = arrowBtn.getBoundingClientRect();
289
+ const popupMinW = 184;
290
+ let left = rect.left;
291
+ if (left + popupMinW > window.innerWidth) left = rect.right - popupMinW;
292
+ popup.style.top = `${rect.bottom + 4}px`;
293
+ popup.style.left = `${Math.max(4, left)}px`;
259
294
  popup.style.display = 'block';
260
295
  arrowBtn.setAttribute('aria-expanded', 'true');
261
296
  };
@@ -263,6 +298,8 @@ export class Toolbar {
263
298
  const closePopup = () => {
264
299
  isOpen = false;
265
300
  popup.style.display = 'none';
301
+ popup.style.top = '';
302
+ popup.style.left = '';
266
303
  arrowBtn.setAttribute('aria-expanded', 'false');
267
304
  };
268
305
 
@@ -270,7 +307,7 @@ export class Toolbar {
270
307
  currentColor = color;
271
308
  strip.style.background = color;
272
309
  colorInput.value = color;
273
- this.context.invoke('editor.focus');
310
+ restoreSelection();
274
311
  def.action(this.context, color);
275
312
  this.context.invoke('editor.afterCommand');
276
313
  closePopup();
@@ -278,17 +315,27 @@ export class Toolbar {
278
315
 
279
316
  const d1 = on(applyBtn, 'click', (e) => {
280
317
  e.preventDefault();
281
- this.context.invoke('editor.focus');
318
+ restoreSelection();
282
319
  def.action(this.context, currentColor);
283
320
  this.context.invoke('editor.afterCommand');
284
321
  });
285
322
 
286
- const d2 = on(arrowBtn, 'click', (e) => {
323
+ const d2 = on(arrowBtn, 'mousedown', (e) => {
324
+ // Prevent editor blur so selection is preserved when the popup opens
325
+ e.preventDefault();
326
+ });
327
+
328
+ const d2b = on(arrowBtn, 'click', (e) => {
287
329
  e.stopPropagation();
288
330
  if (isOpen) closePopup(); else openPopup();
289
331
  });
290
332
 
291
- const d3 = on(swatches, 'click', (e) => {
333
+ const d3 = on(swatches, 'mousedown', (e) => {
334
+ // Prevent blur before the click handler fires
335
+ e.preventDefault();
336
+ });
337
+
338
+ const d3b = on(swatches, 'click', (e) => {
292
339
  const sw = e.target.closest('.an-color-swatch');
293
340
  if (sw) applyColor(sw.dataset.color);
294
341
  });
@@ -298,16 +345,38 @@ export class Toolbar {
298
345
  });
299
346
 
300
347
  const d5 = on(document, 'click', (e) => {
301
- if (isOpen && !wrap.contains(e.target)) closePopup();
348
+ // popup is in document.body, not inside wrap — check both
349
+ if (isOpen && !wrap.contains(e.target) && !popup.contains(e.target)) closePopup();
302
350
  });
303
351
 
304
352
  const d6 = on(popup, 'click', (e) => e.stopPropagation());
305
353
 
306
- this._disposers.push(d1, d2, d3, d4, d5, d6);
354
+ // Close the popup when the viewport scrolls or resizes so the fixed-position
355
+ // popup doesn't drift away from the button it belongs to.
356
+ const onScrollResize = () => { if (isOpen) closePopup(); };
357
+ document.addEventListener('scroll', onScrollResize, { passive: true, capture: true });
358
+ window.addEventListener('resize', onScrollResize, { passive: true });
359
+
360
+ this._disposers.push(d1, d2, d2b, d3, d3b, d4, d5, d6,
361
+ () => document.removeEventListener('scroll', onScrollResize, { capture: true }),
362
+ () => window.removeEventListener('resize', onScrollResize),
363
+ // Remove popup from body on editor destroy
364
+ () => { if (popup.parentNode) popup.parentNode.removeChild(popup); },
365
+ );
366
+
367
+ // Register this popup's closer so other color pickers can close it
368
+ this._colorPickerClosers.push(closePopup);
369
+ this._disposers.push(() => {
370
+ const idx = this._colorPickerClosers.indexOf(closePopup);
371
+ if (idx !== -1) this._colorPickerClosers.splice(idx, 1);
372
+ });
307
373
 
374
+ // Append popup to document.body so it escapes all overflow-clipping and
375
+ // contain:layout ancestors (contain:layout makes the container a fixed-pos
376
+ // containing block per the CSS Contain spec, breaking viewport coordinates).
308
377
  wrap.appendChild(applyBtn);
309
378
  wrap.appendChild(arrowBtn);
310
- wrap.appendChild(popup);
379
+ document.body.appendChild(popup);
311
380
  return wrap;
312
381
  }
313
382
 
@@ -329,22 +398,27 @@ export class Toolbar {
329
398
  'aria-label': def.tooltip || def.name,
330
399
  });
331
400
 
332
- // Blank "placeholder" option
401
+ // Blank "placeholder" option (non-selectable header)
333
402
  const placeholderText = def.placeholder || 'Font';
334
- const placeholder = createElement('option', { value: '' }, [placeholderText]);
403
+ const placeholder = createElement('option', { value: '', disabled: '', hidden: '' }, [placeholderText]);
335
404
  select.appendChild(placeholder);
336
405
 
337
406
  items.forEach((item) => {
338
- const value = (typeof item === 'object') ? item.value : item;
339
- const label = (typeof item === 'object') ? item.label : item;
340
- const opt = createElement('option', { value }, [label]);
341
- if (def.name === 'fontFamily') opt.style.fontFamily = value;
407
+ const value = (typeof item === 'object') ? item.value : item;
408
+ const label = (typeof item === 'object') ? item.label : item;
409
+ const isHeader = (typeof item === 'object') && !!item.disabled;
410
+ const attrs = { value };
411
+ if (isHeader) attrs.disabled = '';
412
+ const opt = createElement('option', attrs, [label]);
413
+ // Only apply fontFamily face preview on real (non-header) entries
414
+ if (def.name === 'fontFamily' && !isHeader) opt.style.fontFamily = value;
342
415
  select.appendChild(opt);
343
416
  });
344
417
 
345
418
  const disposer = on(select, 'change', (e) => {
346
419
  const value = e.target.value;
347
- if (!value) return;
420
+ const selectedOpt = e.target.options[e.target.selectedIndex];
421
+ if (!value || selectedOpt.disabled) return;
348
422
  this.context.invoke('editor.focus');
349
423
  def.action(this.context, value);
350
424
  this.context.invoke('editor.afterCommand');
@@ -502,9 +576,21 @@ export class Toolbar {
502
576
 
503
577
  _detectFontAwesome() {
504
578
  if (!this.options.useFontAwesome) return false;
505
- if (document.querySelector('.fa, .fas, .far, .fal, .fab, .fa-solid')) return true;
506
- const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]')).map((l) => l.href || '').join(' ');
507
- return /fontawesome|font-awesome|use\.fontawesome|all\.css/.test(links);
579
+ // Return cached result when available. This ensures that a later-initialised
580
+ // toolbar sees the same detection state as the first one — even if IconDialog
581
+ // has since injected its own FA <link> into <head> for the icon-picker UI.
582
+ if (_faPageLevelReady !== null) return _faPageLevelReady;
583
+ if (document.querySelector('.fa, .fas, .far, .fal, .fab, .fa-solid')) {
584
+ _faPageLevelReady = true;
585
+ return true;
586
+ }
587
+ // Exclude the editor-self-injected link (id='an-fontawesome-css') so it doesn't
588
+ // count as "the host page loaded FA" for toolbar icon rendering purposes.
589
+ const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]'))
590
+ .filter((l) => l.id !== 'an-fontawesome-css')
591
+ .map((l) => l.href || '').join(' ');
592
+ _faPageLevelReady = /fontawesome|font-awesome|use\.fontawesome|all\.css/.test(links);
593
+ return _faPageLevelReady;
508
594
  }
509
595
 
510
596
  // ---------------------------------------------------------------------------
@@ -219,7 +219,7 @@ export class VideoDialog {
219
219
  if (info && (info.type === 'YouTube' || info.type === 'YouTube Shorts' || info.type === 'Vimeo')) {
220
220
  const iframeTitle = `${info.type} video player`;
221
221
  return (
222
- `<div class="an-video-wrapper" style="position:relative;display:inline-block;max-width:100%">` +
222
+ `<div class="an-video-wrapper" style="position:relative;display:block;width:${width}px;max-width:100%">` +
223
223
  `<iframe src="${info.embedUrl}" width="${width}" height="${height}" ` +
224
224
  `title="${iframeTitle}" ` +
225
225
  `frameborder="0" allowfullscreen ` +
@@ -233,7 +233,7 @@ export class VideoDialog {
233
233
  if (info && info.type === 'Direct video') {
234
234
  const src = info.embedUrl.replace(/"/g, '%22');
235
235
  return (
236
- `<div class="an-video-wrapper" style="position:relative;display:inline-block;max-width:100%">` +
236
+ `<div class="an-video-wrapper" style="position:relative;display:block;width:${width}px;max-width:100%">` +
237
237
  `<video src="${src}" width="${width}" height="${height}" controls ` +
238
238
  `style="display:block;max-width:100%"></video>` +
239
239
  `<div class="an-video-shield"></div>` +
@@ -253,7 +253,7 @@ export class VideoDialog {
253
253
 
254
254
  const escapedSrc = safeSrc.replace(/"/g, '%22');
255
255
  return (
256
- `<div class="an-video-wrapper" style="position:relative;display:inline-block;max-width:100%">` +
256
+ `<div class="an-video-wrapper" style="position:relative;display:block;width:${width}px;max-width:100%">` +
257
257
  `<video src="${escapedSrc}" width="${width}" height="${height}" controls ` +
258
258
  `style="display:block;max-width:100%"></video>` +
259
259
  `<div class="an-video-shield"></div>` +
@@ -21,11 +21,14 @@ export class VideoResizer {
21
21
  this._activeWrapper = null;
22
22
  this._overlay = null;
23
23
  this._disposers = [];
24
+ this._positionRaf = null;
24
25
  }
25
26
 
26
27
  initialize() {
27
28
  this._overlay = this._buildOverlay();
28
- document.body.appendChild(this._overlay);
29
+ const container = this.context.layoutInfo.editable.closest('.an-container') || document.body;
30
+ container.appendChild(this._overlay);
31
+ this._container = container;
29
32
 
30
33
  const editable = this.context.layoutInfo.editable;
31
34
 
@@ -51,6 +54,10 @@ export class VideoResizer {
51
54
  this._dragDisposers.forEach((d) => d());
52
55
  this._dragDisposers = null;
53
56
  }
57
+ if (this._positionRaf) {
58
+ cancelAnimationFrame(this._positionRaf);
59
+ this._positionRaf = null;
60
+ }
54
61
  this._deselect();
55
62
  if (this._overlay && this._overlay.parentNode) {
56
63
  this._overlay.parentNode.removeChild(this._overlay);
@@ -152,10 +159,22 @@ export class VideoResizer {
152
159
  }
153
160
 
154
161
  _updateOverlayPosition() {
162
+ if (this._positionRaf) cancelAnimationFrame(this._positionRaf);
163
+ this._positionRaf = requestAnimationFrame(() => {
164
+ this._positionRaf = null;
165
+ this._updateOverlayPositionNow();
166
+ });
167
+ }
168
+
169
+ _updateOverlayPositionNow() {
155
170
  if (!this._activeWrapper || !this._overlay) return;
171
+ const offsetParent = this._overlay.offsetParent || this._container;
172
+ const containerRect = offsetParent.getBoundingClientRect();
156
173
  const rect = this._activeWrapper.getBoundingClientRect();
157
- this._overlay.style.left = `${rect.left}px`;
158
- this._overlay.style.top = `${rect.top}px`;
174
+ const left = rect.left - containerRect.left + offsetParent.scrollLeft;
175
+ const top = rect.top - containerRect.top + offsetParent.scrollTop;
176
+ this._overlay.style.left = `${left}px`;
177
+ this._overlay.style.top = `${top}px`;
159
178
  this._overlay.style.width = `${rect.width}px`;
160
179
  this._overlay.style.height = `${rect.height}px`;
161
180
  }
@@ -10,6 +10,7 @@ const ICONS = {
10
10
  alignCenter: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="4" width="10" height="8" rx="1"/><line x1="3" y1="16" x2="21" y2="16"/><line x1="6" y1="20" x2="18" y2="20"/></svg>`,
11
11
  originalSize:`<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>`,
12
12
  deleteVideo: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>`,
13
+ preview: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>`,
13
14
  };
14
15
 
15
16
  const SHOW_DELAY = 100;
@@ -24,6 +25,8 @@ export class VideoTooltip {
24
25
  this._showTimer = null;
25
26
  this._hideTimer = null;
26
27
  this._disposers = [];
28
+ this._previewMode = false;
29
+ this._previewClickOff = null;
27
30
  }
28
31
 
29
32
  initialize() {
@@ -61,6 +64,7 @@ export class VideoTooltip {
61
64
  }
62
65
 
63
66
  destroy() {
67
+ if (this._previewMode) this._exitPreview();
64
68
  this._clearTimers();
65
69
  this._disposers.forEach((d) => d());
66
70
  this._disposers = [];
@@ -105,6 +109,11 @@ export class VideoTooltip {
105
109
 
106
110
  el.appendChild(createElement('div', { class: 'an-link-tooltip-sep' }));
107
111
 
112
+ this._previewBtn = this._makeBtn(ICONS.preview, 'Preview Video', () => this._togglePreview());
113
+ el.appendChild(this._previewBtn);
114
+
115
+ el.appendChild(createElement('div', { class: 'an-link-tooltip-sep' }));
116
+
108
117
  this._deleteBtn = this._makeBtn(ICONS.deleteVideo, 'Delete Video', () => this._delete(), true);
109
118
 
110
119
  el.appendChild(this._deleteBtn);
@@ -155,6 +164,8 @@ export class VideoTooltip {
155
164
  }
156
165
 
157
166
  _scheduleHide() {
167
+ // Keep tooltip alive while preview mode is active
168
+ if (this._previewMode) return;
158
169
  clearTimeout(this._showTimer);
159
170
  this._showTimer = null;
160
171
  if (this._hideTimer) return;
@@ -167,6 +178,7 @@ export class VideoTooltip {
167
178
  }
168
179
 
169
180
  _hide() {
181
+ if (this._previewMode) this._exitPreview();
170
182
  this._el.style.display = 'none';
171
183
  this._activeWrapper = null;
172
184
  this._clearTimers();
@@ -249,4 +261,65 @@ export class VideoTooltip {
249
261
  if (wrapper.parentNode) wrapper.parentNode.removeChild(wrapper);
250
262
  this.context.invoke('editor.afterCommand');
251
263
  }
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // Preview mode — temporarily disables the shield so the video is interactive
267
+ // ---------------------------------------------------------------------------
268
+
269
+ _togglePreview() {
270
+ if (this._previewMode) {
271
+ this._exitPreview();
272
+ } else {
273
+ this._enterPreview();
274
+ }
275
+ }
276
+
277
+ _enterPreview() {
278
+ const wrapper = this._activeWrapper;
279
+ if (!wrapper) return;
280
+ this._previewMode = true;
281
+
282
+ // Hide the shield so the iframe / video receives pointer events directly
283
+ const shield = wrapper.querySelector('.an-video-shield');
284
+ if (shield) shield.style.display = 'none';
285
+
286
+ // Hide the resize overlay — it would block interaction with the embed
287
+ this.context.invoke('videoResizer.deselect');
288
+
289
+ // Visual feedback: button turns primary-coloured
290
+ this._previewBtn.classList.add('an-link-tooltip-btn--copied');
291
+ this._previewBtn.title = 'Exit Preview';
292
+
293
+ // Exit preview on mousedown outside the wrapper.
294
+ // Using 'mousedown' (not 'click') so clicks inside an iframe
295
+ // — which never bubble to the outer document — don't accidentally
296
+ // leave preview mode stuck. For <video> elements, mousedown on the
297
+ // video is inside the wrapper so contains() returns true → no exit.
298
+ this._previewClickOff = (e) => {
299
+ if (!wrapper.contains(e.target) && !this._el.contains(e.target)) {
300
+ this._exitPreview();
301
+ }
302
+ };
303
+ document.addEventListener('mousedown', this._previewClickOff, true);
304
+ }
305
+
306
+ _exitPreview() {
307
+ this._previewMode = false;
308
+
309
+ const wrapper = this._activeWrapper;
310
+ if (wrapper) {
311
+ // Restore the shield
312
+ const shield = wrapper.querySelector('.an-video-shield');
313
+ if (shield) shield.style.display = '';
314
+ }
315
+
316
+ // Reset button appearance
317
+ this._previewBtn.classList.remove('an-link-tooltip-btn--copied');
318
+ this._previewBtn.title = 'Preview Video';
319
+
320
+ if (this._previewClickOff) {
321
+ document.removeEventListener('mousedown', this._previewClickOff, true);
322
+ this._previewClickOff = null;
323
+ }
324
+ }
252
325
  }
@@ -99,6 +99,11 @@ export function renderLayout(targetEl, options) {
99
99
  }
100
100
  }
101
101
 
102
+ // Custom focus ring colour
103
+ if (options.focusColor) {
104
+ container.style.setProperty('--an-focus-color', options.focusColor);
105
+ }
106
+
102
107
  // Hide the original element; keep it in DOM for form submission
103
108
  targetEl.style.display = 'none';
104
109
  targetEl.insertAdjacentElement('afterend', container);
@@ -45,6 +45,7 @@ import { defaultToolbar } from './module/Buttons.js';
45
45
  * @property {Function} [onDestroy] - Callback fired when the editor is destroyed: (context) => void
46
46
  * @property {Function} [onCharLimitReached] - Callback fired when the character limit is hit: (context) => void
47
47
  * @property {Function} [onWordLimitReached] - Callback fired when the word limit is hit: (context) => void
48
+ * @property {string} [focusColor] - Custom focus ring colour, e.g. '#f97316'. Overrides the default blue.
48
49
  */
49
50
 
50
51
  /** @type {AsnOptions} */
@@ -131,4 +132,7 @@ export const defaultOptions = {
131
132
  onCharLimitReached: null,
132
133
  // Callback fired when the word limit is reached: function(context)
133
134
  onWordLimitReached: null,
135
+ // Custom focus ring colour — overrides the default blue when set.
136
+ // Accepts any valid CSS colour string, e.g. '#f97316', 'hsl(25,90%,55%)'.
137
+ focusColor: null,
134
138
  };
@@ -12,13 +12,13 @@
12
12
  .an-container {
13
13
  display: flex;
14
14
  flex-direction: column;
15
+ position: relative;
15
16
  border: 1px solid $an-border;
16
17
  border-radius: $an-radius;
17
18
  font-family: $an-font-family;
18
19
  font-size: $an-font-size;
19
20
  box-sizing: border-box;
20
21
  background: $an-bg;
21
- overflow: hidden;
22
22
  // Prevent the container from participating in ancestor layout recalculations.
23
23
  // This limits reflow thrashing when editing large documents.
24
24
  contain: layout;
@@ -30,14 +30,27 @@
30
30
  }
31
31
 
32
32
  &.an-focused {
33
- border-color: $an-primary;
34
- box-shadow: 0 0 0 3px rgba($an-primary, 0.15);
33
+ border-color: var(--an-focus-color, #{$an-primary});
34
+ // color-mix() blends the focus colour with transparent for the glow ring.
35
+ // Falls back gracefully to the default blue glow in older browsers.
36
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--an-focus-color, #{$an-primary}) 18%, transparent);
35
37
  }
36
38
 
37
39
  &.an-disabled {
38
- opacity: 0.6;
39
- pointer-events: none;
40
- user-select: none;
40
+ // Hide toolbar only — read-only view has no editing controls.
41
+ .an-toolbar {
42
+ display: none;
43
+ }
44
+
45
+ // Allow the user to select and copy text but block actual editing.
46
+ // Do NOT set pointer-events:none or user-select:none on the container —
47
+ // that would prevent text selection inside the editable area.
48
+ .an-editable {
49
+ cursor: text;
50
+ user-select: text;
51
+ // Subtle visual cue that the area is read-only.
52
+ background: #fafafa;
53
+ }
41
54
  }
42
55
 
43
56
  &.an-fullscreen {
@@ -234,9 +247,9 @@
234
247
 
235
248
  // Swatch popup panel
236
249
  .an-color-popup {
237
- position: absolute;
238
- top: calc(100% + 4px);
239
- left: 0;
250
+ position: fixed;
251
+ // top / left are set dynamically by JS (getBoundingClientRect) so the popup
252
+ // escapes overflow:hidden / overflow:auto ancestors (toolbar scroll mode, etc.)
240
253
  z-index: 1100;
241
254
  background: $an-bg;
242
255
  border: 1px solid $an-border;
@@ -377,19 +390,25 @@
377
390
  .an-editable {
378
391
  flex: 1;
379
392
  padding: $an-editable-pad;
380
- min-height: 200px;
393
+ min-height: 0;
381
394
  outline: none;
382
395
  line-height: $an-line-height;
383
396
  color: $an-text;
384
397
  overflow-y: auto;
385
398
  word-break: break-word;
386
399
 
387
- // Placeholder
388
- &.an-placeholder::before {
400
+ // Placeholder — only shown when not focused.
401
+ // float:left is the standard contenteditable placeholder technique (used by
402
+ // Quill.js): the pseudo-element is out of normal flow so it never shifts the
403
+ // cursor, yet it renders visually at the start of the empty editing area.
404
+ // Avoid position:absolute here — without position:relative on the parent it
405
+ // would be positioned relative to .an-container (contain:layout) and overlap
406
+ // the toolbar.
407
+ &.an-placeholder:not(:focus)::before {
389
408
  content: attr(data-placeholder);
390
409
  color: $an-muted;
391
410
  pointer-events: none;
392
- position: absolute;
411
+ float: left;
393
412
  }
394
413
 
395
414
  // Content styles
@@ -435,7 +454,7 @@
435
454
 
436
455
  .an-video-wrapper {
437
456
  position: relative;
438
- display: inline-block;
457
+ display: block;
439
458
  max-width: 100%;
440
459
  margin: 4px 0;
441
460
 
@@ -667,6 +686,7 @@
667
686
  display: flex;
668
687
  align-items: center;
669
688
  justify-content: space-between;
689
+ flex-shrink: 0;
670
690
  height: $an-statusbar-h;
671
691
  padding: 0 8px;
672
692
  background: $an-statusbar-bg;
@@ -919,7 +939,7 @@
919
939
  // ---------------------------------------------------------------------------
920
940
 
921
941
  .an-image-resizer {
922
- position: fixed;
942
+ position: absolute;
923
943
  z-index: 10000;
924
944
  box-sizing: border-box;
925
945
  border: 2px solid $an-primary;
@@ -956,7 +976,7 @@
956
976
  $an-video-accent: #8b5cf6; // violet-500
957
977
 
958
978
  .an-video-resizer {
959
- position: fixed;
979
+ position: absolute;
960
980
  z-index: 10000;
961
981
  box-sizing: border-box;
962
982
  border: 2px solid $an-video-accent;
@@ -986,11 +1006,10 @@ $an-video-accent: #8b5cf6; // violet-500
986
1006
  .an-resize-w { top: calc(50% - 4px); left: -4px; cursor: w-resize; }
987
1007
  }
988
1008
 
989
- // Selected video wrapper highlight (applied to .an-video-wrapper)
990
- .an-video-selected {
991
- outline: 2px solid $an-video-accent;
992
- outline-offset: 1px;
993
- }
1009
+ // .an-video-selected is applied by VideoResizer for JS state tracking.
1010
+ // Visual selection is handled entirely by the .an-video-resizer overlay,
1011
+ // so no CSS outline is needed here (two visible frames would appear otherwise).
1012
+ .an-video-selected {}
994
1013
 
995
1014
  // ---------------------------------------------------------------------------
996
1015
  // Icon dialog
@@ -1082,7 +1101,7 @@ $an-video-accent: #8b5cf6; // violet-500
1082
1101
  display: grid;
1083
1102
  grid-template-columns: repeat(auto-fill, 56px);
1084
1103
  gap: 3px;
1085
- max-height: 256px;
1104
+ height: 256px;
1086
1105
  overflow-y: auto;
1087
1106
  margin-bottom: 10px;
1088
1107
  padding: 4px;
@@ -1244,7 +1263,7 @@ $an-video-accent: #8b5cf6; // violet-500
1244
1263
  display: grid;
1245
1264
  grid-template-columns: repeat(auto-fill, 42px);
1246
1265
  gap: 2px;
1247
- max-height: 288px;
1266
+ height: 288px;
1248
1267
  overflow-y: auto;
1249
1268
  margin-bottom: 10px;
1250
1269
  padding: 4px;
@@ -1572,6 +1591,11 @@ $an-video-accent: #8b5cf6; // violet-500
1572
1591
  .an-table-cell {
1573
1592
  background: #24273a;
1574
1593
  border-color: #3f3f5f;
1594
+
1595
+ &.active {
1596
+ background: rgba(99, 102, 241, 0.3);
1597
+ border-color: #6366f1;
1598
+ }
1575
1599
  }
1576
1600
 
1577
1601
  .an-context-item { color: #cdd6f4; }
@@ -1670,25 +1694,31 @@ $an-video-accent: #8b5cf6; // violet-500
1670
1694
  .an-editable {
1671
1695
  ul.an-checklist {
1672
1696
  list-style: none;
1673
- padding-left: 0.5em;
1697
+ padding-left: 0;
1674
1698
  margin: 0.5em 0;
1675
1699
 
1676
1700
  li {
1677
- display: flex;
1678
- align-items: baseline;
1679
- gap: 6px;
1680
- padding: 2px 0;
1701
+ // Use padding-left + absolute checkbox so text is a normal inline flow
1702
+ // (not an anonymous flex item). This ensures font-size, line-height and
1703
+ // all inline formats are identical to regular paragraph text.
1704
+ position: relative;
1705
+ padding-left: 22px;
1706
+ padding-top: 1px;
1707
+ padding-bottom: 1px;
1708
+ min-height: 1.4em;
1681
1709
  line-height: inherit;
1710
+ font-size: inherit;
1711
+ font-family: inherit;
1682
1712
 
1683
1713
  input[type='checkbox'] {
1684
- flex-shrink: 0;
1714
+ position: absolute;
1715
+ left: 0;
1716
+ top: 0.25em;
1685
1717
  width: 14px;
1686
1718
  height: 14px;
1687
1719
  margin: 0;
1688
1720
  cursor: pointer;
1689
1721
  accent-color: $an-primary;
1690
- position: relative;
1691
- top: 2px;
1692
1722
  }
1693
1723
 
1694
1724
  // Checked item: dim + strike-through the text node
package/types/index.d.ts CHANGED
@@ -43,7 +43,7 @@ export interface AsnOptions {
43
43
  /** Number of spaces inserted when Tab is pressed (outside a list). */
44
44
  tabSize?: number;
45
45
  /** Callback on content change. */
46
- onChange?: (context: Context) => void;
46
+ onChange?: (html: string) => void;
47
47
  /** Callback on editor focus. */
48
48
  onFocus?: (context: Context) => void;
49
49
  /** Callback fired on editor blur. */
@@ -59,9 +59,9 @@ export interface AsnOptions {
59
59
  /** Callback fired when the word limit is reached. */
60
60
  onWordLimitReached?: (context: Context) => void;
61
61
  /** Custom image upload handler. */
62
- onImageUpload?: (files: FileList) => void;
62
+ onImageUpload?: (files: File[]) => void;
63
63
  /** Callback when an image upload error occurs. */
64
- onImageError?: (error: Error) => void;
64
+ onImageError?: (error: { file?: File; message: string; error?: unknown }) => void;
65
65
  /** Stick the toolbar to the viewport top when scrolling. */
66
66
  stickyToolbar?: boolean;
67
67
  /** Top offset in px for sticky toolbar (e.g. height of a fixed nav bar). */
@@ -101,7 +101,7 @@ export interface AsnOptions {
101
101
  /** Insert a header row (<thead>) when creating new tables. */
102
102
  tableHeaderRow?: boolean;
103
103
  /** Callback fired after every paste event. */
104
- onPaste?: (data: { text: string; html: string }) => void;
104
+ onPaste?: (data: { text: string; html: string | null }) => void;
105
105
  /** Additional color swatches shown at the top of the color picker. */
106
106
  colorSwatches?: string[];
107
107
  }