fable-editor 1.2.7 → 1.2.8

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.
@@ -1828,7 +1828,12 @@ class FableEditor {
1828
1828
  foreColor = '#000000';
1829
1829
  backColor = '#FACC15';
1830
1830
  openPop = null;
1831
- openSubEl = null;
1831
+ /** Stack of currently-open flyout submenus, shallowest first (e.g. for
1832
+ * Table > Cell > Cell background: [Cell flyout, Cell background flyout]).
1833
+ * A single slot isn't enough past one level deep - opening a 2nd-level
1834
+ * flyout would remove the 1st-level one still needed to bridge visually
1835
+ * back to the top-level popup, leaving a gap. */
1836
+ subStack = [];
1832
1837
  popAnchor = null;
1833
1838
  dlgOvl = null;
1834
1839
  tblActive = null;
@@ -2018,7 +2023,7 @@ class FableEditor {
2018
2023
  if (this.openPop &&
2019
2024
  !this.openPop.contains(e.target) &&
2020
2025
  !this.popAnchor.contains(e.target) &&
2021
- !(this.openSubEl && this.openSubEl.contains(e.target))) {
2026
+ !this.subContains(e.target)) {
2022
2027
  this.closePop();
2023
2028
  }
2024
2029
  });
@@ -2175,8 +2180,17 @@ class FableEditor {
2175
2180
  this.positionVidCtx();
2176
2181
  this.positionCodeCtx();
2177
2182
  });
2178
- this.onWin('scroll', () => {
2179
- this.closePop();
2183
+ this.onWin('scroll', (e) => {
2184
+ /* this fires for ANY scroll in the document (capture:true is what
2185
+ lets it catch a scrollable ancestor, not just the window) -
2186
+ including the popup's own overflow:auto scrolling through a long
2187
+ menu. Only close it for a scroll that happened outside the open
2188
+ menu/flyouts; otherwise scrolling a long dropdown would close it
2189
+ on the very first scroll tick instead of scrolling its content. */
2190
+ const t = e.target;
2191
+ const withinOpenMenu = t instanceof Node && ((this.openPop?.contains(t) ?? false) || this.subContains(t));
2192
+ if (!withinOpenMenu)
2193
+ this.closePop();
2180
2194
  this.positionTableHandles();
2181
2195
  this.positionImageHandles();
2182
2196
  this.positionImgPhCtx();
@@ -2260,7 +2274,7 @@ class FableEditor {
2260
2274
  (this.vidCtx && this.vidCtx.contains(e.target)) ||
2261
2275
  (this.codeCtx && this.codeCtx.contains(e.target)) ||
2262
2276
  (this.openPop && this.openPop.contains(e.target)) ||
2263
- (this.openSubEl && this.openSubEl.contains(e.target)))
2277
+ this.subContains(e.target))
2264
2278
  return;
2265
2279
  this.clearTableHandles();
2266
2280
  this.clearImageHandles();
@@ -2601,8 +2615,28 @@ class FableEditor {
2601
2615
  }
2602
2616
  /* ---------------------------------------------------------- popups / menus */
2603
2617
  closeSub() {
2604
- this.openSubEl?.remove();
2605
- this.openSubEl = null;
2618
+ this.closeSubFrom(0);
2619
+ }
2620
+ /** Removes stacked flyouts from `level` (0 = shallowest) onward, keeping
2621
+ * anything shallower - e.g. hovering a sibling item inside the "Cell"
2622
+ * flyout should drop a deeper "Cell background" flyout but keep "Cell"
2623
+ * itself and the top-level popup it bridges to. */
2624
+ closeSubFrom(level) {
2625
+ while (this.subStack.length > level)
2626
+ this.subStack.pop().remove();
2627
+ }
2628
+ /** True if `node` is inside any currently-open flyout submenu (any depth). */
2629
+ subContains(node) {
2630
+ return this.subStack.some((el) => el.contains(node));
2631
+ }
2632
+ /** 0 for the top-level popup itself; otherwise this container's depth in
2633
+ * the flyout stack + 1. Used to know how many deeper flyouts to close
2634
+ * when opening or hovering within `container`. */
2635
+ subLevelOf(container) {
2636
+ if (container === this.openPop)
2637
+ return 0;
2638
+ const idx = this.subStack.indexOf(container);
2639
+ return idx === -1 ? this.subStack.length : idx + 1;
2606
2640
  }
2607
2641
  closePop() {
2608
2642
  this.closeSub();
@@ -2617,7 +2651,17 @@ class FableEditor {
2617
2651
  }
2618
2652
  }
2619
2653
  openSubFor(item, anchor) {
2620
- this.closeSub();
2654
+ /* captured before truncating the stack - for a flyout nested inside
2655
+ another flyout (e.g. Table > Cell > Cell background), anchor is itself
2656
+ a descendant of the currently-open sub-popup; measuring after removing
2657
+ it would read a detached, zeroed-out rect and land the new flyout at
2658
+ the top-left corner of the screen */
2659
+ const r = anchor.getBoundingClientRect();
2660
+ /* only close flyouts deeper than the one anchor lives in - closing
2661
+ everything (the old behavior) would also remove that same containing
2662
+ flyout, leaving a visual gap back to the top-level popup */
2663
+ const parentPop = anchor.closest('.pop');
2664
+ this.closeSubFrom(parentPop ? this.subLevelOf(parentPop) : 0);
2621
2665
  const sub = document.createElement('div');
2622
2666
  sub.className = 'pop sub';
2623
2667
  sub.dir = this.dir();
@@ -2630,7 +2674,6 @@ class FableEditor {
2630
2674
  e.preventDefault();
2631
2675
  });
2632
2676
  document.body.appendChild(sub);
2633
- const r = anchor.getBoundingClientRect();
2634
2677
  const isRtl = this.dir() === 'rtl';
2635
2678
  // overlap the parent item slightly so the pointer can travel into the flyout
2636
2679
  let x = isRtl ? r.left - sub.offsetWidth + 2 : r.right - 2;
@@ -2639,7 +2682,7 @@ class FableEditor {
2639
2682
  y = Math.max(8 + scrollY, Math.min(y, scrollY + innerHeight - sub.offsetHeight - 8));
2640
2683
  sub.style.left = x + 'px';
2641
2684
  sub.style.top = y + 'px';
2642
- this.openSubEl = sub;
2685
+ this.subStack.push(sub);
2643
2686
  }
2644
2687
  popup(anchor, build, cls) {
2645
2688
  if (this.openPop && this.popAnchor === anchor) {
@@ -2655,7 +2698,11 @@ class FableEditor {
2655
2698
  const r = anchor.getBoundingClientRect();
2656
2699
  // align the menu with its control: at least as wide as the anchor
2657
2700
  el.style.minWidth = Math.max(160, Math.round(r.width)) + 'px';
2658
- el.style.top = r.bottom + scrollY + 2 + 'px';
2701
+ // clamp so a long menu (its own max-height/overflow makes it scrollable)
2702
+ // stays fully on-screen instead of running off the bottom of the viewport
2703
+ let y = r.bottom + scrollY + 2;
2704
+ y = Math.max(8 + scrollY, Math.min(y, scrollY + innerHeight - el.offsetHeight - 8));
2705
+ el.style.top = y + 'px';
2659
2706
  const isRtl = this.dir() === 'rtl';
2660
2707
  let x = isRtl ? r.right - el.offsetWidth : r.left;
2661
2708
  x = Math.max(8, Math.min(x + scrollX, scrollX + innerWidth - el.offsetWidth - 8));
@@ -2687,8 +2734,11 @@ class FableEditor {
2687
2734
  b.addEventListener('mouseenter', () => {
2688
2735
  if (hasSub)
2689
2736
  this.openSubFor(it, b);
2690
- else if (this.openSubEl && el !== this.openSubEl)
2691
- this.closeSub();
2737
+ /* hovering a no-submenu item drops whatever deeper flyout was open
2738
+ from a previously-hovered sibling, but keeps el itself and any
2739
+ shallower ancestors intact */
2740
+ else
2741
+ this.closeSubFrom(this.subLevelOf(el));
2692
2742
  it.hover?.(true);
2693
2743
  });
2694
2744
  b.addEventListener('mouseleave', () => it.hover?.(false));
@@ -3274,8 +3324,8 @@ class FableEditor {
3274
3324
  buildToolbarRegistry() {
3275
3325
  const tableBtn = this.tbtn(IC.tableic, this.t('quicktable'), () => this.tableGrid(tableBtn));
3276
3326
  const registry = {
3277
- undo: () => this.tbtn(IC.undo, this.t('undo'), () => this.exec('undo')),
3278
- redo: () => this.tbtn(IC.redo, this.t('redo'), () => this.exec('redo')),
3327
+ undo: () => this.tbtn(IC.undo, this.t('undo'), () => this.exec('undo'), 'undo'),
3328
+ redo: () => this.tbtn(IC.redo, this.t('redo'), () => this.exec('redo'), 'redo'),
3279
3329
  preview: () => this.tbtn(IC.prevw, this.t('preview'), () => this.previewDlg()),
3280
3330
  print: () => this.tbtn(IC.printic, this.t('print'), () => this.printDoc()),
3281
3331
  importword: () => this.tbtn(IC.wordic, this.t('importword'), () => this.pickWordDoc()),