@tobq/boardclip 2.1.113 → 2.1.114

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.
@@ -14,10 +14,20 @@
14
14
 
15
15
  function isPinned(item) { return item && item.pin != null; }
16
16
  function numpadOf(item) { return item && item.pin && typeof item.pin.number === 'number' ? item.pin.number : null; }
17
+ // A clip's tags, one entry per tag (tags ignore case: the first spelling
18
+ // met is kept, as the model's uniqueTags does).
17
19
  function groupsOf(item) {
18
- return item && item.pin && Array.isArray(item.pin.groups) ? [...new Set(item.pin.groups)] : [];
20
+ const list = item && item.pin && Array.isArray(item.pin.groups) ? item.pin.groups : [];
21
+ const seen = new Set();
22
+ return list.filter((g) => {
23
+ const key = Search.tagKey(g);
24
+ if (!key || seen.has(key)) return false;
25
+ seen.add(key);
26
+ return true;
27
+ });
19
28
  }
20
- function isInGroup(item, group) { return groupsOf(item).includes(group); }
29
+ // Tags are flat and case-insensitive (Search.sameTag): "Forge" is "forge".
30
+ function isInGroup(item, group) { return groupsOf(item).some((g) => Search.sameTag(g, group)); }
21
31
  function cleanTitle(value) { return String(value == null ? '' : value).replace(/\s+/g, ' ').trim(); }
22
32
  function titleOf(item) { return cleanTitle(item && item.title); }
23
33
  function itemId(item) { return item && item.id; }
@@ -114,135 +124,74 @@
114
124
  ...groupsOf(item),
115
125
  ].join(' ');
116
126
  }
117
- function normalizeTagName(group) {
118
- return String(group || '').split('/').map(part => part.trim()).filter(Boolean).join('/');
119
- }
120
- function tagParentPaths(group) {
121
- const name = normalizeTagName(group);
122
- if (!name) return [];
123
- const parts = name.split('/');
124
- const paths = [];
125
- for (let i = 1; i <= parts.length; i += 1) paths.push(parts.slice(0, i).join('/'));
126
- return paths;
127
- }
128
- function tagMatchesFilter(group, filter) {
129
- const tag = normalizeTagName(group);
130
- const parent = normalizeTagName(filter);
131
- return !!parent && (tag === parent || tag.startsWith(`${parent}/`));
132
- }
133
- function itemMatchesGroupFilter(item, filter) {
134
- return groupsOf(item).some(group => tagMatchesFilter(group, filter));
135
- }
136
- function groupFilterCount(items, filter) {
137
- const key = normalizeTagName(filter);
138
- if (!key) return 0;
139
- return (items || []).filter(item => itemMatchesGroupFilter(item, key)).length;
140
- }
141
127
  function sourceGroupsFromFilters(filters) {
142
128
  return [...asFilterSet(filters)]
143
- .map(normalizeTagName)
129
+ .map(Search.cleanTagName)
144
130
  .filter(group => group && !group.startsWith('__'));
145
131
  }
146
- // The group tree, siblings ordered by order.tier(path) first when given
147
- // (the chip row: usable chips before greyed ones), then by use
148
- // (order.weights = Search.groupWeights: decayed clip usage), then by name.
149
- function buildTagTree(groups, order) {
132
+ // The tags in display order, one per tag (case-insensitive: the first
133
+ // spelling met is kept): order.tier(name) first when given (the chip row:
134
+ // usable chips before greyed ones), then by use (order.weights =
135
+ // Search.groupWeights: decayed clip usage), then by name.
136
+ function sortTags(groups, order) {
150
137
  const o = order || {};
151
- const roots = [];
152
- const byName = new Map();
153
- const sourceGroups = [...new Set((groups || []).map(normalizeTagName).filter(Boolean))];
154
- function ensureNode(name, stored) {
155
- const tag = normalizeTagName(name);
156
- if (!tag) return null;
157
- let node = byName.get(tag);
158
- if (!node) {
159
- node = { name: tag, label: tag.split('/').pop(), depth: tag.split('/').length - 1, stored: false, children: [] };
160
- byName.set(tag, node);
161
- const slash = tag.lastIndexOf('/');
162
- if (slash >= 0) {
163
- const parent = ensureNode(tag.slice(0, slash), false);
164
- if (parent && !parent.children.includes(node)) parent.children.push(node);
165
- } else if (!roots.includes(node)) roots.push(node);
166
- }
167
- if (stored) node.stored = true;
168
- return node;
169
- }
170
- for (const group of sourceGroups) {
171
- for (const path of tagParentPaths(group)) ensureNode(path, path === group);
138
+ const seen = new Set();
139
+ const names = [];
140
+ for (const raw of groups || []) {
141
+ const name = Search.cleanTagName(raw);
142
+ const key = Search.tagKey(name);
143
+ if (!key || seen.has(key)) continue;
144
+ seen.add(key);
145
+ names.push(name);
172
146
  }
173
147
  const byUse = Search.compareGroupUse(o.weights || null);
174
- const sortNodes = (nodes) => {
175
- nodes.sort((a, b) => (o.tier ? o.tier(a.name) - o.tier(b.name) : 0) || byUse(a.name, b.name));
176
- nodes.forEach(node => sortNodes(node.children));
177
- };
178
- sortNodes(roots);
179
- return roots;
180
- }
181
- function renderTagTreeMenu(nodes, options, depth) {
182
- const opts = options || {};
183
- const level = Number(depth) || 0;
184
- return (nodes || []).map((node) => renderTagTreeMenuNode(node, opts, level)).join('');
185
- }
186
- function renderTagTreeMenuNode(node, options, depth) {
187
- const opts = options || {};
188
- const mode = opts.mode || 'filter';
189
- const isFilter = mode === 'filter';
190
- const group = normalizeTagName(node && node.name);
191
- if (!group) return '';
192
- // Picker mode (a clip's group checklist) is the shared menu row tree.
193
- if (!isFilter) {
194
- const itemGroups = asFilterSet(opts.itemGroups);
195
- return renderGroupChecklist([node], (g) => (itemGroups.has(g) ? 'all' : 'none'), 'toggle-group');
196
- }
197
- const activeFilters = asFilterSet(opts.activeFilters);
198
- const excludedFilters = asFilterSet(opts.excludedFilters);
199
- const text = escapeHtml(node.label || group);
200
- const hasChildren = !!(node.children && node.children.length);
201
- const treeClass = `${hasChildren ? ' has-children' : ''}${node.stored ? '' : ' virtual'}`;
202
- // opts.verdict(group): the availability census's word on this chip (its
203
- // count under the other filters; greyed + inert when it would show nothing).
204
- const v = opts.verdict ? opts.verdict({ kind: 'group', value: group }) : null;
205
- const count = v ? v.count : groupFilterCount(opts.items || [], group);
206
- const disabled = !!(v && !v.enabled);
207
- const title = excludedFilters.has(group)
208
- ? `Excluding ${group}`
209
- : disabled ? `${group} - ${v.reason}` : `${group} - ${count} item${count !== 1 ? 's' : ''}`;
210
- const state = activeFilters.has(group) ? 'include' : excludedFilters.has(group) ? 'exclude' : '';
211
- // Inside a chip's dropdown a group is the shared menu row (menuRowHtml):
212
- // the include check / exclude glyph in the icon column, the count as its
213
- // hint, the label struck through while excluded. Click includes and
214
- // right-click excludes (the controller's FILTER_TARGET); a greyed row is
215
- // inert like a greyed chip (aria-disabled, never `disabled`, which would
216
- // swallow the events the guard reads).
217
- if (depth > 0) {
218
- const row = {
219
- icon: state === 'include' ? 'check' : state === 'exclude' ? 'block' : '',
220
- check: state === 'include',
221
- label: node.label || group,
222
- hint: disabled ? '' : String(count),
223
- cls: `group-filter-row group-name${state === 'exclude' ? ' excluded' : ''}${disabled ? ` is-disabled${v.kind === 'structural' ? ' dis-structural' : ''}` : ''}`,
224
- attrs: { 'data-group': group, title, 'aria-disabled': disabled ? 'true' : null },
225
- };
226
- return hasChildren
227
- ? `<div class="tag-menu-node has-children">${menuRowHtml({ ...row, caret: true })}<div class="tag-submenu" role="menu">${renderTagTreeMenu(node.children, opts, depth + 1)}</div></div>`
228
- : menuRowHtml(row);
229
- }
230
- const caret = hasChildren ? '<span class="tag-caret mi" aria-hidden="true">expand_more</span>' : '';
231
- // A filter chip only filters: deleting a group lives in Settings > Groups
232
- // (a hover x here deleted the whole group, 14 px from the filter toggle).
233
- const control = renderChip({
234
- cls: `group-tag${treeClass}${disabledChipClass(disabled ? { disabled, disabledKind: v.kind } : null)}`,
235
- state,
236
- attrs: { 'data-group': group, title, 'aria-label': group, 'aria-disabled': disabled ? 'true' : null },
237
- html: `<span class="tag-label">${text}</span>${caret}`,
238
- });
239
- // popover="manual": the chip row is a one-line strip that scrolls under a
240
- // mask, which would clip a nested submenu; installSubmenuAutoflip shows it
241
- // in the top layer instead, beside its chip.
242
- const children = hasChildren
243
- ? `<div class="tag-submenu" role="menu" popover="manual">${renderTagTreeMenu(node.children, opts, depth + 1)}</div>`
244
- : '';
245
- return `<span class="tag-menu-node${hasChildren ? ' has-children' : ''}">${control}${children}</span>`;
148
+ return names.sort((a, b) => (o.tier ? o.tier(a) - o.tier(b) : 0) || byUse(a, b));
149
+ }
150
+ // A set of names keyed by tag (the query's group values may be written in
151
+ // any case: group:Forge selects the forge chip).
152
+ function tagKeySet(names) {
153
+ const keys = new Set();
154
+ for (const name of names || []) { const key = Search.tagKey(name); if (key) keys.add(key); }
155
+ return keys;
156
+ }
157
+ // A group's clip count: from counts (a Map or object of name -> count, the
158
+ // app's per-history tally) when given, else counted over items; either way
159
+ // by tag, whatever the case.
160
+ function tagCounter(items, counts) {
161
+ const byKey = new Map();
162
+ if (counts) {
163
+ const entries = counts instanceof Map ? counts.entries() : Object.entries(counts);
164
+ for (const [name, n] of entries) { const key = Search.tagKey(name); byKey.set(key, (byKey.get(key) || 0) + (Number(n) || 0)); }
165
+ } else {
166
+ for (const item of items || []) for (const key of tagKeySet(groupsOf(item))) byKey.set(key, (byKey.get(key) || 0) + 1);
167
+ }
168
+ return (group) => byKey.get(Search.tagKey(group)) || 0;
169
+ }
170
+ // The chip row's group chips, one per tag. A chip only filters: click
171
+ // includes, right-click excludes (the controller's FILTER_TARGET); deleting
172
+ // a group lives in Settings > Groups (a hover x here deleted the whole
173
+ // group, 14 px from the filter toggle).
174
+ // opts: { activeKeys, excludedKeys (tagKeySet), countOf(group),
175
+ // verdict(token)? (the availability census: a chip that would
176
+ // show nothing is greyed + inert, its reason the tooltip) }
177
+ function renderGroupChips(names, opts) {
178
+ const o = opts || {};
179
+ return (names || []).map((group) => {
180
+ const key = Search.tagKey(group);
181
+ const v = o.verdict ? o.verdict({ kind: 'group', value: group }) : null;
182
+ const count = v ? v.count : o.countOf(group);
183
+ const disabled = !!(v && !v.enabled);
184
+ const state = o.activeKeys.has(key) ? 'include' : o.excludedKeys.has(key) ? 'exclude' : '';
185
+ const title = state === 'exclude'
186
+ ? `Excluding ${group}`
187
+ : disabled ? `${group} - ${v.reason}` : `${group} - ${count} item${count !== 1 ? 's' : ''}`;
188
+ return renderChip({
189
+ cls: `group-tag${disabledChipClass(disabled ? { disabled, disabledKind: v.kind } : null)}`,
190
+ state,
191
+ attrs: { 'data-group': group, title, 'aria-label': group, 'aria-disabled': disabled ? 'true' : null },
192
+ html: `<span class="tag-label">${escapeHtml(group)}</span>`,
193
+ });
194
+ }).join('');
246
195
  }
247
196
  function asFilterSet(value) {
248
197
  if (value instanceof Set) return value;
@@ -555,30 +504,31 @@
555
504
  });
556
505
  }
557
506
  // Groups: the selection first, as one cluster led by its or / and (once
558
- // two or more groups are joined, or one under a remembered "and"), then the
507
+ // two or more groups are joined, or one under "and" - the default, which
508
+ // greys what it would empty, so the toggle is the way to "or" - or one
509
+ // under a remembered "or", the way back: Search.dimConnective), then the
559
510
  // excluded ones; then the chips that would show something, then the greyed
560
511
  // ones (the row is one line, so greyed chips never push usable ones out of
561
512
  // sight); each part by use. The cluster holds exactly the values in the
562
- // query: a sub-group is its own chip there under its full name (work/api),
563
- // while its parent stays with the rest. A chip that is selected or
513
+ // query, matched by tag whatever their case. A chip that is selected or
564
514
  // unselected glides to its new place (flipChildren, the consumer's paint).
565
515
  const tier = verdict ? (g) => { const v = verdict({ kind: 'group', value: g }); return v.enabled ? 0 : v.kind === 'structural' ? 2 : 1; } : null;
566
516
  const weights = options.groupWeights || (census && census.groupWeights) || Search.groupWeights(items);
567
- const tree = buildTagTree(groups, { weights, tier });
568
- const flat = [];
569
- const walk = (nodes) => nodes.forEach((node) => { flat.push(node); walk(node.children); });
570
- walk(tree);
571
- const fullName = (node) => (node.name.includes('/') ? { ...node, label: node.name } : node);
572
- const selected = flat.filter((node) => activeFilters.has(node.name)).map(fullName);
573
- const excluded = flat.filter((node) => !activeFilters.has(node.name) && excludedFilters.has(node.name)).map(fullName);
574
- const rest = tree.filter((node) => !activeFilters.has(node.name) && !excludedFilters.has(node.name));
575
- const treeOpts = { mode: 'filter', items, activeFilters, excludedFilters, groupCounts, verdict };
517
+ const names = sortTags(groups, { weights, tier });
518
+ const activeKeys = tagKeySet(activeFilters);
519
+ const excludedKeys = tagKeySet(excludedFilters);
520
+ const isOn = (g) => activeKeys.has(Search.tagKey(g));
521
+ const isOff = (g) => !isOn(g) && excludedKeys.has(Search.tagKey(g));
522
+ const selected = names.filter(isOn);
523
+ const excluded = names.filter(isOff);
524
+ const rest = names.filter((g) => !isOn(g) && !isOff(g));
525
+ const chipOpts = { activeKeys, excludedKeys, countOf: tagCounter(items, groupCounts), verdict };
576
526
  const sep = '<span class="cluster-sep" aria-hidden="true"></span>';
577
527
  if (selected.length || excluded.length) {
578
528
  html += sep + connToggleHtml('group', Search.dimConnective(Search.parseQuery(query), 'group', options.joins), 'groups')
579
- + renderTagTreeMenu(selected, treeOpts) + renderTagTreeMenu(excluded, treeOpts) + (rest.length ? sep : '');
529
+ + renderGroupChips(selected, chipOpts) + renderGroupChips(excluded, chipOpts) + (rest.length ? sep : '');
580
530
  }
581
- html += renderTagTreeMenu(rest, treeOpts);
531
+ html += renderGroupChips(rest, chipOpts);
582
532
  return html;
583
533
  }
584
534
  // An image preview: the picture in a wrapper the size of the picture. Known
@@ -700,15 +650,6 @@
700
650
  + '<button class="meta-ghost" type="button" data-action="tag-add" title="Add to group" aria-label="Add to group"><span class="mi">add</span></button>';
701
651
  return `${html}<span class="bc-reveal meta-reveal"><span class="bc-reveal-inner">${ghosts}</span></span>`;
702
652
  }
703
- // The meta line's group names when a row opens its ghosts (# / +, the
704
- // reveal at the line's end): a wrapping names box keeps the width it had, so
705
- // the + would sit at its far edge with a gap after the last name that still
706
- // shows. Instead the names that would not fit beside the open ghosts are
707
- // marked .meta-cut (hidden whole only while the reveal is open, CSS) and the
708
- // box shrinks to the rest. Worked out from the closed row (the ghosts' open
709
- // width is the meta line's height per ghost plus the gaps), one row per
710
- // hover, so it costs nothing on the typing path. The first name is never set
711
- // aside (alone on the line it ellipsizes).
712
653
  // An image row's buttons float at the row's top right; they get their
713
654
  // frosted backing (.img-under) only while the picture actually reaches
714
655
  // under them (a wide picture, or one zoomed up), so a narrow one leaves them
@@ -723,26 +664,52 @@
723
664
  const gap = 4; // --sp-1: closer than that and the picture reads as under the buttons
724
665
  row.classList.toggle('img-under', p.width > 0 && p.right > a.left - gap && p.top < b.bottom + gap && p.bottom > a.top - gap);
725
666
  }
667
+ // The width a row's reveal (its buttons, or the meta line's ghosts) takes
668
+ // once open, worked out while it is still closed or only starting to open:
669
+ // until then its buttons are squeezed to their glyphs, so their rects and
670
+ // used widths say nothing. Its buttons are square (as wide as they are
671
+ // tall), plus their margins (the first one's --reveal-gap) and the gaps.
672
+ function revealOpenWidth(reveal) {
673
+ const inner = reveal && reveal.firstElementChild;
674
+ if (!inner) return 0;
675
+ const px = (v) => parseFloat(v) || 0;
676
+ const kids = Array.from(inner.children);
677
+ let width = Math.max(0, kids.length - 1) * px(getComputedStyle(inner).columnGap);
678
+ for (const k of kids) {
679
+ const s = getComputedStyle(k);
680
+ width += px(s.height) + px(s.marginInlineStart) + px(s.marginInlineEnd);
681
+ }
682
+ return width;
683
+ }
684
+ // The meta line's group names when a row opens its ghosts (# / +, the
685
+ // reveal at the line's end): a wrapping names box keeps the width it had, so
686
+ // the + would sit at its far edge with a gap after the last name that still
687
+ // shows. Instead the names that would not fit beside the open ghosts are
688
+ // marked .meta-cut (hidden whole only while the reveal is open, CSS) and the
689
+ // box shrinks to the rest. Worked out from the row as it is entered, for
690
+ // the line as it will be once open: a text row's own buttons (.row-actions,
691
+ // beside .content) slide in at the same time and take their width off the
692
+ // whole line, and the ghosts take theirs after the names. One row per hover,
693
+ // so it costs nothing on the typing path. The first name is never set aside
694
+ // (alone on the line it ellipsizes).
726
695
  function fitMetaTags(row) {
727
696
  const box = row && row.querySelector ? row.querySelector('.meta-tags') : null;
728
697
  if (!box || typeof getComputedStyle !== 'function') return;
729
698
  const tags = Array.from(box.children);
730
699
  for (const t of tags) t.classList.remove('meta-cut');
731
700
  const meta = box.parentElement;
732
- const inner = meta && meta.querySelector('.meta-reveal > .bc-reveal-inner');
733
- if (!inner || tags.length < 2) return;
734
- const ghosts = Array.from(inner.children);
735
- const metaStyle = getComputedStyle(meta);
736
- const metaRect = meta.getBoundingClientRect();
701
+ const reveal = meta && meta.querySelector('.meta-reveal');
702
+ if (!reveal || tags.length < 2) return;
737
703
  const px = (v) => parseFloat(v) || 0;
704
+ // The open row's line is narrower by what is left of its buttons' opening
705
+ // (none when they are open already: focus, a held row).
706
+ const actions = row.querySelector('.row-actions');
707
+ const narrower = actions ? Math.max(0, revealOpenWidth(actions) - actions.getBoundingClientRect().width) : 0;
738
708
  // Room the open reveal takes after the names box: its own gap (the line's
739
709
  // gap is cancelled by the reveal's negative margin) and the ghosts.
740
- const revealStyle = getComputedStyle(inner.parentElement);
741
- const ghostGap = px(getComputedStyle(inner).columnGap);
742
- const open = px(metaStyle.columnGap) + px(revealStyle.marginLeft) + (ghosts.length ? px(getComputedStyle(ghosts[0]).marginInlineStart) : 0)
743
- + ghosts.length * metaRect.height + Math.max(0, ghosts.length - 1) * ghostGap;
710
+ const open = px(getComputedStyle(meta).columnGap) + px(getComputedStyle(reveal).marginLeft) + revealOpenWidth(reveal);
744
711
  const boxStyle = getComputedStyle(box);
745
- const limit = metaRect.right - open;
712
+ const limit = meta.getBoundingClientRect().right - narrower - open;
746
713
  const tagMargin = px(getComputedStyle(tags[0]).marginLeft) + px(getComputedStyle(tags[0]).marginRight);
747
714
  const nameGap = px(boxStyle.columnGap);
748
715
  let x = box.getBoundingClientRect().left + px(boxStyle.paddingLeft);
@@ -810,13 +777,20 @@
810
777
  + `</div><div class="content">${primaryHtml}${previewHtml}<div class="meta">${renderClipMeta(item)}</div></div>${actions}</div></div>`;
811
778
  }
812
779
  // Empty list states, ONE renderer for the app and the demo: what is empty and
813
- // why, with the way out. kind (derived from { total, query } when omitted):
780
+ // why, with the way out. kind (derived from { total, query, groups } when omitted):
814
781
  // 'no-clips' - history is empty
815
782
  // 'no-match' - the search text matches nothing
816
783
  // 'filtered' - the filters (group:/is:/since:/...) exclude everything
817
784
  // 'empty-group' - the only filter is one group, and it has no clips
818
- // opts.nudgeHtml fills the .empty-nudge slot (renderRelaxNudge);
819
- // opts.groups lets it tell an invalid query from a bad spelling.
785
+ // opts.groups (the tag names) lets it tell an invalid query from a bad
786
+ // spelling, and a group: value that is no tag (a typo, a retired name like
787
+ // todo/forge) from an empty tag: that is 'filtered', never "No clips in
788
+ // todo/forge yet". opts.nudgeHtml fills the .empty-nudge slot (renderRelaxNudge).
789
+ function emptyGroupName(parsed, groups) {
790
+ const value = parsed.groups[0] || '';
791
+ if (!Array.isArray(groups)) return value;
792
+ return groups.find((g) => Search.sameTag(g, value)) || null;
793
+ }
820
794
  function emptyStateKind(opts) {
821
795
  const o = opts || {};
822
796
  if (!(Number(o.total) > 0)) return 'no-clips';
@@ -824,14 +798,14 @@
824
798
  if (Search.hasSearchTerms(parsed)) return 'no-match';
825
799
  const onlyGroup = parsed.groups.length === 1 && !parsed.negGroups.length && !parsed.is.length && !parsed.negIs.length
826
800
  && !parsed.nums.length && !parsed.negNums.length && !parsed.since && !parsed.before && !parsed.len && !parsed.lines && !parsed.words && !parsed.id;
827
- return onlyGroup ? 'empty-group' : 'filtered';
801
+ return onlyGroup && emptyGroupName(parsed, o.groups) != null ? 'empty-group' : 'filtered';
828
802
  }
829
803
  function renderEmptyState(options) {
830
804
  const o = options || {};
831
805
  const kind = o.kind || emptyStateKind(o);
832
806
  const parsed = Search.parseQuery(o.query || '');
833
807
  const said = parsed.terms.map((c) => (c.regex ? `/${c.value}/` : c.value)).join(' ').trim();
834
- const group = parsed.groups[0] || '';
808
+ const group = emptyGroupName(parsed, o.groups) || '';
835
809
  const states = {
836
810
  'no-clips': { icon: 'content_paste', title: 'No clips yet', hint: 'Copy something and it shows up here.' },
837
811
  'no-match': { icon: 'search_off', title: said ? `No matches for "${said}"` : 'No matches', hint: 'Check the spelling, or try fewer or different words.', action: 'Clear search' },
@@ -1131,7 +1105,7 @@
1131
1105
  return has === 0 ? 'none' : has === list.length ? 'all' : 'some';
1132
1106
  }
1133
1107
  function similarLabel(n) { return `Select ${n} similar`; }
1134
- // ONE menu row primitive (.bc-menu-item) for every menu, submenu, group tree
1108
+ // ONE menu row primitive (.bc-menu-item) for every menu, submenu, group list
1135
1109
  // and picker: an icon column (an icon, a membership check, or empty, so labels
1136
1110
  // always line up), the label, then an optional trailing hint or submenu
1137
1111
  // caret. Rows are menu items for keyboard navigation (createMenu): roving
@@ -1155,27 +1129,25 @@
1155
1129
  return `<div class="tag-menu-node has-children" role="none">${parent}<div class="tag-submenu${subClass ? ` ${subClass}` : ''}" role="menu">${innerHtml}</div></div>`;
1156
1130
  }
1157
1131
  const MENU_SEPARATOR = '<div class="bc-menu-sep" role="separator"></div>';
1158
- // ONE group checklist for every group menu: the tag tree as rows with the
1159
- // membership glyph in the icon column (state(group) 'all' | 'some' | 'none'
1160
- // = check / dash / nothing, in the accent; labels stay --text) and a nested
1161
- // submenu per parent tag. Single-clip pickers toggle with toggle-group, the
1162
- // multi-select tree with bulk-group. A click on a member removes it.
1132
+ // ONE group checklist for every group menu: one row per tag (sortTags
1133
+ // order) with the membership glyph in the icon column (state(group) 'all' |
1134
+ // 'some' | 'none' = check / dash / nothing, in the accent; labels stay
1135
+ // --text). Single-clip pickers toggle with toggle-group, the multi-select
1136
+ // list with bulk-group. A click on a member removes it.
1163
1137
  const MEMBERSHIP_GLYPH = { all: ['check', 'true'], some: ['remove', 'mixed'], none: ['', 'false'] };
1164
- function renderGroupChecklist(nodes, state, action) {
1165
- return (nodes || []).map((node) => {
1166
- const group = normalizeTagName(node && node.name);
1167
- if (!group) return '';
1138
+ function renderGroupChecklist(names, state, action) {
1139
+ return (names || []).map((group) => {
1168
1140
  const [icon, checked] = MEMBERSHIP_GLYPH[state(group)] || MEMBERSHIP_GLYPH.none;
1169
- const row = { icon, check: true, label: node.label || group, cls: 'group-name', role: 'menuitemcheckbox', attrs: { 'data-action': action, 'data-group': group, 'aria-checked': checked, title: group } };
1170
- return node.children && node.children.length
1171
- ? submenuNodeHtml(row, renderGroupChecklist(node.children, state, action))
1172
- : menuRowHtml(row);
1141
+ return menuRowHtml({ icon, check: true, label: group, cls: 'group-name', role: 'menuitemcheckbox', attrs: { 'data-action': action, 'data-group': group, 'aria-checked': checked, title: group } });
1173
1142
  }).join('');
1174
1143
  }
1175
1144
  // "New group..." closes every group checklist: a normal row, after a
1176
- // separator when there are groups above it.
1177
- function withNewGroupRow(treeHtml, action) {
1178
- return treeHtml + (treeHtml ? MENU_SEPARATOR : '') + menuRowHtml({ icon: 'add', label: 'New group...', attrs: { 'data-action': action } });
1145
+ // separator when there are groups above it. The tag rows sit in their own
1146
+ // part (.bc-group-rows), which scrolls when the window cannot hold them all
1147
+ // (createMenu / fitSubmenu cap it), so "New group..." always stays in view.
1148
+ function withNewGroupRow(rowsHtml, action) {
1149
+ return (rowsHtml ? `<div class="bc-group-rows" role="none">${rowsHtml}</div>${MENU_SEPARATOR}` : '')
1150
+ + menuRowHtml({ icon: 'add', label: 'New group...', attrs: { 'data-action': action } });
1179
1151
  }
1180
1152
  // ONE menu-content builder for the per-clip "..." menu. Reuses the exact
1181
1153
  // data-action attributes the controller already dispatches (pin/edit/rename/
@@ -1210,7 +1182,7 @@
1210
1182
  // The editor's "back to the text it opened with" (the window bar keeps
1211
1183
  // only find and the menu).
1212
1184
  if (context === 'editor' && !isImage) html += row('revert', 'Revert to original');
1213
- html += submenuNodeHtml({ icon: 'sell', label: 'Add to group' }, clipGroupTreeHtml(groups, item, Search.groupWeights(opts.items || [])));
1185
+ html += submenuNodeHtml({ icon: 'sell', label: 'Add to group' }, clipGroupListHtml(groups, item, Search.groupWeights(opts.items || [])));
1214
1186
  html += submenuNodeHtml({ icon: 'tag', label: 'Numpad', hint: numpadOf(item) ? String(numpadOf(item)) : '' }, renderKeypadMenu(item, items, nmap), 'bc-keypad');
1215
1187
  // opts.similarCount: a number (0 = no row) or null while it is being
1216
1188
  // counted (a disabled placeholder the controller fills in); absent = never.
@@ -1226,9 +1198,10 @@
1226
1198
  // Group checklist + "New group..." for ONE clip: the single builder behind
1227
1199
  // the clip menu's "Add to group" submenu AND the title-bar strip's / a row's
1228
1200
  // + popover (openGroupPickerAt). The clip's current groups carry the check.
1229
- function clipGroupTreeHtml(groups, item, weights) {
1230
- const itemGroups = new Set(groupsOf(item));
1231
- return withNewGroupRow(renderTagTreeMenu(buildTagTree([...(groups || []), ...itemGroups], { weights }), { mode: 'picker', itemGroups }), 'add-group');
1201
+ function clipGroupListHtml(groups, item, weights) {
1202
+ const itemGroups = groupsOf(item);
1203
+ const has = tagKeySet(itemGroups);
1204
+ return withNewGroupRow(renderGroupChecklist(sortTags([...(groups || []), ...itemGroups], { weights }), (g) => (has.has(Search.tagKey(g)) ? 'all' : 'none'), 'toggle-group'), 'add-group');
1232
1205
  }
1233
1206
  // ONE menu-content builder for the multi-select bulk menu (shared by the
1234
1207
  // action bar's overflow and the right-click menu on a multi-selection). Bulk
@@ -1245,7 +1218,7 @@
1245
1218
  let html = '<div class="bc-menu-list">';
1246
1219
  html += row('bulk-paste', 'content_paste', `Paste all (${count})`);
1247
1220
  html += row('bulk-copy', CLIP_ACTION_ICONS.copy, `Copy all (${count})`);
1248
- html += submenuNodeHtml({ icon: 'sell', label: 'Add to group' }, bulkGroupTreeHtml(groups, selItems, Search.groupWeights(opts.items || [])));
1221
+ html += submenuNodeHtml({ icon: 'sell', label: 'Add to group' }, bulkGroupListHtml(groups, selItems, Search.groupWeights(opts.items || [])));
1249
1222
  if (allText) html += row('bulk-unify', 'merge', `Unify (${count})`);
1250
1223
  html += MENU_SEPARATOR + row('bulk-delete', CLIP_ACTION_ICONS.del, `Delete (${count})`, 'danger');
1251
1224
  html += '</div>';
@@ -1256,10 +1229,9 @@
1256
1229
  // Each group shows its membership across the selection (all = check, some =
1257
1230
  // dash, none = empty); a click toggles it for every selected clip (all ->
1258
1231
  // remove from all, else add to all).
1259
- function bulkGroupTreeHtml(groups, selItems, weights) {
1232
+ function bulkGroupListHtml(groups, selItems, weights) {
1260
1233
  const sel = selItems || [];
1261
- const treeGroups = [...new Set([...(groups || []), ...sel.flatMap(groupsOf)])];
1262
- return withNewGroupRow(renderGroupChecklist(buildTagTree(treeGroups, { weights }), (g) => groupMembership(sel, g), 'bulk-group'), 'bulk-add-group');
1234
+ return withNewGroupRow(renderGroupChecklist(sortTags([...(groups || []), ...sel.flatMap(groupsOf)], { weights }), (g) => groupMembership(sel, g), 'bulk-group'), 'bulk-add-group');
1263
1235
  }
1264
1236
  // The contextual bar shown while 2+ clips are selected. It takes the chip
1265
1237
  // bar's place at the same height (the shell stacks both in .chip-row; the
@@ -1539,7 +1511,6 @@
1539
1511
  el.addEventListener('transitionend', fade.refresh);
1540
1512
  el.addEventListener('wheel', (e) => {
1541
1513
  if (e.ctrlKey || e.metaKey || !e.deltaY || Math.abs(e.deltaX) >= Math.abs(e.deltaY)) return;
1542
- if (e.target && e.target.closest && e.target.closest('.tag-submenu')) return;
1543
1514
  if (el.scrollWidth <= el.clientWidth + 1 || getComputedStyle(el).overflowX === 'visible') return;
1544
1515
  const before = el.scrollLeft;
1545
1516
  el.scrollLeft += e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY;
@@ -1552,8 +1523,7 @@
1552
1523
  // until the search options panel opens (attachSearchBox), then every chip,
1553
1524
  // wrapped. The switch animates both ways (FLIP: each chip glides from where
1554
1525
  // it was to where it lands while the row's height follows), so the line and
1555
- // the full set read as the same chips. Its group submenus open in the top
1556
- // layer (installSubmenuAutoflip), out of the strip's clip and mask.
1526
+ // the full set read as the same chips.
1557
1527
  function attachChipStrip(el) {
1558
1528
  if (!el) return { setExpanded() {}, isExpanded: () => false };
1559
1529
  if (el._bcChipStrip) return el._bcChipStrip;
@@ -2527,11 +2497,14 @@
2527
2497
  let onClosed = null;
2528
2498
  let opener = null;
2529
2499
  let anchor = null;
2500
+ let fades = []; // the edge fades of this menu's group lists
2530
2501
  function close() {
2531
2502
  if (!el) return;
2532
2503
  const hadFocus = el.contains(document.activeElement);
2533
2504
  el.remove();
2534
2505
  el = null;
2506
+ for (const fade of fades) fade.detach();
2507
+ fades = [];
2535
2508
  document.removeEventListener('pointerdown', onOutside, true);
2536
2509
  document.removeEventListener('keydown', onKey, true);
2537
2510
  document.removeEventListener('scroll', onScroll, true);
@@ -2549,8 +2522,9 @@
2549
2522
  // that moved away — close instead (scrolls inside the menu are fine).
2550
2523
  function onScroll(e) { if (el && !el.contains(e.target)) close(); }
2551
2524
  // --- Keyboard: one level = the root list or one .tag-submenu; its items
2552
- // are its own rows (a parent row included) and the keys of its keypad.
2553
- const ITEMS = ':scope > .bc-menu-item, :scope > .tag-menu-node > .bc-menu-item, :scope > .numpad-picker .np-btn';
2525
+ // are its own rows (a parent row and a group list's tag rows included) and
2526
+ // the keys of its keypad.
2527
+ const ITEMS = ':scope > .bc-menu-item, :scope > .bc-group-rows > .bc-menu-item, :scope > .tag-menu-node > .bc-menu-item, :scope > .numpad-picker .np-btn';
2554
2528
  const rootLevel = () => (el ? el.querySelector(':scope > .bc-menu-list') || el : null);
2555
2529
  const itemsOf = (level) => (level ? [...level.querySelectorAll(ITEMS)] : []);
2556
2530
  const isOff = (item) => !!(item && (item.disabled || item.getAttribute('aria-disabled') === 'true'));
@@ -2564,7 +2538,7 @@
2564
2538
  function focusStep(level, from, key) {
2565
2539
  const items = itemsOf(level);
2566
2540
  const next = menuNavIndex(items.length, from ? items.indexOf(from) : -1, key, (i) => isOff(items[i]));
2567
- if (next >= 0) focusItem(items[next]);
2541
+ if (next >= 0) { focusItem(items[next]); revealMenuRow(items[next]); }
2568
2542
  }
2569
2543
  function shut(node) {
2570
2544
  for (const n of [node, ...node.querySelectorAll('.tag-menu-node.open')]) {
@@ -2672,10 +2646,14 @@
2672
2646
  mount.appendChild(el);
2673
2647
  const isBody = mount === document.body || mount === document.documentElement;
2674
2648
  const hostRect = mount.getBoundingClientRect();
2675
- const mw = el.offsetWidth;
2676
- const mh = el.offsetHeight;
2677
2649
  const vw = isBody ? window.innerWidth : hostRect.width;
2678
2650
  const vh = isBody ? window.innerHeight : hostRect.height;
2651
+ // A group list popover never runs past the window (its submenus are
2652
+ // capped where they open: fitSubmenu); every group list fades its edges.
2653
+ capGroupRows(el, el.querySelector(':scope > .bc-group-list > .bc-group-rows'), vh - 8);
2654
+ for (const rows of el.querySelectorAll('.bc-group-rows')) fades.push(attachScrollFade(rows, 'box'));
2655
+ const mw = el.offsetWidth;
2656
+ const mh = el.offsetHeight;
2679
2657
  let localX = isBody ? o.x : o.x - hostRect.left;
2680
2658
  let localY = isBody ? o.y : o.y - hostRect.top;
2681
2659
  if (o.aboveY != null && localY + mh > vh - 4) {
@@ -2696,8 +2674,36 @@
2696
2674
  }
2697
2675
  return { open, close, isOpen: () => !!el, root: () => el };
2698
2676
  }
2677
+ // A group list never runs out of its window: its tag rows (.bc-group-rows)
2678
+ // get at most the height the window leaves once the surface's other parts
2679
+ // (the separator, "New group...", the padding) are counted, and scroll
2680
+ // under the shared edge fade past that, so "New group..." always stays in
2681
+ // view. room: the height the window gives the whole surface. Measured on the
2682
+ // live layout without taking the cap off first, so a hover re-fit never
2683
+ // resets the list's scroll place.
2684
+ function capGroupRows(surface, rows, room) {
2685
+ if (!surface || !rows || !(room > 0)) return;
2686
+ const others = surface.offsetHeight - rows.offsetHeight;
2687
+ const first = rows.firstElementChild;
2688
+ const cap = Math.max(Math.floor(room - others), first ? first.offsetHeight * 3 : 0);
2689
+ rows.style.maxHeight = rows.scrollHeight > cap ? `${cap}px` : '';
2690
+ }
2691
+ // A keyboard-focused row of a scrolling group list stays in view, clear of
2692
+ // the edge fade (one row of room on the side it moves toward, where the list
2693
+ // is tall enough). The pointer never scrolls it: rows under a resting mouse
2694
+ // would run away.
2695
+ function revealMenuRow(item) {
2696
+ const rows = item && item.parentElement;
2697
+ if (!rows || !rows.classList || !rows.classList.contains('bc-group-rows') || rows.scrollHeight <= rows.clientHeight) return;
2698
+ const r = item.getBoundingClientRect();
2699
+ const b = rows.getBoundingClientRect();
2700
+ const pad = Math.min(r.height, Math.max(0, (b.height - r.height) / 2));
2701
+ if (r.top < b.top + pad) rows.scrollTop -= b.top + pad - r.top;
2702
+ else if (r.bottom > b.bottom - pad) rows.scrollTop += r.bottom - (b.bottom - pad);
2703
+ }
2699
2704
  // Keep a shown submenu inside the window: a right-opening submenu near the
2700
- // edge opens leftward (flip-x), a tall tree near the bottom shifts up.
2705
+ // edge opens leftward (flip-x), a group list taller than the window scrolls
2706
+ // (capGroupRows), and one near the bottom shifts up.
2701
2707
  // boundsEl: document (the app popup IS the OS window, so the viewport) or
2702
2708
  // the demo's embedded popup box. Hover (installSubmenuAutoflip) and the
2703
2709
  // menu keyboard (createMenu) both place submenus through this.
@@ -2710,6 +2716,7 @@
2710
2716
  ? { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight }
2711
2717
  : boundsEl.getBoundingClientRect();
2712
2718
  if (bound.right - bound.left < 40) return; // hidden/unmeasurable host
2719
+ capGroupRows(sub, sub.querySelector(':scope > .bc-group-rows'), bound.bottom - bound.top - 8);
2713
2720
  if (r.right > bound.right - 4) {
2714
2721
  const nr = node.getBoundingClientRect();
2715
2722
  if (nr.left - bound.left > bound.right - nr.right) sub.classList.add('flip-x'); // open toward the roomier side
@@ -2725,84 +2732,13 @@
2725
2732
  }
2726
2733
  // Auto-flip/clamp hover submenus so they never overflow the window. The popup
2727
2734
  // window is narrow, so a right-opening submenu near the edge must open leftward
2728
- // (flip-x) and a tall group tree near the bottom must shift up. One delegated
2729
- // listener per consumer root covers the filter bar AND the popover menus (the
2730
- // controller installs it automatically); keyboard-opened submenus are placed
2731
- // by createMenu through the same fitSubmenu.
2732
- // A chip's submenu in the top layer (popover="manual", the chip row's): it
2733
- // escapes the strip's clip and mask, so it is placed by hand under its chip
2734
- // (left edges aligned, or right edges when that would overflow), clamped to
2735
- // the bounds, and shown while its chip or the submenu itself is hovered or
2736
- // holds the focus (it is still the chip node's DOM child, so :hover and
2737
- // :focus-within reach the node from inside it).
2738
- function placeTopLayerSubmenu(node, sub, boundsEl) {
2739
- const bound = (boundsEl === document || boundsEl === document.documentElement)
2740
- ? { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight }
2741
- : boundsEl.getBoundingClientRect();
2742
- const nr = node.getBoundingClientRect();
2743
- const r = sub.getBoundingClientRect();
2744
- let left = nr.left;
2745
- if (left + r.width > bound.right - 4) left = Math.max(bound.left + 4, nr.right - r.width);
2746
- let top = nr.bottom;
2747
- if (top + r.height > bound.bottom - 4) top = Math.max(bound.top + 4, bound.bottom - 4 - r.height);
2748
- sub.style.left = `${Math.round(left)}px`;
2749
- sub.style.top = `${Math.round(top)}px`;
2750
- }
2751
- function closeTopLayerSubmenus(rootEl) {
2752
- const root = rootEl || (typeof document !== 'undefined' ? document : null);
2753
- if (root && typeof root._bcCloseTopLayer === 'function') root._bcCloseTopLayer();
2754
- }
2735
+ // (flip-x) and a tall group list near the bottom must shift up. One delegated
2736
+ // listener per consumer root covers the popover menus (the controller
2737
+ // installs it automatically); keyboard-opened submenus are placed by
2738
+ // createMenu through the same fitSubmenu.
2755
2739
  function installSubmenuAutoflip(rootEl) {
2756
2740
  if (typeof document === 'undefined' || !rootEl || rootEl._bcAutoflip) return;
2757
2741
  rootEl._bcAutoflip = true;
2758
- const shownTopLayer = new Set();
2759
- const openTopLayer = (node, sub) => {
2760
- if (typeof sub.showPopover !== 'function') return;
2761
- if (!sub.matches(':popover-open')) { try { sub.showPopover(); } catch { return; } }
2762
- shownTopLayer.add(sub);
2763
- placeTopLayerSubmenu(node, sub, rootEl);
2764
- };
2765
- // A pointer or the focus leaving a chip (or its submenu): close every
2766
- // top-layer submenu whose chip no longer has either.
2767
- const closeStale = () => setTimeout(() => {
2768
- for (const sub of shownTopLayer) {
2769
- const node = sub.parentElement;
2770
- if (sub.isConnected && node && node.matches(':hover, :focus-within')) continue;
2771
- shownTopLayer.delete(sub);
2772
- if (sub.isConnected && sub.matches(':popover-open')) { try { sub.hidePopover(); } catch {} }
2773
- }
2774
- }, 0);
2775
- rootEl.addEventListener('mouseout', () => { if (shownTopLayer.size) closeStale(); });
2776
- rootEl.addEventListener('focusout', () => { if (shownTopLayer.size) closeStale(); });
2777
- const closeAll = () => {
2778
- for (const sub of shownTopLayer) {
2779
- if (sub.isConnected && sub.matches(':popover-open')) { try { sub.hidePopover(); } catch {} }
2780
- }
2781
- shownTopLayer.clear();
2782
- };
2783
- rootEl._bcCloseTopLayer = closeAll;
2784
- if (typeof window !== 'undefined') window.addEventListener('blur', closeAll);
2785
- rootEl.addEventListener('scroll', (event) => {
2786
- const scroller = event.target;
2787
- if (!shownTopLayer.size || !scroller || !scroller.contains) return;
2788
- for (const sub of shownTopLayer) {
2789
- const node = sub.parentElement;
2790
- if (!node || !scroller.contains(node)) continue;
2791
- const nr = node.getBoundingClientRect();
2792
- const sr = scroller.getBoundingClientRect();
2793
- if (nr.right < sr.left || nr.left > sr.right) {
2794
- shownTopLayer.delete(sub);
2795
- try { sub.hidePopover(); } catch {}
2796
- } else {
2797
- placeTopLayerSubmenu(node, sub, rootEl);
2798
- }
2799
- }
2800
- }, true);
2801
- rootEl.addEventListener('focusin', (event) => {
2802
- const node = event.target && event.target.closest ? event.target.closest('.tag-menu-node.has-children') : null;
2803
- const sub = node ? node.querySelector(':scope > .tag-submenu[popover]') : null;
2804
- if (sub) openTopLayer(node, sub);
2805
- });
2806
2742
  rootEl.addEventListener('mouseover', (event) => {
2807
2743
  const target = event.target;
2808
2744
  if (!target || !target.closest) return;
@@ -2810,7 +2746,6 @@
2810
2746
  if (!node) return;
2811
2747
  const sub = node.querySelector(':scope > .tag-submenu');
2812
2748
  if (!sub) return;
2813
- if (sub.hasAttribute('popover')) { openTopLayer(node, sub); return; }
2814
2749
  // setTimeout (not rAF): rAF is throttled to a halt in background tabs.
2815
2750
  // Bounds: the app popup IS the OS window (viewport); the demo popup is a
2816
2751
  // box embedded in the marketing page, so clamp to that box instead.
@@ -2845,16 +2780,22 @@
2845
2780
  }
2846
2781
  }
2847
2782
 
2783
+ // Returns { isShown(), hide() }: shown while the toast still holds THIS
2784
+ // message (another toast replacing it, its timer or its action ends it), so
2785
+ // an action tied to the toast (the controller's Undo) lives exactly as long.
2848
2786
  function showActionToast(toastEl, opts) {
2849
- if (!toastEl) return;
2787
+ if (!toastEl) return null;
2850
2788
  const o = opts || {};
2851
2789
  toastEl.innerHTML = '';
2852
2790
  const msg = document.createElement('span');
2853
2791
  msg.textContent = o.message || '';
2854
2792
  toastEl.appendChild(msg);
2793
+ const isShown = () => toastEl.classList.contains('show') && msg.parentNode === toastEl;
2855
2794
  // Hiding also drops the action button, so a hidden toast never keeps a
2856
- // focusable (Enter would undo again) or clickable Undo around.
2795
+ // focusable (Enter would undo again) or clickable Undo around. A toast
2796
+ // another message already replaced is left alone.
2857
2797
  const hide = () => {
2798
+ if (msg.parentNode !== toastEl) return;
2858
2799
  if (toastEl._actionTimer) clearTimeout(toastEl._actionTimer);
2859
2800
  toastEl._actionTimer = null;
2860
2801
  toastEl.classList.remove('show');
@@ -2871,6 +2812,7 @@
2871
2812
  toastEl.classList.add('show');
2872
2813
  if (toastEl._actionTimer) clearTimeout(toastEl._actionTimer);
2873
2814
  toastEl._actionTimer = setTimeout(hide, o.timeout || 5000);
2815
+ return { isShown, hide };
2874
2816
  }
2875
2817
  // Paint the current selection state onto an already-rendered list + drive the
2876
2818
  // slim selection bar. Shared by both consumers so the class names + bar markup
@@ -2973,7 +2915,8 @@
2973
2915
  const general = settingSectionHtml('General',
2974
2916
  switchRowHtml({ id: 'autoLaunch', label: 'Launch on startup' })
2975
2917
  + settingRowHtml({ label: 'Popup shortcut', help: [['shortcutStatus']], control: '<button class="shortcut-btn" id="shortcutRecord" type="button"></button>', reset: settingResetHtml('shortcutReset', 'Reset to the default shortcut') })
2976
- + settingRowHtml({ label: 'Quick paste shortcut', help: [['quickPasteStatus']], control: '<button class="shortcut-btn" id="quickPasteRecord" type="button"></button>', reset: settingResetHtml('quickPasteReset', 'Reset to the default shortcut') }));
2918
+ + settingRowHtml({ label: 'Quick paste shortcut', help: [['quickPasteStatus']], control: '<button class="shortcut-btn" id="quickPasteRecord" type="button"></button>', reset: settingResetHtml('quickPasteReset', 'Reset to the default shortcut') })
2919
+ + settingRowHtml({ label: 'New note shortcut', help: [['newNoteShortcutStatus']], control: '<button class="shortcut-btn" id="newNoteShortcutRecord" type="button"></button>', reset: settingResetHtml('newNoteShortcutReset', 'Reset to the default shortcut') }));
2977
2920
  const appearance = settingSectionHtml('Appearance',
2978
2921
  settingRowHtml({ label: 'Theme', control: theme })
2979
2922
  + settingRowHtml({ label: 'Accent', help: [['accentHelp']], control: swatches })
@@ -3075,8 +3018,8 @@
3075
3018
  // deleted) }.
3076
3019
  function renderGroupRows(groups, o) {
3077
3020
  const opt = o || {};
3078
- const countOf = (g) => Number(opt.counts instanceof Map ? opt.counts.get(g) : (opt.counts || {})[g]) || 0;
3079
- const shared = opt.shared instanceof Set ? opt.shared : new Set(opt.shared || []);
3021
+ const countOf = tagCounter(null, opt.counts || {});
3022
+ const shared = tagKeySet(opt.shared);
3080
3023
  // The switch column says what it is, right above the switches (the rows'
3081
3024
  // own grid, so the label ends on the switches' edge).
3082
3025
  const head = (groups || []).length
@@ -3084,10 +3027,10 @@
3084
3027
  : '';
3085
3028
  return head + (groups || []).map((g) => {
3086
3029
  const count = countOf(g);
3087
- const isAi = g === opt.aiGroup;
3030
+ const isAi = Search.sameTag(g, opt.aiGroup);
3088
3031
  return renderSettingsItem({
3089
3032
  title: g, meta: `${count} clip${count === 1 ? '' : 's'}`, cls: 'group-row',
3090
- toggle: { checked: isAi || shared.has(g), disabled: isAi, cls: 'gp-share', label: isAi ? 'The AI group is always shared with AI' : `Share ${g} with AI`, attrs: { 'data-group': g } },
3033
+ toggle: { checked: isAi || shared.has(Search.tagKey(g)), disabled: isAi, cls: 'gp-share', label: isAi ? 'The AI group is always shared with AI' : `Share ${g} with AI`, attrs: { 'data-group': g } },
3091
3034
  actionHtml: isAi ? '' : `<button class="icon-btn gp-del" type="button" data-group="${escapeHtml(g)}" title="Delete group" aria-label="Delete group ${escapeHtml(g)}"><span class="mi">close</span></button>`,
3092
3035
  });
3093
3036
  }).join('');
@@ -3611,9 +3554,14 @@
3611
3554
  // [self-renders — also called directly by the search-clear button],
3612
3555
  // focusSearch()
3613
3556
  // mutate: pin(id), numpadAssign(id,slot), numpadUnassign(slot),
3614
- // toggleGroup(id,group) [add-or-remove], createGroup(name),
3615
- // deleteGroup(group), deleteClip(id), clearUnpinned(),
3616
- // setGroupSharedAi(group)
3557
+ // toggleGroup(id,group) [add-or-remove],
3558
+ // groupAssignMany(ids, text, shouldHave) [text may name several, comma-separated],
3559
+ // createGroup(text) -> tag names | false [text may name several],
3560
+ // deleteGroup(group) -> { ok, name, ids, shared } | false,
3561
+ // restoreGroup(that) [the delete's Undo], deleteClip(id),
3562
+ // clearUnpinned(), setGroupSharedAi(group, shared) [shared: the switch's new state]
3563
+ // undo: offerUndo({ message, undo, count? }) -> the toast's { isShown, hide }
3564
+ // (Core.showActionToast) [the toast with Undo; the Undo lives while it shows]
3617
3565
  // actions: activateClip(id), copyClip(id) [the clipboard only, no paste],
3618
3566
  // copyMany(ids) [Paste all's joined text, no paste], editClip(id,itemEl), openImage(item),
3619
3567
  // saveImage(item)->feedbackString|null
@@ -4347,15 +4295,48 @@
4347
4295
  const toast = (msg) => { if (a.toast && msg) a.toast(msg); };
4348
4296
  const protectedHas = (group) => {
4349
4297
  const p = a.protectedGroups && a.protectedGroups();
4350
- return !!(p && typeof p.has === 'function' && p.has(group));
4298
+ return !!(p && [...p].some((g) => Search.sameTag(g, group)));
4351
4299
  };
4300
+ // The Undo of the last delete (clips or a group): live exactly while its
4301
+ // toast shows it, through the toast's own action or Ctrl/Cmd+Z (Settings
4302
+ // included). Once that toast is gone, or another message replaced it, the
4303
+ // Undo is gone too: a later Ctrl/Cmd+Z never puts back something nothing
4304
+ // on screen offers. adapter.offerUndo returns the toast (showActionToast's
4305
+ // { isShown, hide }); one that returns nothing keeps the Undo until it is
4306
+ // used or the next delete replaces it.
4307
+ let lastUndo = null;
4308
+ function offerUndo(opts, run) {
4309
+ const entry = { run, toast: null };
4310
+ lastUndo = entry;
4311
+ entry.toast = a.offerUndo({ ...opts, undo: () => runUndo(entry) }) || null;
4312
+ }
4313
+ function liveUndo() {
4314
+ const t = lastUndo && lastUndo.toast;
4315
+ if (t && typeof t.isShown === 'function' && !t.isShown()) lastUndo = null;
4316
+ return lastUndo;
4317
+ }
4318
+ async function runUndo(entry) {
4319
+ if (!entry || lastUndo !== entry) return;
4320
+ lastUndo = null;
4321
+ if (entry.toast && typeof entry.toast.hide === 'function') entry.toast.hide();
4322
+ await entry.run();
4323
+ refresh();
4324
+ }
4352
4325
 
4326
+ // Delete a group: the confirm says how many clips have it, then it goes at
4327
+ // once with an Undo (the toast's action, or Ctrl/Cmd+Z) that puts it back
4328
+ // on those clips. adapter.deleteGroup(name) returns what the Undo needs
4329
+ // ({ ok, name, ids, shared }) and adapter.restoreGroup(that) puts it back.
4353
4330
  async function deleteGroup(group) {
4354
4331
  if (!group || protectedHas(group)) return;
4355
- const ok = await dialogs.confirm({ title: `Delete group "${group}"?`, message: 'Items will be ungrouped but not deleted.', okLabel: 'Delete', danger: true });
4332
+ const count = allItems().filter((item) => isInGroup(item, group)).length;
4333
+ const ok = await dialogs.confirm({ title: `Delete group "${group}"?`, message: count ? `It is on ${count} clip${count === 1 ? '' : 's'}. The clips stay.` : '', okLabel: 'Delete', danger: true });
4356
4334
  if (!ok) return;
4357
- await a.deleteGroup(group);
4335
+ const result = await a.deleteGroup(group);
4358
4336
  refresh();
4337
+ if (result && result.ok && a.restoreGroup && a.offerUndo) {
4338
+ offerUndo({ message: `Deleted group "${result.name || group}"` }, () => a.restoreGroup(result));
4339
+ }
4359
4340
  }
4360
4341
  async function tryAssignNumpad(id, slot) {
4361
4342
  const nmap = a.numpadMap ? a.numpadMap() : {};
@@ -4368,11 +4349,33 @@
4368
4349
  await a.numpadAssign(id, slot);
4369
4350
  refresh();
4370
4351
  }
4352
+ // New group(s): the typed text may name several ("todo, forge");
4353
+ // adapter.createGroup(text) returns the tag names it created or reused
4354
+ // (an existing tag in any case is reused), or false. The clips then GET
4355
+ // them (add-only: a toggle would take off a tag a clip already has).
4356
+ async function createGroups() {
4357
+ const text = await dialogs.prompt({ title: 'New group name', okLabel: 'Create' });
4358
+ if (!text) return null;
4359
+ const names = await a.createGroup(text);
4360
+ return Array.isArray(names) && names.length ? names : null;
4361
+ }
4362
+ async function addTags(ids, names) {
4363
+ if (!ids.length || !names.length) return;
4364
+ // ONE write per clip, every name in the comma-separated text: a second
4365
+ // write would still carry the clip's rev from before the first.
4366
+ if (a.groupAssignMany) { await a.groupAssignMany(ids, names.join(', '), true); return; }
4367
+ if (!a.toggleGroup) return;
4368
+ for (const id of ids) {
4369
+ const it = a.itemById(id);
4370
+ const missing = names.filter((name) => !(it && isInGroup(it, name)));
4371
+ // Only the missing names: a toggle of them all adds them all.
4372
+ if (missing.length) await a.toggleGroup(id, missing.join(', '));
4373
+ }
4374
+ }
4371
4375
  async function addGroup(id) {
4372
- const name = await dialogs.prompt({ title: 'New group name', okLabel: 'Create' });
4373
- if (!name) return;
4374
- await a.createGroup(name);
4375
- if (id != null && a.toggleGroup) await a.toggleGroup(id, name); // clip is not yet in the new group, so this adds
4376
+ const names = await createGroups();
4377
+ if (!names) return;
4378
+ if (id != null) await addTags([id], names);
4376
4379
  refresh();
4377
4380
  }
4378
4381
  async function clearAll() {
@@ -4398,12 +4401,11 @@
4398
4401
  // menuHostAt(x, y): a host per open (the demo's menus open in the window
4399
4402
  // they were opened from); else every menu mounts in menuHost.
4400
4403
  const menu = createMenu(a.menuHostAt || a.menuHost || a.dialogHost);
4401
- // Keep hover submenus (filter bar, pickers, popover menus) inside the window.
4404
+ // Keep hover submenus (the clip and bulk menus') inside the window.
4402
4405
  installSubmenuAutoflip(a.menuHost || (typeof document !== 'undefined' ? document : null));
4403
4406
  const selectedIds = new Set();
4404
4407
  let anchorId = null;
4405
4408
  let focusId = null;
4406
- let lastUndo = null;
4407
4409
  // heldId: the row whose buttons stay out while its menu / popover is open
4408
4410
  // or it is being dragged (painted as .actions-held).
4409
4411
  let heldId = null;
@@ -4694,8 +4696,7 @@
4694
4696
  refresh();
4695
4697
  if (snapshots && snapshots.length && a.restoreClips && a.offerUndo) {
4696
4698
  const snaps = snapshots;
4697
- lastUndo = async () => { lastUndo = null; await a.restoreClips(snaps); refresh(); };
4698
- a.offerUndo({ count: snaps.length, undo: () => { if (lastUndo) lastUndo(); } });
4699
+ offerUndo({ count: snaps.length, message: `Deleted ${snaps.length} clip${snaps.length === 1 ? '' : 's'}` }, () => a.restoreClips(snaps));
4699
4700
  } else {
4700
4701
  toast(a.deletedToast);
4701
4702
  }
@@ -4714,10 +4715,11 @@
4714
4715
  refresh();
4715
4716
  }
4716
4717
  async function bulkAddGroup() {
4717
- const name = await dialogs.prompt({ title: 'New group name', okLabel: 'Create' });
4718
- if (!name) return;
4719
- if (a.createGroup) await a.createGroup(name);
4720
- await bulkGroup(name);
4718
+ const ids = selectionInfo().ids;
4719
+ const names = await createGroups();
4720
+ if (!names) return;
4721
+ await addTags(ids, names);
4722
+ refresh();
4721
4723
  }
4722
4724
  async function unifySelection() {
4723
4725
  const ids = selectionInfo().ids;
@@ -4733,10 +4735,10 @@
4733
4735
  menu.open({ x, y, keyboard, html: renderBulkMenu(info, { groups: groupNames(), selectedItems: selItems, items: allItems() }) });
4734
4736
  }
4735
4737
  // The bar's dedicated Group button: a popover with JUST the tri-state group
4736
- // tree (no submenu hop). The full bulk menu stays on right-click.
4738
+ // list (no submenu hop). The full bulk menu stays on right-click.
4737
4739
  function openBulkGroupMenu(x, y) {
4738
4740
  const selItems = selectionInfo().ids.map((id) => a.itemById(id)).filter(Boolean);
4739
- menu.open({ x, y, html: `<div class="bc-menu-list bc-group-list">${bulkGroupTreeHtml(groupNames(), selItems, Search.groupWeights(allItems()))}</div>` });
4741
+ menu.open({ x, y, html: `<div class="bc-menu-list bc-group-list">${bulkGroupListHtml(groupNames(), selItems, Search.groupWeights(allItems()))}</div>` });
4740
4742
  }
4741
4743
  // The popup's single-clip menu: also holds the row's buttons out while it
4742
4744
  // is open and offers "Select N similar" for a text clip (N over all
@@ -4775,13 +4777,13 @@
4775
4777
  menu.open({ id, x, y, html: renderClipMenu(item, { items: allItems(), groups: groupNames(), numpadMap: a.numpadMap ? a.numpadMap() : {}, context: context || a.menuContext }) });
4776
4778
  }
4777
4779
  // The one-clip group picker: the title-bar strip's + and a row's meta +
4778
- // (same clipGroupTreeHtml checklist as the clip menu's submenu, same
4780
+ // (same clipGroupListHtml checklist as the clip menu's submenu, same
4779
4781
  // toggle-group/add-group dispatch - the menu root carries data-id).
4780
4782
  // Mirrors openBulkGroupMenu.
4781
4783
  function openGroupPickerAt(id, x, y, aboveY) {
4782
4784
  const item = a.itemById(id);
4783
4785
  if (!item) return;
4784
- menu.open({ id, x, y, aboveY, onClose: releaseOnClose(id), html: `<div class="bc-menu-list bc-group-list">${clipGroupTreeHtml(groupNames(), item, Search.groupWeights(allItems()))}</div>` });
4786
+ menu.open({ id, x, y, aboveY, onClose: releaseOnClose(id), html: `<div class="bc-menu-list bc-group-list">${clipGroupListHtml(groupNames(), item, Search.groupWeights(allItems()))}</div>` });
4785
4787
  hold(id);
4786
4788
  }
4787
4789
  // A row's numpad badge / ghost #: the keypad (the same renderKeypadMenu the
@@ -4803,9 +4805,9 @@
4803
4805
  // row; the popup still blur-hides the moment the user clicks into one of them.
4804
4806
  // Keyboard opens (Ctrl/Alt+Enter) stay a hand-off: focus moves to the editor.
4805
4807
  const KEEP_POPUP = { keepPopup: true };
4806
- // Filter targets: the chip bar's chips, the group rows in a chip's dropdown
4807
- // and a row's group names (click includes, right-click excludes).
4808
- const FILTER_TARGET = '.filter-tag[data-filter], .filter-tag[data-group], .group-filter-row[data-group], .meta-tag[data-group]';
4808
+ // Filter targets: the chip bar's chips and a row's group names (click
4809
+ // includes, right-click excludes).
4810
+ const FILTER_TARGET = '.filter-tag[data-filter], .filter-tag[data-group], .meta-tag[data-group]';
4809
4811
  async function openClipInEditor(event, row) {
4810
4812
  event.preventDefault(); event.stopPropagation();
4811
4813
  const item = a.itemById(row.dataset.id);
@@ -4959,7 +4961,10 @@
4959
4961
  const slotEl = t.closest('.np-slot.has-content');
4960
4962
  if (slotEl && slotEl.dataset.slotId) { event.stopPropagation(); await a.copyClip(slotEl.dataset.slotId); toast('Copied'); return true; }
4961
4963
  const share = t.closest('.gp-share');
4962
- if (share) { event.stopPropagation(); await a.setGroupSharedAi(share.dataset.group); refresh(); return true; }
4964
+ // The switch has already flipped: its new state IS the choice, so the
4965
+ // direction is the one renderGroupRows drew (by tag, any case), never a
4966
+ // second lookup that could disagree with it.
4967
+ if (share) { event.stopPropagation(); await a.setGroupSharedAi(share.dataset.group, !!share.checked); refresh(); return true; }
4963
4968
  const gpDel = t.closest('.gp-del');
4964
4969
  if (gpDel) { event.stopPropagation(); deleteGroup(gpDel.dataset.group); return true; }
4965
4970
  // A clip's group checklist (clip menu submenu, + / meta popovers): a row
@@ -5106,7 +5111,29 @@
5106
5111
  if (!a.imageZoom) return false;
5107
5112
  return a.imageZoom.onWheel(event);
5108
5113
  }
5114
+ // Quick keys (quickKeyStep): armed by the host each time the popup opens,
5115
+ // ended by the first key that is not one or by any press in the popup (the
5116
+ // search field included), so they never fire mid-search or mid-click.
5117
+ let quickArmed = false;
5118
+ function armQuickKeys() { quickArmed = true; }
5119
+ // Hosts bind it in the capture phase, on the whole popup.
5120
+ function onPointerdown() { quickArmed = false; }
5121
+ function runQuickKey(event) {
5122
+ const step = quickKeyStep(event, {
5123
+ armed: quickArmed,
5124
+ empty: String((a.getQuery && a.getQuery()) || '') === '',
5125
+ blocked: dialogs.isOpen() || menu.isOpen() || !!(a.isSettingsOpen && a.isSettingsOpen()),
5126
+ nmap: a.numpadMap ? a.numpadMap() : {},
5127
+ });
5128
+ quickArmed = step.armed;
5129
+ if (!step.act) return false;
5130
+ event.preventDefault();
5131
+ if (step.act.slot) { if (a.quickPaste) a.quickPaste(step.act.slot); }
5132
+ else if (a.newNote) a.newNote();
5133
+ return true;
5134
+ }
5109
5135
  async function onKeydown(event) {
5136
+ if (runQuickKey(event)) return;
5110
5137
  // Ctrl+= / Ctrl+- / Ctrl+0 size image previews, even under a dialog: the
5111
5138
  // page itself must never zoom.
5112
5139
  if (a.imageZoom && a.imageZoom.onKeydown(event)) return;
@@ -5119,13 +5146,15 @@
5119
5146
  else if (a.hidePopup) a.hidePopup();
5120
5147
  return;
5121
5148
  }
5122
- if (a.isSettingsOpen && a.isSettingsOpen()) return;
5123
- if ((event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') && !mod && !event.altKey && isActivatableControl(event.target)) return;
5124
5149
  // Ctrl/Cmd+A & +Z: the search box is focused nearly always in the app, so
5125
5150
  // route by whether the field actually has text — with text the chord means
5126
5151
  // the FIELD (native select-all / typing undo); empty, it means the LIST.
5127
5152
  const fieldHasText = isTypingTarget(event.target) && typeof event.target.value === 'string' && event.target.value.length > 0;
5128
- if (mod && (event.key === 'z' || event.key === 'Z')) { if (!fieldHasText && lastUndo) { event.preventDefault(); lastUndo(); } return; }
5153
+ // Ctrl/Cmd+Z runs a delete's Undo while its toast shows it, Settings
5154
+ // included (a group is deleted there).
5155
+ if (mod && (event.key === 'z' || event.key === 'Z')) { const undo = !fieldHasText && liveUndo(); if (undo) { event.preventDefault(); runUndo(undo); } return; }
5156
+ if (a.isSettingsOpen && a.isSettingsOpen()) return;
5157
+ if ((event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') && !mod && !event.altKey && isActivatableControl(event.target)) return;
5129
5158
  if (mod && (event.key === 'a' || event.key === 'A')) { if (!fieldHasText) { event.preventDefault(); selectAll(); } return; }
5130
5159
  // The menu key / Shift+F10 opens the cursor row's menu (the bulk menu on
5131
5160
  // a multi-selection) from the keyboard, its first row focused.
@@ -5178,6 +5207,9 @@
5178
5207
  onAuxclick,
5179
5208
  onContextmenu,
5180
5209
  onKeydown,
5210
+ onPointerdown,
5211
+ armQuickKeys,
5212
+ quickKeysArmed: () => quickArmed,
5181
5213
  onWheel,
5182
5214
  onDragstart,
5183
5215
  onMouseover,
@@ -5191,9 +5223,6 @@
5191
5223
  // re-painting selection classes after a full list rebuild) + exposed for tests.
5192
5224
  selection: selectionInfo,
5193
5225
  focusedId: () => focusId,
5194
- // An open menu or popover owns the keys (the app's empty-search digit
5195
- // quick-paste must not fire under it).
5196
- isMenuOpen: () => menu.isOpen(),
5197
5226
  clearSelection,
5198
5227
  selectAll,
5199
5228
  moveFocus,
@@ -5213,9 +5242,8 @@
5213
5242
  heldId: () => heldId,
5214
5243
  // The popup was hidden: the pointer is no longer over a row (its tint goes).
5215
5244
  forgetPointer: () => { if (hoverId != null) { hoverId = null; refreshSimilar(); } },
5216
- // Popup hide/reset must not leave a stale popover, nor a chip's
5217
- // top-layer submenu (popover="manual": nothing else closes it then).
5218
- closeMenu: () => { menu.close(); closeTopLayerSubmenus(a.menuHost || (typeof document !== 'undefined' ? document : null)); },
5245
+ // Popup hide/reset must not leave a stale popover.
5246
+ closeMenu: () => menu.close(),
5219
5247
  };
5220
5248
  }
5221
5249
  // What a search hands the editor's find bar when a clip opens from it: the
@@ -5699,6 +5727,55 @@
5699
5727
  bounds,
5700
5728
  };
5701
5729
  }
5730
+ // Ctrl+N (Cmd+N on a Mac) in the app's note editor window opens a new note
5731
+ // (editor.html -> the popup's New note action). The platform's own modifier
5732
+ // and nothing else: on a Mac Ctrl+N is the text fields' line-down, and AltGr
5733
+ // (= Ctrl+Alt) types characters. A layout with no Latin letters (Cyrillic,
5734
+ // Greek) matches the N key's position instead, as the OS's own shortcuts do.
5735
+ // The demo binds none: browsers keep Ctrl/Cmd+N for themselves.
5736
+ function newNoteKey(e, mac = isMacPlatform()) {
5737
+ if (!e || e.altKey || e.shiftKey || isCompositionKey(e)) return false;
5738
+ if (mac ? (!e.metaKey || e.ctrlKey) : (!e.ctrlKey || e.metaKey)) return false;
5739
+ return keyIsLetter(e, 'n');
5740
+ }
5741
+ // Is this press the letter's key, for a shortcut? A Latin layout's own
5742
+ // letter (Dvorak's n is wherever Dvorak puts it); a layout with no Latin
5743
+ // letters (Cyrillic, Greek) the key's position, as the OS's own shortcuts do.
5744
+ function keyIsLetter(e, letter) {
5745
+ const key = String(e.key || '').toLowerCase();
5746
+ return /^[a-z]$/.test(key) ? key === letter : e.code === `Key${letter.toUpperCase()}`;
5747
+ }
5748
+ // A key the IME is handling (mid composition, or the press that starts one:
5749
+ // Chromium reports key 'Process' / keyCode 229 for it, with isComposing still
5750
+ // false on Windows and the letter itself on a Mac) types into the
5751
+ // composition: it is never a shortcut, whatever key position it is on.
5752
+ function isCompositionKey(e) {
5753
+ return !!(e && (e.isComposing || e.key === 'Process' || e.keyCode === 229));
5754
+ }
5755
+ // Pressing these alone types nothing: it never counts as the first key.
5756
+ const MODIFIER_KEYS = new Set(['Shift', 'Control', 'Alt', 'AltGraph', 'Meta', 'OS', 'Super', 'Hyper', 'Fn', 'FnLock', 'CapsLock', 'NumLock', 'ScrollLock', 'Symbol', 'SymbolLock']);
5757
+ function isModifierKey(e) { return !!(e && MODIFIER_KEYS.has(e.key)); }
5758
+ // The popup's quick keys, right after it opens with the search empty: 1-9
5759
+ // paste that numpad slot, N opens a new note. What a press means there:
5760
+ // { slot } | { note: true } | null (an ordinary key: it types). The digit is
5761
+ // the one the key types (Shift+& on AZERTY is 1); Shift+N types N. A digit
5762
+ // with no clip on its slot types too, and so does any key an IME takes.
5763
+ function quickKeyFor(e, nmap) {
5764
+ if (!e || e.ctrlKey || e.altKey || e.metaKey || isCompositionKey(e) || e.repeat) return null;
5765
+ const key = String(e.key || '');
5766
+ if (/^[1-9]$/.test(key)) return nmap && Number(key) in nmap ? { slot: Number(key) } : null;
5767
+ return !e.shiftKey && keyIsLetter(e, 'n') ? { note: true } : null;
5768
+ }
5769
+ // One key press against the quick keys' state (pure, for the controller):
5770
+ // ctx { armed, empty (the search), blocked (a menu, dialog or settings is
5771
+ // open), nmap }. Any key but a lone modifier ends them until the next open,
5772
+ // a quick key included (one per open).
5773
+ function quickKeyStep(e, ctx) {
5774
+ const c = ctx || {};
5775
+ if (!c.armed) return { armed: false, act: null };
5776
+ if (isModifierKey(e)) return { armed: true, act: null };
5777
+ return { armed: false, act: c.empty && !c.blocked ? quickKeyFor(e, c.nmap) : null };
5778
+ }
5702
5779
  // Shared plain-text editor — ONE implementation mounted by BOTH the desktop
5703
5780
  // app (in its own window) and the website demo (in its clip window).
5704
5781
  // Edits are captured live: every keystroke fires onInput (the host persists
@@ -6964,17 +7041,19 @@
6964
7041
  return changed ? withPinTimestamp(next, ts, 'number') : next;
6965
7042
  });
6966
7043
  }
7044
+ // Puts the tag on the clip, or takes it off (in whatever spelling the clip
7045
+ // carries it: tags ignore case).
6967
7046
  function toggleGroup(items, id, group, now) {
6968
7047
  const ts = now || Math.floor(Date.now() / 1000);
7048
+ const name = Search.cleanTagName(group);
6969
7049
  return (items || []).map((item) => {
6970
7050
  if (itemId(item) !== id) return item;
6971
7051
  const next = { ...item, pin: item.pin ? { ...item.pin } : {} };
6972
- const groups = new Set(groupsOf(next));
6973
- if (groups.has(group)) groups.delete(group);
6974
- else groups.add(group);
6975
- if (groups.size) next.pin.groups = [...groups];
7052
+ const had = groupsOf(next);
7053
+ const groups = had.some((g) => Search.sameTag(g, name)) ? had.filter((g) => !Search.sameTag(g, name)) : (name ? [...had, name] : had);
7054
+ if (groups.length) next.pin.groups = groups;
6976
7055
  else delete next.pin.groups;
6977
- if (typeof next.pin.number !== 'number' && !groups.size) next.pin = null;
7056
+ if (typeof next.pin.number !== 'number' && !groups.length) next.pin = null;
6978
7057
  return withPinTimestamp(next, ts, 'groups');
6979
7058
  });
6980
7059
  }
@@ -7006,14 +7085,9 @@
7006
7085
  builtinFilterCount,
7007
7086
  builtinFilters,
7008
7087
  itemSearchText,
7009
- normalizeTagName,
7010
- tagParentPaths,
7011
- tagMatchesFilter,
7012
- itemMatchesGroupFilter,
7013
- groupFilterCount,
7014
7088
  sourceGroupsFromFilters,
7015
- buildTagTree,
7016
- renderTagTreeMenu,
7089
+ sortTags,
7090
+ renderGroupChips,
7017
7091
  asFilterSet,
7018
7092
  hasActiveFilters,
7019
7093
  filterItems,
@@ -7045,8 +7119,8 @@
7045
7119
  renderClipActions,
7046
7120
  renderClipMenu,
7047
7121
  renderBulkMenu,
7048
- bulkGroupTreeHtml,
7049
- clipGroupTreeHtml,
7122
+ bulkGroupListHtml,
7123
+ clipGroupListHtml,
7050
7124
  renderClipKeys,
7051
7125
  renderSelectionBar,
7052
7126
  attachSearchBox,
@@ -7058,7 +7132,6 @@
7058
7132
  attachScrollFade,
7059
7133
  attachSideScroll,
7060
7134
  attachChipStrip,
7061
- closeTopLayerSubmenus,
7062
7135
  resolveFadeVars,
7063
7136
  FADE_PRESETS,
7064
7137
  attachResizeHandle,
@@ -7115,6 +7188,9 @@
7115
7188
  editorScrollTopForIndex,
7116
7189
  renderWindowBar,
7117
7190
  attachWindowControls,
7191
+ newNoteKey,
7192
+ quickKeyFor,
7193
+ quickKeyStep,
7118
7194
  createEditor,
7119
7195
  createImageViewer,
7120
7196
  lcsSegments,