mail-editor-pancake 0.0.7 → 0.0.9

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 (33) hide show
  1. package/README.md +174 -3
  2. package/package.json +1 -1
  3. package/packages/blocks/dist/index.d.ts +12 -0
  4. package/packages/blocks/dist/index.js +186 -148
  5. package/packages/blocks/dist/index.js.map +1 -1
  6. package/packages/blocks/src/index.ts +2 -0
  7. package/packages/blocks/src/social.ts +8 -11
  8. package/packages/blocks/src/socialGroup.ts +8 -11
  9. package/packages/blocks/src/socialShared.ts +52 -0
  10. package/packages/blocks/src/text.ts +21 -14
  11. package/packages/core/dist/index.d.ts +99 -5
  12. package/packages/core/dist/index.js +2475 -1480
  13. package/packages/core/dist/index.js.map +1 -1
  14. package/packages/core/dist/style.css +1 -1
  15. package/packages/core/src/editor/Canvas.ts +77 -34
  16. package/packages/core/src/editor/ColorPickerPopover.ts +291 -0
  17. package/packages/core/src/editor/Editor.ts +269 -32
  18. package/packages/core/src/editor/ExportModal.ts +2 -1
  19. package/packages/core/src/editor/FocusBreadcrumb.ts +120 -0
  20. package/packages/core/src/editor/InlineEditor.ts +316 -65
  21. package/packages/core/src/editor/LeftPanel.ts +1 -2
  22. package/packages/core/src/editor/RichTextToolbar.ts +66 -44
  23. package/packages/core/src/editor/RightPanel.ts +491 -74
  24. package/packages/core/src/editor/SourceView.ts +2 -1
  25. package/packages/core/src/editor/Topbar.ts +98 -7
  26. package/packages/core/src/editor/styles.css +427 -33
  27. package/packages/core/src/index.ts +23 -1
  28. package/packages/core/src/store/store.ts +44 -3
  29. package/packages/core/src/types.ts +8 -0
  30. package/packages/core/src/utils/richHtmlInsert.ts +16 -0
  31. package/packages/core/src/utils/richTextCommand.ts +30 -0
  32. package/packages/core/src/utils/sectionLayout.ts +13 -0
  33. package/packages/core/src/variables/index.ts +91 -0
@@ -1,6 +1,6 @@
1
1
  import { Registry, defineBlock } from '../registry/registry';
2
2
  import { renderDoc } from '../renderer';
3
- import { Store, pruneEmptySections } from '../store/store';
3
+ import { Store, createSection, pruneSectionIfEmpty } from '../store/store';
4
4
  import type {
5
5
  BlockDefinition,
6
6
  EditorUiOptions,
@@ -9,7 +9,13 @@ import type {
9
9
  Selection,
10
10
  Variable,
11
11
  } from '../types';
12
+ import {
13
+ buildLinkVariableHtml,
14
+ normalizeVariable,
15
+ variablePlaceholder,
16
+ } from '../variables';
12
17
  import { clear, h } from '../utils/dom';
18
+ import { appendInlineToRichHtml } from '../utils/richHtmlInsert';
13
19
  import { Canvas } from './Canvas';
14
20
  import { ExportModal } from './ExportModal';
15
21
  import { LeftPanel } from './LeftPanel';
@@ -65,6 +71,11 @@ export interface EditorOptions {
65
71
  imageAssets?: ImageAssetsHandlers;
66
72
  /** 右栏控件形态等可选 UI 偏好。 */
67
73
  ui?: EditorUiOptions;
74
+ /**
75
+ * 顶栏「重置内容」恢复的目标文档(与 `initialDoc` 独立;编辑已保存邮件时仍应指向业务预置模板)。
76
+ * 未传时与构造时的 `initialDoc` 合并结果一致。
77
+ */
78
+ presetDoc?: Partial<EmailDoc>;
68
79
  }
69
80
 
70
81
  /**
@@ -96,6 +107,12 @@ export class MailEditor {
96
107
  private changeTimer: number | null = null;
97
108
  /** 显式品牌色时覆盖 CSS 变量;未设置则由 styles 按 light/dark 使用默认紫/靛 */
98
109
  private accentColorOverride: string | undefined;
110
+ /** 顶栏开关:是否在画布上始终显示 Section / Block 轻量虚线边框 */
111
+ private showLayoutBorders = false;
112
+ /** 「重置内容」时恢复的文档快照(与当前画布内容无关) */
113
+ private presetContentDoc: EmailDoc;
114
+ /** 宿主通过 setVariables 注入的列表;setValue 恢复文档后仍会写回,避免被 doc JSON 里的空数组覆盖 */
115
+ private configuredVariables: Variable[] = [];
99
116
  private systemThemeMq: MediaQueryList | null = null;
100
117
  private readonly _onSystemThemeMqChange = () => {
101
118
  if (this.accentColorOverride) this._applyAccentVars();
@@ -157,6 +174,9 @@ export class MailEditor {
157
174
  }
158
175
 
159
176
  const initialDoc = createDefaultDoc(opts.initialDoc);
177
+ this.presetContentDoc = structuredClone(
178
+ createDefaultDoc(opts.presetDoc ?? opts.initialDoc),
179
+ );
160
180
  this.store = new Store(initialDoc);
161
181
 
162
182
  const initialAccent = normalizeAccentHex(this.opts.accentColor ?? '');
@@ -191,22 +211,91 @@ export class MailEditor {
191
211
  setValue(doc: EmailDoc) {
192
212
  this._blurRightPanelIfFocused();
193
213
  this.store.replace(doc);
214
+ this._applyConfiguredVariables();
194
215
  }
195
216
 
196
217
  getValue(): EmailDoc {
197
218
  return this.store.doc;
198
219
  }
199
220
 
200
- setVariables(vars: Variable[]) {
221
+ /**
222
+ * 清空画布:移除所有 Section/Block,保留 meta、styles、variables。
223
+ * 会提交内联编辑并清空选中。
224
+ */
225
+ clearCanvas(): void {
226
+ this.canvas.commitInlineEdit();
227
+ this._blurRightPanelIfFocused();
201
228
  this.store.update((d) => {
202
- d.variables = vars;
229
+ d.sections = [];
203
230
  });
231
+ this.store.setSelection(null);
232
+ }
233
+
234
+ /** 将画布恢复为构造时 `presetDoc`(或 `initialDoc`)对应的预置内容。 */
235
+ resetToPreset(): void {
236
+ this._blurRightPanelIfFocused();
237
+ this.store.replace(structuredClone(this.presetContentDoc));
238
+ this._applyConfiguredVariables();
239
+ this.store.setSelection(null);
240
+ }
241
+
242
+ /** 更新「重置内容」的目标(例如宿主异步加载默认模板后)。 */
243
+ setPresetDoc(partial: Partial<EmailDoc>): void {
244
+ this.presetContentDoc = structuredClone(createDefaultDoc(partial));
245
+ }
246
+
247
+ setVariables(vars: Variable[]) {
248
+ this.configuredVariables = vars.map(normalizeVariable);
249
+ this._applyConfiguredVariables();
204
250
  }
205
251
 
206
252
  getVariables(): Variable[] {
253
+ if (this.configuredVariables.length) return this.configuredVariables;
207
254
  return this.store.doc.variables;
208
255
  }
209
256
 
257
+ private _applyConfiguredVariables() {
258
+ if (!this.configuredVariables.length) return;
259
+ const snapshot = this.configuredVariables.map((v) => ({ ...v }));
260
+ this.store.update((d) => {
261
+ d.variables = snapshot;
262
+ });
263
+ }
264
+
265
+ /**
266
+ * 插入变量 key(`{{key}}`)。
267
+ * @returns 是否成功插入
268
+ */
269
+ insertVariableKey(v: Variable): boolean {
270
+ const normalized = normalizeVariable(v);
271
+ return this._insertAtFocus(variablePlaceholder(normalized.key), false);
272
+ }
273
+
274
+ /**
275
+ * 插入链接 / 图片变量对应元素(`<a>` / image 块等)。
276
+ * 非 link/image 时退化为 insertVariableKey。
277
+ */
278
+ insertVariableElement(v: Variable): boolean {
279
+ const normalized = normalizeVariable(v);
280
+ if (normalized.kind === 'image') {
281
+ return this._insertImageVariable(normalized);
282
+ }
283
+ if (normalized.kind === 'link') {
284
+ const token = variablePlaceholder(normalized.key);
285
+ const html = buildLinkVariableHtml(
286
+ token,
287
+ this.store.doc.styles.linkColor || '#ff5a00',
288
+ );
289
+ return this._insertAtFocus(html, true);
290
+ }
291
+ return this.insertVariableKey(normalized);
292
+ }
293
+
294
+ /** 同 {@link insertVariableKey} */
295
+ insertVariable(v: Variable): boolean {
296
+ return this.insertVariableKey(v);
297
+ }
298
+
210
299
  /** 当前界面主题(`system` 不解析为 light/dark)。 */
211
300
  getTheme(): EditorTheme {
212
301
  return this.theme;
@@ -345,6 +434,11 @@ export class MailEditor {
345
434
  onExport: () => this._showExport(),
346
435
  showFullscreenButton: this.opts.ui?.hideTopbarFullscreen !== true,
347
436
  onFullscreenToggle: () => void this._toggleFullscreen(),
437
+ onLayoutBordersToggle: () => this._toggleLayoutBorders(),
438
+ showClearCanvasButton: this.opts.ui?.hideTopbarClearCanvas !== true,
439
+ onClearCanvas: () => this.clearCanvas(),
440
+ showResetContentButton: this.opts.ui?.hideTopbarResetContent !== true,
441
+ onResetContent: () => this.resetToPreset(),
348
442
  });
349
443
 
350
444
  this.toolbar = new RichTextToolbar({ positionRoot: this.root });
@@ -362,6 +456,12 @@ export class MailEditor {
362
456
  this.rightPanel = new RightPanel({
363
457
  store: this.store,
364
458
  registry: this.registry,
459
+ docRootLabel: this.opts.ui?.hideMailMeta ? '版式' : '邮件',
460
+ onFocusSection: (sectionId) => {
461
+ this.canvas.commitInlineEdit();
462
+ this.store.setSelection({ kind: 'section', sectionId });
463
+ },
464
+ onFocusDocument: () => this._focusMailSettings(),
365
465
  imageAssets: this.opts.imageAssets,
366
466
  ui: this.opts.ui,
367
467
  });
@@ -424,7 +524,7 @@ export class MailEditor {
424
524
  const col = sec.columns[sel.columnIndex];
425
525
  if (!col) return;
426
526
  col.blocks = col.blocks.filter((b) => b.id !== sel.blockId);
427
- pruneEmptySections(d);
527
+ pruneSectionIfEmpty(d, sel.sectionId);
428
528
  });
429
529
  this.store.setSelection(null);
430
530
  }
@@ -445,9 +545,11 @@ export class MailEditor {
445
545
  }
446
546
 
447
547
  private _showVariablePopover(anchor: HTMLElement) {
448
- const vars = this.store.doc.variables;
548
+ this.canvas.currentInlineEditor?.saveSelection();
549
+
550
+ const vars = this.getVariables();
449
551
  if (!vars.length) {
450
- alert('当前未配置变量。请先调用 editor.setVariables([...])');
552
+ this._showToast('暂无可用变量');
451
553
  return;
452
554
  }
453
555
  const existing = this.root.querySelector('.sm-popover');
@@ -456,25 +558,72 @@ export class MailEditor {
456
558
  return;
457
559
  }
458
560
 
459
- const pop = h('div', { class: 'sm-popover' });
561
+ const pop = h('div', { class: 'sm-popover sm-popover--variables' });
562
+ let onDocClick: ((ev: MouseEvent) => void) | null = null;
563
+ const dismissPopover = () => {
564
+ pop.remove();
565
+ if (onDocClick) document.removeEventListener('click', onDocClick, true);
566
+ };
567
+
460
568
  for (const v of vars) {
461
- pop.append(
569
+ const token = variablePlaceholder(v.key);
570
+ const row = h('div', { class: 'sm-popover__row' });
571
+ const actions = h('div', { class: 'sm-popover__actions' });
572
+ if (v.kind === 'link' || v.kind === 'image') {
573
+ actions.append(
574
+ h(
575
+ 'button',
576
+ {
577
+ class: 'sm-popover__action',
578
+ type: 'button',
579
+ onclick: (e: Event) => {
580
+ e.stopPropagation();
581
+ this.insertVariableElement(v);
582
+ dismissPopover();
583
+ },
584
+ },
585
+ ['插入元素'],
586
+ ),
587
+ );
588
+ }
589
+ actions.append(
590
+ h(
591
+ 'button',
592
+ {
593
+ class: 'sm-popover__action sm-popover__action--copy',
594
+ type: 'button',
595
+ title: `复制 ${token}`,
596
+ onclick: (e: Event) => {
597
+ e.stopPropagation();
598
+ void copyVariableToken(token).then((ok) => {
599
+ if (!ok) return;
600
+ dismissPopover();
601
+ this._showToast('已复制变量');
602
+ });
603
+ },
604
+ },
605
+ ['复制'],
606
+ ),
607
+ );
608
+ row.append(
462
609
  h(
463
610
  'button',
464
611
  {
465
612
  class: 'sm-popover__item',
466
613
  type: 'button',
467
614
  onclick: () => {
468
- this._insertVariable(v);
469
- pop.remove();
615
+ this.insertVariableKey(v);
616
+ dismissPopover();
470
617
  },
471
618
  },
472
619
  [
473
- h('span', { class: 'sm-popover__key' }, [`{{${v.key}}}`]),
474
620
  h('span', { class: 'sm-popover__label' }, [v.label]),
621
+ h('span', { class: 'sm-popover__key' }, [token]),
475
622
  ],
476
623
  ),
624
+ actions,
477
625
  );
626
+ pop.append(row);
478
627
  }
479
628
 
480
629
  const rect = anchor.getBoundingClientRect();
@@ -483,27 +632,62 @@ export class MailEditor {
483
632
  pop.style.left = `${rect.left - rootRect.left}px`;
484
633
  this.root.append(pop);
485
634
 
486
- const onDocClick = (ev: MouseEvent) => {
635
+ onDocClick = (ev: MouseEvent) => {
487
636
  if (!pop.contains(ev.target as Node) && ev.target !== anchor) {
488
- pop.remove();
489
- document.removeEventListener('click', onDocClick, true);
637
+ dismissPopover();
490
638
  }
491
639
  };
492
- setTimeout(() => document.addEventListener('click', onDocClick, true), 0);
493
- }
494
-
495
- private _insertVariable(v: Variable) {
640
+ setTimeout(() => {
641
+ if (onDocClick) document.addEventListener('click', onDocClick, true);
642
+ }, 0);
643
+ }
644
+
645
+ private _showToast(text: string) {
646
+ const t = h('div', { class: 'sm-toast' }, [text]);
647
+ this.root.append(t);
648
+ requestAnimationFrame(() => t.classList.add('is-visible'));
649
+ setTimeout(() => {
650
+ t.classList.remove('is-visible');
651
+ setTimeout(() => t.remove(), 200);
652
+ }, 1400);
653
+ }
654
+
655
+ private _insertImageVariable(v: Variable): boolean {
656
+ const token = variablePlaceholder(v.key);
657
+ const block = this.registry.createBlock('image');
658
+ const props = block.props as { src: string; alt: string };
659
+ props.src = token;
660
+ props.alt = v.label?.trim() || v.key;
496
661
  const sel = this.store.selection;
497
- const placeholder = `{{${v.key}}}`;
662
+ this.store.update((d) => {
663
+ if (sel?.kind === 'block') {
664
+ const sec = d.sections.find((s) => s.id === sel.sectionId);
665
+ const col = sec?.columns[sel.columnIndex];
666
+ if (col) {
667
+ const idx = col.blocks.findIndex((b) => b.id === sel.blockId);
668
+ col.blocks.splice(idx >= 0 ? idx + 1 : col.blocks.length, 0, block);
669
+ return;
670
+ }
671
+ }
672
+ let section = d.sections[d.sections.length - 1];
673
+ if (!section) {
674
+ section = createSection('1');
675
+ d.sections.push(section);
676
+ }
677
+ section.columns[0]?.blocks.push(block);
678
+ });
679
+ return true;
680
+ }
498
681
 
499
- // 最高优:正在画布内联编辑的富文本光标处
682
+ /** 在焦点处插入文本或 HTML;失败时返回 false */
683
+ private _insertAtFocus(content: string, asHtml: boolean): boolean {
500
684
  const inline = this.canvas.currentInlineEditor;
501
685
  if (inline) {
502
- inline.insertText(placeholder);
503
- return;
686
+ if (asHtml) inline.insertHtml(content);
687
+ else inline.insertText(content);
688
+ return true;
504
689
  }
505
690
 
506
- // 次之:当前聚焦的 input/textarea(属性面板)
507
691
  const active = document.activeElement as HTMLInputElement | HTMLTextAreaElement | null;
508
692
  if (
509
693
  active &&
@@ -512,14 +696,14 @@ export class MailEditor {
512
696
  ) {
513
697
  const start = active.selectionStart ?? active.value.length;
514
698
  const end = active.selectionEnd ?? active.value.length;
515
- active.value = active.value.slice(0, start) + placeholder + active.value.slice(end);
516
- const ev = new Event('input', { bubbles: true });
517
- active.dispatchEvent(ev);
518
- return;
699
+ active.value = active.value.slice(0, start) + content + active.value.slice(end);
700
+ active.dispatchEvent(new Event('input', { bubbles: true }));
701
+ return true;
519
702
  }
520
703
 
521
- // 最次:选中 Block 时追加到该 block 的 inlineEditable 字段或第一个文本字段
704
+ const sel = this.store.selection;
522
705
  if (sel?.kind === 'block') {
706
+ let updated = false;
523
707
  this.store.update((d) => {
524
708
  for (const s of d.sections) {
525
709
  for (const c of s.columns) {
@@ -530,16 +714,40 @@ export class MailEditor {
530
714
  def?.inlineEditable?.propKey ??
531
715
  def?.schema.find((f) => f.type === 'text' || f.type === 'textarea')?.key;
532
716
  if (key) {
533
- const cur = String((b.props as any)[key] ?? '');
534
- (b.props as any)[key] = cur + placeholder;
717
+ const cur = String((b.props as Record<string, unknown>)[key] ?? '');
718
+ const richInline = def?.inlineEditable && def.inlineEditable.mode !== 'plain';
719
+ (b.props as Record<string, unknown>)[key] = richInline
720
+ ? appendInlineToRichHtml(cur, content)
721
+ : cur + content;
722
+ updated = true;
535
723
  }
536
724
  }
537
725
  }
538
726
  });
539
- return;
727
+ if (updated) return true;
540
728
  }
541
729
 
542
- alert('请先双击进入文本编辑、或聚焦一个输入框后再插入变量');
730
+ const block = this.registry.createBlock('text');
731
+ const wrapped = appendInlineToRichHtml('', content);
732
+ (block.props as { content: string }).content = wrapped;
733
+ this.store.update((d) => {
734
+ let section = d.sections[d.sections.length - 1];
735
+ if (!section) {
736
+ section = createSection('1');
737
+ d.sections.push(section);
738
+ }
739
+ if (sel?.kind === 'block') {
740
+ const sec = d.sections.find((s) => s.id === sel.sectionId);
741
+ const col = sec?.columns[sel.columnIndex];
742
+ if (col) {
743
+ const idx = col.blocks.findIndex((b) => b.id === sel.blockId);
744
+ col.blocks.splice(idx >= 0 ? idx + 1 : col.blocks.length, 0, block);
745
+ return;
746
+ }
747
+ }
748
+ section.columns[0]?.blocks.push(block);
749
+ });
750
+ return true;
543
751
  }
544
752
 
545
753
  private _showExport() {
@@ -582,6 +790,35 @@ export class MailEditor {
582
790
  console.warn('[simple-mail] 全屏不可用:', err);
583
791
  }
584
792
  }
793
+
794
+ private _toggleLayoutBorders() {
795
+ this.showLayoutBorders = !this.showLayoutBorders;
796
+ this.root.classList.toggle('sm-show-layout-borders', this.showLayoutBorders);
797
+ this.topbar.setLayoutBordersActive(this.showLayoutBorders);
798
+ }
799
+ }
800
+
801
+ async function copyVariableToken(token: string): Promise<boolean> {
802
+ try {
803
+ await navigator.clipboard.writeText(token);
804
+ return true;
805
+ } catch {
806
+ // fallback
807
+ }
808
+ try {
809
+ const ta = document.createElement('textarea');
810
+ ta.value = token;
811
+ ta.setAttribute('readonly', 'true');
812
+ ta.style.position = 'fixed';
813
+ ta.style.left = '-9999px';
814
+ document.body.appendChild(ta);
815
+ ta.select();
816
+ const ok = document.execCommand('copy');
817
+ document.body.removeChild(ta);
818
+ return ok;
819
+ } catch {
820
+ return false;
821
+ }
585
822
  }
586
823
 
587
824
  function smGetFullscreenElement(): Element | null {
@@ -6,6 +6,7 @@ import { renderDoc } from '../renderer';
6
6
  import type { Store } from '../store/store';
7
7
  import { prettyHtml } from '../utils/html';
8
8
  import { h } from '../utils/dom';
9
+ import { richTextExecCommand } from '../utils/richTextCommand';
9
10
  import { Modal } from './Modal';
10
11
 
11
12
  export interface ExportModalOptions {
@@ -99,7 +100,7 @@ export class ExportModal {
99
100
  ta.value = this.current;
100
101
  document.body.appendChild(ta);
101
102
  ta.select();
102
- document.execCommand('copy');
103
+ richTextExecCommand('copy');
103
104
  document.body.removeChild(ta);
104
105
  this._toast('已复制');
105
106
  }
@@ -0,0 +1,120 @@
1
+ import type { Registry } from '../registry/registry';
2
+ import type { Store } from '../store/store';
3
+ import { findBlockLocation, findSection } from '../store/store';
4
+ import { clear, h } from '../utils/dom';
5
+ import { layoutHumanLabel } from '../utils/sectionLayout';
6
+
7
+ export interface FocusBreadcrumbOptions {
8
+ store: Store;
9
+ registry: Registry;
10
+ /** 文档级根节点文案(如「邮件」/「版式」) */
11
+ docRootLabel: string;
12
+ onFocusDocument: () => void;
13
+ onFocusSection: (sectionId: string) => void;
14
+ }
15
+
16
+ interface CrumbItem {
17
+ label: string;
18
+ current: boolean;
19
+ onClick?: () => void;
20
+ }
21
+
22
+ /**
23
+ * 右栏聚焦路径:邮件 > 一列 > 文本。
24
+ * 点击父级等同 Esc 上浮(块 → Section → 文档级面板)。
25
+ */
26
+ export class FocusBreadcrumb {
27
+ el: HTMLElement;
28
+ private opts: FocusBreadcrumbOptions;
29
+
30
+ constructor(opts: FocusBreadcrumbOptions) {
31
+ this.opts = opts;
32
+ this.el = h('nav', {
33
+ class: 'sm-focus-crumb',
34
+ 'aria-label': '当前选中',
35
+ });
36
+ opts.store.subscribeSelection(() => this.sync());
37
+ opts.store.subscribe(() => this.sync());
38
+ this.sync();
39
+ }
40
+
41
+ setVisible(on: boolean) {
42
+ this.el.hidden = !on;
43
+ }
44
+
45
+ sync() {
46
+ clear(this.el);
47
+ const items = this._buildItems();
48
+ if (!items.length) {
49
+ this.el.hidden = true;
50
+ return;
51
+ }
52
+ this.el.hidden = false;
53
+
54
+ items.forEach((item, i) => {
55
+ if (i > 0) {
56
+ this.el.append(h('span', { class: 'sm-focus-crumb__sep', 'aria-hidden': 'true' }, ['›']));
57
+ }
58
+ if (item.current || !item.onClick) {
59
+ this.el.append(
60
+ h(
61
+ 'span',
62
+ {
63
+ class: `sm-focus-crumb__item sm-focus-crumb__item--current`,
64
+ title: item.label,
65
+ },
66
+ [item.label],
67
+ ),
68
+ );
69
+ } else {
70
+ this.el.append(
71
+ h(
72
+ 'button',
73
+ {
74
+ type: 'button',
75
+ class: 'sm-focus-crumb__item',
76
+ title: `选中:${item.label}`,
77
+ onclick: () => item.onClick!(),
78
+ },
79
+ [item.label],
80
+ ),
81
+ );
82
+ }
83
+ });
84
+ }
85
+
86
+ private _buildItems(): CrumbItem[] {
87
+ const { store, registry, docRootLabel, onFocusDocument, onFocusSection } = this.opts;
88
+ const sel = store.selection;
89
+ const doc = store.doc;
90
+
91
+ if (!sel) {
92
+ return [{ label: docRootLabel, current: true }];
93
+ }
94
+
95
+ if (sel.kind === 'section') {
96
+ const sec = findSection(doc, sel.sectionId);
97
+ if (!sec) return [{ label: docRootLabel, current: true }];
98
+ return [
99
+ { label: docRootLabel, current: false, onClick: onFocusDocument },
100
+ { label: layoutHumanLabel(sec.layout), current: true },
101
+ ];
102
+ }
103
+
104
+ const sec = findSection(doc, sel.sectionId);
105
+ const loc = findBlockLocation(doc, sel.blockId);
106
+ if (!sec || !loc) {
107
+ return [{ label: docRootLabel, current: true }];
108
+ }
109
+
110
+ const blockName = registry.get(loc.block.type)?.name ?? loc.block.type;
111
+ return [
112
+ {
113
+ label: layoutHumanLabel(sec.layout),
114
+ current: false,
115
+ onClick: () => onFocusSection(sel.sectionId),
116
+ },
117
+ { label: blockName, current: true },
118
+ ];
119
+ }
120
+ }