@weniatall/dsh-file-viewer 0.3.5 → 0.3.7

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 (2) hide show
  1. package/lib/client.js +443 -11
  2. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -52,6 +52,19 @@ window.__ModuleLoader__.load({
52
52
  );
53
53
  }
54
54
 
55
+ /** Outline / table-of-contents glyph: short bars stepping in by level. */
56
+ function SvgIconOutline({ size = 15 }) {
57
+ return react.createElement("svg", {
58
+ width: size, height: size, viewBox: "0 0 16 16", fill: "none",
59
+ stroke: "currentColor", strokeWidth: "1.4", strokeLinecap: "round", strokeLinejoin: "round"
60
+ },
61
+ react.createElement("path", { d: "M2.5 3.5h11" }),
62
+ react.createElement("path", { d: "M2.5 7h7.5" }),
63
+ react.createElement("path", { d: "M2.5 10.5h9" }),
64
+ react.createElement("path", { d: "M2.5 14h5.5" })
65
+ );
66
+ }
67
+
55
68
  function SvgIconRefresh({ size = 15 }) {
56
69
  return react.createElement("svg", {
57
70
  width: size, height: size, viewBox: "0 0 16 16", fill: "none",
@@ -305,6 +318,18 @@ window.__ModuleLoader__.load({
305
318
  // nothing itself.
306
319
  const COMPOSER_SLOT = "conversation.input.left";
307
320
  const STYLE_ID = "dsh-file-viewer-styles";
321
+ /**
322
+ * The id both context menus carry.
323
+ *
324
+ * It exists purely so the stylesheet can win against a theme's glass rule.
325
+ * The glass theme paints `[role="menu"]` with a translucent background and
326
+ * a large backdrop blur; its selector is long enough (a :is() chain plus
327
+ * :not()) that no class-based override beats it, and it loads after this
328
+ * bundle. An id selector outranks any combination of classes and
329
+ * attributes, so the fix does not depend on sheet order. Only one menu is
330
+ * mounted at a time, so sharing the id is safe.
331
+ */
332
+ const CONTEXT_MENU_ID = "dsh-fv-context-menu";
308
333
  // Per-browser UI state, so it lives in localStorage rather than plugin
309
334
  // settings; the key follows the `<plugin>.<setting>` form used elsewhere.
310
335
  const ENTRY_POSITION_KEY = "dsh-file-viewer.entry-position";
@@ -338,6 +363,29 @@ window.__ModuleLoader__.load({
338
363
  const inject = ["slots", "connection", "workspaces"];
339
364
 
340
365
  const css = `
366
+ /* ---------------------------------------------------------------------
367
+ Opt out of the "every modal becomes a centred card" treatment.
368
+
369
+ The host mounts overlay seats inside a plain div[role="presentation"],
370
+ and dsh-mobile-adapter turns that container into a full-screen
371
+ rgba(0,0,0,.5) + blur(4px) scrim whenever it CONTAINS a
372
+ [role="dialog"][aria-modal="true"] — and shrinks the dialog itself to a
373
+ 420px centred card. That is right for a small confirm dialog and wrong
374
+ for this drawer: the viewer is a full-height panel pinned to the right
375
+ edge, and being treated as a card blacked out the app behind it (read
376
+ by the user as "the right-click menu has an opaque mask") and fought
377
+ the drawer's own width.
378
+
379
+ The adapter keys on containment (:has(...)), not on being the parent,
380
+ so these overrides use the descendant form too: wherever the host nests
381
+ this viewer, the same container is neutralised. Every rule is scoped to
382
+ a container that actually holds .fv-scrim, so no other dialog on the
383
+ page is affected. !important is required because the adapter's own
384
+ declarations use it.
385
+ --------------------------------------------------------------------- */
386
+ body>div[role="presentation"]:has(.fv-scrim){background:transparent!important;-webkit-backdrop-filter:none!important;backdrop-filter:none!important;padding:0!important;display:block!important;align-items:stretch!important;justify-content:flex-start!important}
387
+ body>div[role="presentation"]:has(.fv-scrim)>[aria-hidden="true"]{display:none!important}
388
+ .fv-scrim[role="dialog"]{position:absolute!important;inset:0!important;width:auto!important;max-width:none!important;max-height:none!important;min-height:0!important;margin:0!important;border:none!important;border-radius:0!important;box-shadow:none!important;background:transparent!important;-webkit-backdrop-filter:none!important;backdrop-filter:none!important;overflow:visible!important}
341
389
  .fv-scrim{position:absolute;inset:0;z-index:40;display:flex;align-items:stretch;justify-content:flex-end;background:transparent;pointer-events:auto;animation:fv-fade .16s ease-out}
342
390
  .fv-shell{display:flex;flex-direction:column;width:min(64vw,1100px);min-width:0;min-height:0;overflow:hidden;border-left:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-base,#fff));box-shadow:var(--dsw-shadow-lv3);font-family:var(--dsw-font-family);color:var(--dsw-alias-label-primary);animation:fv-slide .2s cubic-bezier(.2,.8,.2,1);transition:width .2s cubic-bezier(.2,.8,.2,1)}
343
391
  .fv-shell[data-fullscreen="true"]{width:100vw;border-left:none}
@@ -435,8 +483,35 @@ window.__ModuleLoader__.load({
435
483
  .fv-lang-select{box-sizing:border-box;max-width:132px;height:24px;padding:0 4px;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;background:var(--dsw-alias-bg-layer-1,transparent);color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:22px;cursor:pointer}
436
484
  .fv-lang-select:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3,var(--dsw-alias-border-l2))}
437
485
  .fv-lang-select:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#4d6bfe);outline-offset:1px}
438
- .fv-content{flex:1 1 auto;min-height:0;overflow:auto;padding:12px;overscroll-behavior:contain;-webkit-overflow-scrolling:touch}
486
+ .fv-content{flex:1 1 auto;min-height:0;overflow:auto;padding:12px;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;isolation:isolate}
439
487
  .fv-content>*{max-width:100%}
488
+ /* The Markdown outline: a rail pinned to the reading area's trailing edge.
489
+ The wrapper is the positioning context, so the rail stays put while the
490
+ text inside .fv-content scrolls. */
491
+ .fv-content-wrap{position:relative;flex:1 1 auto;min-height:0;display:flex;flex-direction:column}
492
+ .fv-content-wrap>.fv-content{flex:1 1 auto}
493
+ .fv-outline{position:absolute;top:8px;right:8px;z-index:10;display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-height:calc(100% - 16px);pointer-events:none}
494
+ .fv-outline>*{pointer-events:auto}
495
+ .fv-outline-toggle{display:inline-grid;place-items:center;width:28px;height:28px;padding:0;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-base,#fff));box-shadow:var(--dsw-shadow-lv2,0 2px 8px rgba(0,0,0,.08));color:var(--dsw-alias-label-secondary);cursor:pointer;flex:none;transition:color .12s,background-color .12s}
496
+ .fv-outline-toggle:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover-solid,var(--dsw-alias-interactive-bg-hover))}
497
+ .fv-outline[data-open="true"] .fv-outline-toggle{background:var(--dsw-alias-interactive-bg-hover-solid,var(--dsw-alias-interactive-bg-hover));color:var(--dsw-alias-label-primary)}
498
+ .fv-outline-toggle:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#4d6bfe);outline-offset:1px}
499
+ /* The panel scrolls on its own: a long document can have fifty headings,
500
+ and the rail must never push past the reading area. */
501
+ .fv-outline-panel{display:flex;flex-direction:column;min-height:0;max-height:min(60vh,420px);width:min(280px,52vw);padding:6px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-base,#fff));box-shadow:var(--dsw-shadow-lv3);animation:fv-menu-in .12s cubic-bezier(.2,.8,.2,1)}
502
+ .fv-outline-head{flex:none;padding:4px 8px 6px;border-bottom:1px solid var(--dsw-alias-border-l1);margin-bottom:4px;color:var(--dsw-alias-label-caption);font-size:11px;font-weight:600;letter-spacing:.02em}
503
+ .fv-outline-list{display:flex;flex-direction:column;gap:1px;min-height:0;overflow-y:auto;overscroll-behavior:contain}
504
+ .fv-outline-item{display:block;width:100%;padding:5px 8px;border:none;border-radius:6px;background:none;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:1.4;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
505
+ .fv-outline-item:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
506
+ .fv-outline-item[aria-current="true"]{background:var(--dsw-alias-interactive-bg-hover-solid,var(--dsw-alias-interactive-bg-hover));color:var(--dsw-alias-label-primary);font-weight:600}
507
+ .fv-outline-item:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#4d6bfe);outline-offset:-2px}
508
+ /* Indentation comes from the DOM order and the level, so a document that
509
+ starts at level two is not silently pushed one level in. */
510
+ .fv-outline-item[data-level="1"]{padding-left:20px}
511
+ .fv-outline-item[data-level="2"]{padding-left:32px}
512
+ .fv-outline-item[data-level="3"]{padding-left:44px}
513
+ .fv-outline-item[data-level="4"]{padding-left:56px}
514
+ .fv-outline-item[data-level="5"]{padding-left:68px}
440
515
  .fv-doc-article{max-width:880px;margin:0 auto;padding:12px 16px;line-height:1.8;font-size:14px;word-break:break-word}
441
516
  .fv-doc-article h1{font-size:22px;font-weight:700;margin:28px 0 16px;line-height:1.35;border-bottom:1px solid var(--dsw-alias-border-l1);padding-bottom:8px}
442
517
  .fv-doc-article h2{font-size:18px;font-weight:600;margin:24px 0 12px;line-height:1.4;border-bottom:1px dashed var(--dsw-alias-border-l1);padding-bottom:6px}
@@ -602,8 +677,30 @@ window.__ModuleLoader__.load({
602
677
  .fv-content[data-wrap="on"] div[class*="_line_"]{align-items:flex-start}
603
678
  .fv-content[data-wrap="on"] span[class*="_gutter_"]{flex:none;white-space:pre}
604
679
  .fv-content[data-wrap="on"] span[class*="_content_"]{min-width:0;flex:1 1 auto;white-space:pre-wrap;overflow-wrap:anywhere}
605
- .fv-context-backdrop{position:fixed;inset:0;z-index:99;background:transparent;touch-action:none}
606
- .fv-context-menu{position:fixed;z-index:100;min-width:190px;padding:5px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-specific-menu,var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-base)));box-shadow:var(--dsw-shadow-lv3);color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);font-size:13px;display:flex;flex-direction:column;gap:3px;animation:fv-menu-in .12s cubic-bezier(.2,.8,.2,1);touch-action:manipulation}
680
+ .fv-context-backdrop{position:fixed;inset:0;z-index:99;background:transparent!important;-webkit-backdrop-filter:none!important;backdrop-filter:none!important;touch-action:none}
681
+ /* 菜单是实底卡片,不吃主题的毛玻璃。
682
+ 玻璃主题(glass.css)里有一条规则,给 [role="menu"] 套上
683
+ background:var(--color-menu) 和 backdrop-filter:blur(...);我们的菜单正好
684
+ 带 role="menu",于是被套上半透明底 + 几十 px 的模糊。backdrop-filter 会把
685
+ **它背后的一切**糊掉,而背后正是铺满全屏的遮罩层,所以整屏发虚、只剩菜单
686
+ 轮廓;系统默认主题没有这条规则,因此只有玻璃主题下才看得到。
687
+ 这里把三样都钉死:清掉模糊、底色用宿主菜单底(不透明),确保菜单里的文字
688
+ 永远落在实色上、背后也不会被糊。 */
689
+ .fv-context-menu{position:fixed;z-index:100;min-width:190px;padding:5px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-base,#fff));box-shadow:var(--dsw-shadow-lv3);color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);font-size:13px;display:flex;flex-direction:column;gap:3px;animation:fv-menu-in .12s cubic-bezier(.2,.8,.2,1);touch-action:manipulation;-webkit-backdrop-filter:none!important;backdrop-filter:none!important;isolation:isolate}
690
+ /* 底色单独一条,且带 !important:玻璃主题用 background:var(--color-menu)
691
+ !important 把菜单底改成 rgba(248,249,255,.72),不压住它菜单会透出背后的
692
+ 正文,读起来像半透明脏块。这条把菜单底钉回宿主的实色值,主题换色也不影响
693
+ 可读性。 */
694
+ .fv-context-menu{background-color:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-base,#fff))!important}
695
+ /* 上面两条还不够。玻璃主题的样式表在插件之后注入,两条 !important 规则
696
+ 特异性相同时「后加载者胜」,实测菜单仍拿到 blur(41.6px) 与
697
+ rgba(248,249,255,.72)。这条把选择器抬到 html + 重复类 + 属性同时命中,
698
+ 特异性稳稳高过主题的 :is(html[data-...]) [role="menu"],与加载顺序无关。 */
699
+ /* 用 id 选择器(特异性 1-0-0)压过主题:玻璃主题的选择器是
700
+ :is(html[data-theme-studio=...]) [role="menu"]:not(...),叠加后仍属类/
701
+ 属性级别,且它的样式表在本插件之后注入;实测类级 !important 会输给它,
702
+ id 级则与加载顺序无关地胜出。 */
703
+ #dsh-fv-context-menu{-webkit-backdrop-filter:none!important;backdrop-filter:none!important;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-base,#fff))!important;background-color:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-base,#fff))!important;isolation:isolate!important}
607
704
  @keyframes fv-menu-in{from{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}
608
705
  .fv-context-item{display:flex;align-items:center;gap:10px;width:100%;height:34px;padding:0 10px;border:none;border-radius:8px;background:none;color:inherit;font:inherit;text-align:left;cursor:pointer;user-select:none;-webkit-tap-highlight-color:transparent}
609
706
  .fv-context-item:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
@@ -686,7 +783,11 @@ window.__ModuleLoader__.load({
686
783
  .fv-body[data-pane="content"] .fv-tree,.fv-body[data-pane="tree"] .fv-main{display:none!important}
687
784
  .fv-crumbs{font-size:11px}
688
785
  .fv-file-path{display:none!important}
689
- .fv-context-backdrop{background:rgba(0,0,0,.45);-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);animation:fv-fade .16s ease-out}
786
+ /* 遮罩只用来接住「点菜单外面关闭」,不该遮住内容。
787
+ 它曾经在这里被涂成 rgba(0,0,0,.45) 并加了模糊,等于把整个抽屉
788
+ 盖黑。手机上菜单本身已经是贴底的独立面板,靠自身圆角与阴影就能
789
+ 立住,不需要再压一层遮罩。 */
790
+ .fv-context-backdrop{background:transparent!important;-webkit-backdrop-filter:none!important;backdrop-filter:none!important}
690
791
  .fv-context-menu{position:fixed!important;bottom:max(16px,var(--dsh-sab,0px))!important;left:max(16px,var(--dsh-sal,0px))!important;right:max(16px,var(--dsh-sar,0px))!important;top:auto!important;width:auto!important;max-width:none!important;border-radius:18px!important;padding:8px!important;box-shadow:var(--dsw-shadow-lv3)!important;animation:fv-menu-slide-up .2s cubic-bezier(.2,.8,.2,1)!important}
691
792
  .fv-context-head{padding:4px 8px 8px;border-bottom:1px solid var(--dsw-alias-border-l1);display:flex;flex-direction:column;gap:2px;margin-bottom:4px}
692
793
  .fv-context-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);word-break:break-all;line-height:1.3}
@@ -701,6 +802,17 @@ window.__ModuleLoader__.load({
701
802
  .fv-tab-dir{display:none!important}
702
803
  .fv-tab-close{width:24px!important;height:24px!important;font-size:16px!important}
703
804
  .fv-main-head{padding:6px 8px!important;gap:6px!important}
805
+ /* 手机上的目录:按钮放大到可点面积,面板几乎占满宽度,
806
+ 并让正文在目录展开时仍能读到。 */
807
+ .fv-outline{top:6px!important;right:6px!important;max-height:calc(100% - 12px)!important}
808
+ .fv-outline-toggle{width:34px!important;height:34px!important;border-radius:10px!important}
809
+ .fv-outline-panel{width:min(78vw,320px)!important;max-height:min(56vh,380px)!important;border-radius:12px!important}
810
+ .fv-outline-item{padding:9px 10px!important;font-size:13px!important;border-radius:8px!important}
811
+ .fv-outline-item[data-level="1"]{padding-left:22px!important}
812
+ .fv-outline-item[data-level="2"]{padding-left:34px!important}
813
+ .fv-outline-item[data-level="3"]{padding-left:46px!important}
814
+ .fv-outline-item[data-level="4"]{padding-left:58px!important}
815
+ .fv-outline-item[data-level="5"]{padding-left:70px!important}
704
816
  /* SafePathsModal 移动端自适应优化:输入框与添加按钮纵向铺满,防溢出 */
705
817
  .fv-modal-scrim{padding:10px!important}
706
818
  .fv-modal{width:min(96vw,560px)!important;max-height:min(90dvh,90vh,680px)!important;border-radius:16px!important}
@@ -730,6 +842,79 @@ window.__ModuleLoader__.load({
730
842
  }
731
843
  `;
732
844
 
845
+ /**
846
+ * Strip any full-screen blur / darkening that other layers put over the app
847
+ * while this drawer is open.
848
+ *
849
+ * The drawer is a right-hand panel, not a modal dialog: content behind it
850
+ * stays visible and interactive. Other layers disagree — dsh-mobile-adapter
851
+ * turns the host's role="presentation" wrapper into a blurred
852
+ * rgba(0,0,0,.5) scrim, and glass themes paint backdrop-filter on anything
853
+ * they consider a floating surface. Both are keyed on selectors this plugin
854
+ * does not own (role attributes, sibling scrims), so overriding them from
855
+ * CSS depends on winning a specificity fight against third-party sheets that
856
+ * load after ours.
857
+ *
858
+ * Walking up from the drawer and neutralising those properties in place is
859
+ * source-agnostic: it removes whatever a theme or adapter applied, no matter
860
+ * which rule did it, and it is re-applied on every open because those layers
861
+ * can re-render. The original inline values are restored on close so no
862
+ * other feature inherits our override.
863
+ *
864
+ * @param {Element | null} scrim - this drawer's root element.
865
+ * @param {boolean} active - true while the drawer is open.
866
+ * @returns {() => void} a function that restores what was changed.
867
+ */
868
+ function suppressBackdropEffects(scrim, active) {
869
+ if (!scrim || typeof window === "undefined") return () => {};
870
+ const view = scrim.ownerDocument?.defaultView || window;
871
+ const touched = [];
872
+ const clear = (element) => {
873
+ if (!element || element.nodeType !== 1) return;
874
+ const style = view.getComputedStyle(element);
875
+ const backdrop = style.backdropFilter || style.webkitBackdropFilter || "";
876
+ const background = style.backgroundColor || "";
877
+ // Only touch what actually has an effect: a transparent background and a
878
+ // `none` filter mean there is nothing to undo, and writing to them would
879
+ // burn a style recalculation for no visible reason.
880
+ const hasBlur = backdrop !== "" && backdrop !== "none";
881
+ const isDark = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/.test(background)
882
+ && !/rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0\s*\)/.test(background)
883
+ && parseFloat(/rgba?\([^)]*?([\d.]+)\)$/.exec(background)?.[1] ?? "1") > 0.05;
884
+ if (!hasBlur && !isDark) return;
885
+ touched.push({
886
+ element,
887
+ backdrop: element.style.backdropFilter,
888
+ webkitBackdrop: element.style.webkitBackdropFilter,
889
+ background: element.style.backgroundColor,
890
+ });
891
+ if (hasBlur) {
892
+ element.style.setProperty("backdrop-filter", "none", "important");
893
+ element.style.setProperty("-webkit-backdrop-filter", "none", "important");
894
+ }
895
+ if (isDark) element.style.setProperty("background-color", "transparent", "important");
896
+ };
897
+ if (active) {
898
+ // The wrapper the host mounts this seat into, plus its own sibling
899
+ // scrims, are the two places a modal treatment shows up.
900
+ let node = scrim.parentElement;
901
+ for (let depth = 0; node && depth < 4; depth += 1) {
902
+ clear(node);
903
+ for (const sibling of node.children) {
904
+ if (sibling !== scrim && sibling.getAttribute("aria-hidden") === "true") clear(sibling);
905
+ }
906
+ node = node.parentElement;
907
+ }
908
+ }
909
+ return () => {
910
+ for (const entry of touched) {
911
+ entry.element.style.backdropFilter = entry.backdrop;
912
+ entry.element.style.webkitBackdropFilter = entry.webkitBackdrop;
913
+ entry.element.style.backgroundColor = entry.background;
914
+ }
915
+ };
916
+ }
917
+
733
918
  /**
734
919
  * Inject the stylesheet, refreshing it when the content has changed.
735
920
  *
@@ -1520,6 +1705,63 @@ window.__ModuleLoader__.load({
1520
1705
  return cut <= 0 ? "" : trimmed.slice(0, cut);
1521
1706
  }
1522
1707
 
1708
+ /**
1709
+ * The headings a Markdown document declares, in document order.
1710
+ *
1711
+ * Read from the SOURCE rather than from the rendered DOM: the renderer's
1712
+ * node shapes are the host's business and can change, while the ATX heading
1713
+ * syntax is fixed by CommonMark. The outline therefore stays correct even if
1714
+ * the primitives package swaps its renderer.
1715
+ *
1716
+ * ATX headings only (`# Title`). Setext headings (`Title` underlined with
1717
+ * `===` / `---`) are not recognised, which mirrors what the outline can
1718
+ * usefully show — a document using them mixes styles and the extra rules
1719
+ * would risk mis-reading a table separator as a heading.
1720
+ *
1721
+ * Fenced code blocks are skipped: a `# comment` inside a shell snippet is
1722
+ * not a section, and listing it would scroll the reader into a code block.
1723
+ *
1724
+ * @param {string} text - the Markdown source.
1725
+ * @returns {Array<{level: number, text: string, line: number, key: string}>}
1726
+ * headings from `level` 1..6, in order, with their 0-based source line.
1727
+ */
1728
+ function parseHeadings(text) {
1729
+ const source = typeof text === "string" ? text : "";
1730
+ if (source === "") return [];
1731
+ const headings = [];
1732
+ const lines = source.split(/\r?\n/);
1733
+ // Fence tracking: ``` or ~~~ toggles; a closing fence must use the same
1734
+ // character and be at least as long as the opener (CommonMark).
1735
+ let fence = null;
1736
+ for (let index = 0; index < lines.length; index += 1) {
1737
+ const line = lines[index];
1738
+ const fenceMatch = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
1739
+ if (fenceMatch !== null) {
1740
+ const marker = fenceMatch[1];
1741
+ if (fence === null) fence = marker;
1742
+ else if (marker[0] === fence[0] && marker.length >= fence.length) fence = null;
1743
+ continue;
1744
+ }
1745
+ if (fence !== null) continue;
1746
+ // A heading is at most three leading spaces, then 1..6 #, then a space
1747
+ // or end of line. `#nospace` is not a heading.
1748
+ const match = /^\s{0,3}(#{1,6})(?:\s+|$)(.*)$/.exec(line);
1749
+ if (match === null) continue;
1750
+ const level = match[1].length;
1751
+ // Strip the optional closing sequence (`## Title ##`) and inline
1752
+ // markers that add nothing to a navigation label.
1753
+ let label = match[2].replace(/\s+#+\s*$/, "").trim();
1754
+ label = label
1755
+ .replace(/`([^`]*)`/g, "$1")
1756
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
1757
+ .replace(/[*_~]{1,3}([^*_~]+)[*_~]{1,3}/g, "$1")
1758
+ .trim();
1759
+ if (label === "") continue;
1760
+ headings.push({ level, text: label, line: index, key: index + "\u0000" + label });
1761
+ }
1762
+ return headings;
1763
+ }
1764
+
1523
1765
  /**
1524
1766
  * Append a file reference to a draft. Mirrors core.js's appendMention; this
1525
1767
  * file is the browser bundle and cannot import from the host half.
@@ -3115,6 +3357,10 @@ window.__ModuleLoader__.load({
3115
3357
  }),
3116
3358
  react.createElement("div", {
3117
3359
  ref,
3360
+ // The id exists so the stylesheet can outrank a theme's glass rule
3361
+ // (an id beats any class/attribute selector, so it does not matter
3362
+ // which sheet loads last). Only one menu is ever mounted at a time.
3363
+ id: CONTEXT_MENU_ID,
3118
3364
  className: "fv-context-menu",
3119
3365
  style: { top, left },
3120
3366
  role: "menu",
@@ -3142,6 +3388,61 @@ window.__ModuleLoader__.load({
3142
3388
  react.createElement("span", { className: "fv-context-item-label" }, item.label)))));
3143
3389
  }
3144
3390
 
3391
+ /**
3392
+ * The outline of a rendered Markdown document.
3393
+ *
3394
+ * Modelled on the session's turn navigator: a compact rail on the right
3395
+ * edge that stays out of the reading column, expands into a labelled list
3396
+ * on demand, marks the section currently on screen, and jumps on click.
3397
+ *
3398
+ * The headings come from the source (see `parseHeadings`), while the
3399
+ * scroll target is the Nth rendered heading element. Matching by position
3400
+ * rather than by text keeps it working when the renderer rewrites labels
3401
+ * (smart quotes, inline code, links) — the two lists are built from the
3402
+ * same document in the same order, so index N in one is index N in the
3403
+ * other.
3404
+ *
3405
+ * @param {{headings: Array<object>, scrollerRef: object, activeKey: string,
3406
+ * open: boolean, onToggle: Function, onPick: Function}} props
3407
+ */
3408
+ function MarkdownOutline({ headings, open, activeKey, onToggle, onPick }) {
3409
+ const rows = Array.isArray(headings) ? headings : [];
3410
+ if (rows.length === 0) return null;
3411
+ // One indent step per nesting level, measured from the shallowest
3412
+ // heading present: a document starting at `##` should not indent every
3413
+ // row by one level of nothing.
3414
+ const baseLevel = rows.reduce((min, row) => Math.min(min, row.level), 6);
3415
+ return react.createElement("div", {
3416
+ className: "fv-outline",
3417
+ "data-open": open ? "true" : "false",
3418
+ },
3419
+ react.createElement("button", {
3420
+ type: "button",
3421
+ className: "fv-outline-toggle",
3422
+ "aria-expanded": open ? "true" : "false",
3423
+ "aria-label": open ? "收起目录" : "展开目录",
3424
+ title: open ? "收起目录" : "目录",
3425
+ onClick: onToggle,
3426
+ }, react.createElement(SvgIconOutline, { size: 15 })),
3427
+ open
3428
+ ? react.createElement("nav", {
3429
+ className: "fv-outline-panel",
3430
+ "aria-label": "文档目录",
3431
+ },
3432
+ react.createElement("div", { className: "fv-outline-head" }, "目录"),
3433
+ react.createElement("div", { className: "fv-outline-list" },
3434
+ ...rows.map((row) => react.createElement("button", {
3435
+ key: row.key,
3436
+ type: "button",
3437
+ className: "fv-outline-item",
3438
+ "data-level": row.level - baseLevel,
3439
+ "aria-current": activeKey === row.key ? "true" : undefined,
3440
+ title: row.text,
3441
+ onClick: () => onPick(row),
3442
+ }, row.text))))
3443
+ : null);
3444
+ }
3445
+
3145
3446
  function apply(ctx) {
3146
3447
  ensureStyles(typeof document === "undefined" ? null : document);
3147
3448
  // `null` is closed; an object is open. Carrying the requesting session lets
@@ -3646,6 +3947,13 @@ window.__ModuleLoader__.load({
3646
3947
  const [raw, setRaw] = react.useState(false);
3647
3948
  const [diffMode, setDiffMode] = react.useState(false);
3648
3949
  const [diffData, setDiffData] = react.useState(null);
3950
+ // The Markdown outline: whether it is expanded, which section is on
3951
+ // screen, and the scroll container it drives. The container is owned
3952
+ // here rather than by the outline so the outline never has to reach
3953
+ // across the tree to find it.
3954
+ const [outlineOpen, setOutlineOpen] = react.useState(false);
3955
+ const [activeHeading, setActiveHeading] = react.useState("");
3956
+ const contentRef = react.useRef(null);
3649
3957
  // A file opened from a commit is read from that commit's blob, never
3650
3958
  // from the working tree: the two differ exactly when the history is
3651
3959
  // interesting, and a deleted file has no working-tree copy at all.
@@ -3716,6 +4024,81 @@ window.__ModuleLoader__.load({
3716
4024
  return () => { live = false; };
3717
4025
  }, [root, meta.path, offset, lang, fromCommit, commit, repo, meta.diffSha, meta.rowPath, meta.oldPath]);
3718
4026
 
4027
+ // ------------------------------------------------------------------
4028
+ // Markdown outline
4029
+ //
4030
+ // These hooks sit ABOVE the early returns below. React matches hooks by
4031
+ // call order, so a hook that only runs once a document has loaded would
4032
+ // change the order between the loading render and the loaded one — the
4033
+ // exact shape of "Minified React error #310" (too many/few hooks).
4034
+ // Everything here therefore reads `state.data` defensively instead of
4035
+ // the post-return `data` binding.
4036
+ // ------------------------------------------------------------------
4037
+ const loaded = state.data;
4038
+ // The outline only exists for a rendered Markdown preview. In source
4039
+ // mode the text is a code block, and a JSON tree has no headings.
4040
+ const outlineVisible = loaded !== null && loaded !== undefined
4041
+ && loaded.kind === "markdown" && typeof loaded.text === "string"
4042
+ && !raw && !diffMode;
4043
+ const outlineText = outlineVisible ? loaded.text : "";
4044
+ const headings = react.useMemo(
4045
+ () => (outlineText === "" ? [] : parseHeadings(outlineText)),
4046
+ [outlineText],
4047
+ );
4048
+
4049
+ /**
4050
+ * The rendered heading elements, in document order.
4051
+ *
4052
+ * Kept as a plain lookup instead of state: the elements are owned by
4053
+ * the renderer and re-created on every render, so caching them would
4054
+ * hand out detached nodes.
4055
+ */
4056
+ const headingElements = () => {
4057
+ const host = contentRef.current;
4058
+ if (!host) return [];
4059
+ return [...host.querySelectorAll("h1,h2,h3,h4,h5,h6")];
4060
+ };
4061
+
4062
+ // Mark the section currently at the top of the reading area. Measured
4063
+ // on scroll rather than with an IntersectionObserver because the
4064
+ // "current" section is the last heading above the fold, which is a
4065
+ // comparison between two positions, not a visibility test.
4066
+ react.useEffect(() => {
4067
+ if (!outlineVisible || headings.length === 0) {
4068
+ setActiveHeading("");
4069
+ return undefined;
4070
+ }
4071
+ const host = contentRef.current;
4072
+ if (!host) return undefined;
4073
+ let frame = 0;
4074
+ const measure = () => {
4075
+ frame = 0;
4076
+ const elements = host.querySelectorAll("h1,h2,h3,h4,h5,h6");
4077
+ if (elements.length === 0) return;
4078
+ const top = host.getBoundingClientRect().top;
4079
+ let current = "";
4080
+ for (let index = 0; index < elements.length; index += 1) {
4081
+ const row = headings[index];
4082
+ if (row === undefined) continue;
4083
+ // 24px of slack keeps a heading that is exactly at the edge from
4084
+ // flickering between two rows while the reader scrolls past it.
4085
+ if (elements[index].getBoundingClientRect().top - top <= 24) current = row.key;
4086
+ else break;
4087
+ }
4088
+ setActiveHeading(current);
4089
+ };
4090
+ const onScroll = () => {
4091
+ if (frame !== 0) return;
4092
+ frame = requestAnimationFrame(measure);
4093
+ };
4094
+ measure();
4095
+ host.addEventListener("scroll", onScroll, { passive: true });
4096
+ return () => {
4097
+ if (frame !== 0) cancelAnimationFrame(frame);
4098
+ host.removeEventListener("scroll", onScroll);
4099
+ };
4100
+ }, [outlineVisible, headings]);
4101
+
3719
4102
  if (state.status === "error") {
3720
4103
  return react.createElement("div", { className: "fv-note fv-error" }, messageOf(state.error));
3721
4104
  }
@@ -3841,14 +4224,50 @@ window.__ModuleLoader__.load({
3841
4224
  ? [diffTab, contentTab, sourceTab]
3842
4225
  : [contentTab, sourceTab, diffTab]).filter((tab) => tab !== null))
3843
4226
  : null;
4227
+
4228
+
4229
+ /** Scroll the section for one outline row into view. */
4230
+ const pickHeading = (row) => {
4231
+ const index = headings.findIndex((candidate) => candidate.key === row.key);
4232
+ const element = headingElements()[index];
4233
+ if (!element) return;
4234
+ const host = contentRef.current;
4235
+ // Scroll the container, not the window: the drawer is its own
4236
+ // scroller and scrollIntoView would also move the page behind it.
4237
+ if (host) {
4238
+ const offset = element.getBoundingClientRect().top - host.getBoundingClientRect().top;
4239
+ const reduce = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
4240
+ host.scrollTo({ top: host.scrollTop + offset - 8, behavior: reduce ? "auto" : "smooth" });
4241
+ } else {
4242
+ element.scrollIntoView({ block: "start" });
4243
+ }
4244
+ setActiveHeading(row.key);
4245
+ // On a phone the panel would cover the text it just jumped to.
4246
+ if (typeof window !== "undefined" && window.innerWidth <= 768) setOutlineOpen(false);
4247
+ };
4248
+
3844
4249
  return react.createElement(react.Fragment, null,
3845
4250
  tabs,
3846
- react.createElement("div", { className: "fv-content", "data-wrap": wrapping ? "on" : "off" },
3847
- previewUnavailable
3848
- ? react.createElement("div", { className: "fv-note" },
3849
- data.kind === "markdown" ? "文档过大,仅显示源码。" : "文件过大,仅显示源码。")
3850
- : null,
3851
- body),
4251
+ react.createElement("div", { className: "fv-content-wrap" },
4252
+ react.createElement("div", {
4253
+ className: "fv-content",
4254
+ "data-wrap": wrapping ? "on" : "off",
4255
+ ref: contentRef,
4256
+ },
4257
+ previewUnavailable
4258
+ ? react.createElement("div", { className: "fv-note" },
4259
+ data.kind === "markdown" ? "文档过大,仅显示源码。" : "文件过大,仅显示源码。")
4260
+ : null,
4261
+ body),
4262
+ outlineVisible
4263
+ ? react.createElement(MarkdownOutline, {
4264
+ headings,
4265
+ open: outlineOpen,
4266
+ activeKey: activeHeading,
4267
+ onToggle: () => setOutlineOpen((current) => !current),
4268
+ onPick: pickHeading,
4269
+ })
4270
+ : null),
3852
4271
  pager);
3853
4272
  }
3854
4273
 
@@ -4016,6 +4435,7 @@ window.__ModuleLoader__.load({
4016
4435
  }),
4017
4436
  react.createElement("div", {
4018
4437
  ref,
4438
+ id: CONTEXT_MENU_ID,
4019
4439
  className: "fv-context-menu",
4020
4440
  style: { top, left },
4021
4441
  role: "menu",
@@ -4071,6 +4491,7 @@ window.__ModuleLoader__.load({
4071
4491
  }
4072
4492
 
4073
4493
 
4494
+
4074
4495
  /** Safe Access Paths Configuration Dialog */
4075
4496
  function SafePathsModal({ open, onClose, onToast, onUpdated, prefillPath }) {
4076
4497
  const [loading, setLoading] = react.useState(false);
@@ -4227,6 +4648,17 @@ window.__ModuleLoader__.load({
4227
4648
  }, [open]);
4228
4649
  const [roots, setRoots] = react.useState([]);
4229
4650
  const [root, setRoot] = react.useState("");
4651
+ // The drawer is a panel, not a modal: whatever layer wraps it must not
4652
+ // dim or blur the app behind it. The CSS in this bundle opts out of the
4653
+ // known cases, and this walks the real DOM for the rest — a theme or
4654
+ // adapter may paint those properties from a rule this plugin cannot name
4655
+ // in advance. Re-applied on every open because those layers re-render.
4656
+ react.useEffect(() => {
4657
+ if (!open) return undefined;
4658
+ const scrim = typeof document === "undefined" ? null : document.querySelector(".fv-scrim");
4659
+ const restore = suppressBackdropEffects(scrim, true);
4660
+ return () => { restore(); };
4661
+ }, [open]);
4230
4662
  // The tree is an expansion set plus a per-directory listing cache; a
4231
4663
  // directory is fetched the first time it opens and then remembered, so
4232
4664
  // collapsing and reopening costs nothing.
@@ -5979,7 +6411,7 @@ window.__ModuleLoader__.load({
5979
6411
  formatCommitDate, formatCommitTimestamp,
5980
6412
  // Content-area tabs: the strip, its four-item menu and the path helper
5981
6413
  // that gives a tab its secondary directory line.
5982
- directoryOf, TabStrip, TabContextMenu,
6414
+ directoryOf, TabStrip, TabContextMenu, parseHeadings, MarkdownOutline, SvgIconOutline,
5983
6415
  TAB_MENU_ITEMS, tabsAfterClose, tabsAfterUpsert,
5984
6416
  };
5985
6417
  return module.exports;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weniatall/dsh-file-viewer",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "Workspace file viewer and Git panel for DeepSeek Harness: file tree, syntax-highlighted code, images, PDF, Excel and Word, plus local/remote branches, paged commit history, commit diffs, working-tree changes and changed-file navigation",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",