@helping-ai-workflow/md2doc 3.2.1 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,11 +11,14 @@
11
11
  else root.md2docToolbarModel = factory();
12
12
  })(typeof self !== 'undefined' ? self : this, function () {
13
13
 
14
- // The 22-button roster, spec §4 table (`export▾` moved out of scope — no
15
- // browser-reachable export path). Six groups, in render order; group order
16
- // here doubles as separator placement for the caller (a separator sits
17
- // between consecutive groups).
14
+ // The 23-button roster, spec §4 table (`export▾` moved out of scope — no
15
+ // browser-reachable export path) plus the v3.4.0 §3 `save` button. Seven
16
+ // groups, in render order; group order here doubles as separator placement
17
+ // for the caller (a separator sits between consecutive groups).
18
18
  const BUTTON_DEFS = [
19
+ // file
20
+ { id: 'save', group: 'file', label: '💾', icon: '💾', title: '儲存 (Ctrl+S)', toggle: false },
21
+
19
22
  // history
20
23
  { id: 'undo', group: 'history', label: 'Undo', icon: '↶', title: '復原 (Ctrl+Z)', toggle: false },
21
24
  { id: 'redo', group: 'history', label: 'Redo', icon: '↷', title: '重做 (Ctrl+Y)', toggle: false },
@@ -29,11 +32,11 @@ const BUTTON_DEFS = [
29
32
  { id: 'check', group: 'block', label: '☑', icon: '☑', title: '待辦清單', toggle: false },
30
33
 
31
34
  // inline
32
- { id: 'bold', group: 'inline', label: 'B', icon: 'B', title: '粗體', toggle: false },
33
- { id: 'italic', group: 'inline', label: 'I', icon: 'I', title: '斜體', toggle: false },
34
- { id: 'strike', group: 'inline', label: 'S', icon: 'S', title: '刪除線', toggle: false },
35
- { id: 'inline-code', group: 'inline', label: '`', icon: '`', title: '行內程式碼', toggle: false },
36
- { id: 'link', group: 'inline', label: '🔗', icon: '🔗', title: '連結', toggle: false },
35
+ { id: 'bold', group: 'inline', label: 'B', icon: 'B', title: '粗體', toggle: true },
36
+ { id: 'italic', group: 'inline', label: 'I', icon: 'I', title: '斜體', toggle: true },
37
+ { id: 'strike', group: 'inline', label: 'S', icon: 'S', title: '刪除線', toggle: true },
38
+ { id: 'inline-code', group: 'inline', label: '`', icon: '`', title: '行內程式碼', toggle: true },
39
+ { id: 'link', group: 'inline', label: '🔗', icon: '🔗', title: '連結', toggle: true },
37
40
 
38
41
  // indent
39
42
  { id: 'outdent', group: 'indent', label: '⇤', icon: '⇤', title: '減少縮排', toggle: false },
@@ -59,14 +62,53 @@ const BUTTON_DEFS = [
59
62
 
60
63
  const BUTTONS = Object.freeze(BUTTON_DEFS.map((b) => Object.freeze(Object.assign({}, b))));
61
64
 
62
- const GROUPS = Object.freeze(['history', 'block', 'inline', 'indent', 'insert', 'view']);
65
+ const GROUPS = Object.freeze(['file', 'history', 'block', 'inline', 'indent', 'insert', 'view']);
63
66
 
64
67
  // Buttons that stay enabled even when there is no block at all (the zeroed
65
68
  // state right after rerenderAll, before any block gets focus/selection).
66
- const NO_BLOCK_ALLOWED = new Set(['undo', 'redo', 'outline', 'preview']);
69
+ // v3.4.0 §3: 'save' is here because unsaved changes have nothing to do with
70
+ // where the caret is — a document can be dirty with no block focused at all.
71
+ // NOTE (ruling P2): this entry does NOT currently change save's behaviour.
72
+ // `state.save.disabled = !dirty` below runs AFTER the no-block loop that
73
+ // reads this set, so it unconditionally overwrites whatever the no-block
74
+ // loop decided for 'save' — today the two never disagree in a way that
75
+ // shows through. This entry is kept anyway because it records the intent
76
+ // ("save must not be gateable on hasBlock") independently of where the
77
+ // `!dirty` line happens to sit; if that line is ever moved above the
78
+ // no-block loop, this set becomes the only thing still enforcing it. Do not
79
+ // delete it as "dead code" without re-checking that ordering.
80
+ const NO_BLOCK_ALLOWED = new Set(['undo', 'redo', 'outline', 'preview', 'save']);
81
+
82
+ // The mark element each inline button toggles. This module cannot ask the
83
+ // DOM which marks a selection sits in, so the client measures that and hands
84
+ // the answer in as `ctx.marks` — keyed by TAG, not by button id, because
85
+ // .ed-seltb carries a U button with no counterpart in this roster and both
86
+ // toolbars are painted from one measurement. The mapping from button to tag
87
+ // has to live somewhere, and here is where the button ids are.
88
+ const MARK_TAG_BY_ID = Object.freeze({
89
+ bold: 'STRONG',
90
+ italic: 'EM',
91
+ strike: 'DEL',
92
+ 'inline-code': 'CODE',
93
+ link: 'A',
94
+ });
67
95
 
68
- // ctx: {blockType, indent, headingDepth, inList, listOrdered, hasSelection, mode}
69
- // -> { [id]: {active: bool, disabled: bool, label?: string} }
96
+ // The inline-format ids, read off the map above rather than retyped. The
97
+ // `hasSelection` rule below used to carry a hand-written copy of this list;
98
+ // the marks rule under it would have been a second.
99
+ const MARK_IDS = Object.freeze(Object.keys(MARK_TAG_BY_ID));
100
+
101
+ // The per-tag values `ctx.marks` may carry, and what each one means for the
102
+ // button that toggles that tag. The client's selectionMarkStates() is what
103
+ // produces them; the names are shared vocabulary, not a re-derivation.
104
+ // 'whole' the selection lies inside one mark of that tag
105
+ // 'partial' the selection overlaps marks of that tag without lying in one
106
+ // 'none' no mark of that tag is involved
107
+ // 'inert' pressing the button would do nothing at all
108
+
109
+ // ctx: {blockType, indent, headingDepth, inList, listOrdered, hasSelection,
110
+ // mode, marks}
111
+ // -> { [id]: {active: bool, mixed: bool, disabled: bool, label?: string} }
70
112
  function deriveState(ctx) {
71
113
  const c = ctx || {};
72
114
  const blockType = c.blockType;
@@ -75,11 +117,12 @@ function deriveState(ctx) {
75
117
  const inList = !!c.inList;
76
118
  const listOrdered = !!c.listOrdered;
77
119
  const mode = c.mode;
120
+ const dirty = !!c.dirty;
78
121
  const hasBlock = blockType !== null && blockType !== undefined;
79
122
 
80
123
  const state = {};
81
124
  for (const b of BUTTONS) {
82
- state[b.id] = { active: false, disabled: false };
125
+ state[b.id] = { active: false, mixed: false, disabled: false };
83
126
  }
84
127
 
85
128
  // v3.2.1: the mode button names the NEXT state, never the current one.
@@ -152,24 +195,79 @@ function deriveState(ctx) {
152
195
  // refuse for reasons this ctx does not carry (a structural list gate, a
153
196
  // block that owns no source line).
154
197
  if (!c.hasSelection) {
155
- for (const id of ['bold', 'italic', 'strike', 'inline-code', 'link']) {
198
+ for (const id of MARK_IDS) {
156
199
  state[id].disabled = true;
157
200
  }
158
201
  }
159
202
 
203
+ // v3.3.0 (F2): what the selection is already marked as. Before this the ctx
204
+ // carried one boolean about the selection and nothing about its identity.
205
+ // MEASURED on the build before this change, reading the fixed toolbar's B
206
+ // over a selection wholly inside a <strong>, one straddling its edge, and one
207
+ // in plain text: aria-pressed came back null on all three — the buttons were
208
+ // `toggle: false`, so the client's aria-pressed write skipped them.
209
+ //
210
+ // `active` and `mixed` are separate booleans rather than one tri-state
211
+ // because `active` is what the toggle-roster test sweeps for and what the
212
+ // rest of this function already speaks. The client turns the pair into
213
+ // aria-pressed true / mixed / false.
214
+ //
215
+ // 'inert' disables. It is NOT the same question as `hasSelection`: that one
216
+ // asks whether the action would refuse the selection outright, this one asks
217
+ // whether the branch the action would actually take is a no-op. A press on
218
+ // an inert button leaves the document exactly as it was.
219
+ const marks = c.marks;
220
+ if (marks) {
221
+ for (const id of MARK_IDS) {
222
+ const m = marks[MARK_TAG_BY_ID[id]];
223
+ state[id].active = m === 'whole';
224
+ state[id].mixed = m === 'partial';
225
+ if (m === 'inert') state[id].disabled = true;
226
+ }
227
+ }
228
+
229
+ // v3.4.0 §3: the save button IS the save-state indicator — grey when the
230
+ // document matches disk, lit when it does not. The truth stays
231
+ // single-sourced: client.js's `documentIsDirty()` — since v3.4.0 batch3
232
+ // Task 7 that is `stack.isDirty() || burstHasUncommittedEdit() ||
233
+ // waveUnwrittenEdit` — arrives as ctx.dirty and is not recomputed here.
234
+ // This runs before the source-mode override below so that override can
235
+ // still give save its own exemption.
236
+ state.save.disabled = !dirty;
237
+
160
238
  // mode 'source' is the full-document raw-source escape hatch: everything
161
239
  // is disabled except the mode button that got you there (id 'preview',
162
- // which in v3.2.1 is the edit <-> source toggle). This overrides every
163
- // disabled flag computed above, and is the ONLY way back out — do not
164
- // narrow it.
240
+ // which in v3.2.1 is the edit <-> source toggle), since T11-2 'outline'
241
+ // (☰), and since v3.4.0 §3 'save'. The mode button is still the ONLY way
242
+ // back to 'edit' — that has not changed. 'outline' stays reachable because
243
+ // it is what opens the sidebar drawer (toggleOutlineSidebar() in
244
+ // lib/editor/client.js), and the fixed `.sidebar-toggle` button that used
245
+ // to reach the drawer another way is hidden in edit mode entirely (see the
246
+ // T11-2 comment in lib/md2doc.js) — a disabled ☰ here would leave source
247
+ // mode with no way to open that drawer at all. 'save' stays reachable
248
+ // because source mode can still hold unsaved changes (the user can type
249
+ // into the source textarea) — see the per-button comment below for the
250
+ // exact shape of the exemption. This override still runs last and still
251
+ // overrides every disabled flag computed above for every other button; do
252
+ // not narrow it for anything but these three ids.
165
253
  if (mode === 'source') {
166
254
  for (const b of BUTTONS) {
167
- state[b.id].disabled = b.id !== 'preview';
255
+ if (b.id === 'preview' || b.id === 'outline') { state[b.id].disabled = false; continue; }
256
+ // v3.4.0 §3: save is a third exemption, for the same shape of reason
257
+ // outline already had one — source mode can still hold unsaved
258
+ // changes (the user can type into the source textarea), so forcing
259
+ // the only save button grey here would make a mode that holds
260
+ // unsaved work with no way out of it. The exemption is from the MODE
261
+ // forcing it grey, not from being grey — a clean document still shows
262
+ // it disabled in source mode (see the `state.save.disabled = !dirty`
263
+ // line above, which already ran and is left untouched here).
264
+ if (b.id === 'save') continue;
265
+ state[b.id].disabled = true;
168
266
  }
169
267
  }
170
268
 
171
269
  return state;
172
270
  }
173
271
 
174
- return { BUTTONS, GROUPS, deriveState };
272
+ return { BUTTONS, GROUPS, MARK_TAG_BY_ID, deriveState };
175
273
  });