@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.
- package/README.md +5 -2
- package/editor-preload.js +2 -0
- package/editor.html +12 -0
- package/index.html +94 -48
- package/lib/clipboard-model.js +588 -23
- package/lib/mcp-core.js +240 -69
- package/lib/sync-delta.js +5 -2
- package/lib/windows-hook-policy.js +127 -0
- package/lib/windows-hook-worker.js +18 -77
- package/lib/windows-hook.js +20 -9
- package/main.js +812 -188
- package/mcp/boardclip-mcp.js +68 -39
- package/mcp-approval.html +4 -2
- package/package.json +1 -1
- package/preload.js +4 -2
- package/site/index.html +108 -37
- package/site/shared/clip-search.js +291 -134
- package/site/shared/clipboard-popup.css +8 -20
- package/site/shared/clipboard-ui-core.js +405 -329
- package/viewer.html +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
129
|
+
.map(Search.cleanTagName)
|
|
144
130
|
.filter(group => group && !group.startsWith('__'));
|
|
145
131
|
}
|
|
146
|
-
// The
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
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
|
|
152
|
-
const
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
if (!
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
function
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
?
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
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
|
|
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
|
|
568
|
-
const
|
|
569
|
-
const
|
|
570
|
-
|
|
571
|
-
const
|
|
572
|
-
const selected =
|
|
573
|
-
const excluded =
|
|
574
|
-
const rest =
|
|
575
|
-
const
|
|
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
|
-
+
|
|
529
|
+
+ renderGroupChips(selected, chipOpts) + renderGroupChips(excluded, chipOpts) + (rest.length ? sep : '');
|
|
580
530
|
}
|
|
581
|
-
html +=
|
|
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
|
|
733
|
-
if (!
|
|
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
|
|
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 =
|
|
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.
|
|
819
|
-
//
|
|
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
|
|
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
|
|
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:
|
|
1159
|
-
// membership glyph in the icon column (state(group) 'all' |
|
|
1160
|
-
// = check / dash / nothing, in the accent; labels stay
|
|
1161
|
-
//
|
|
1162
|
-
//
|
|
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(
|
|
1165
|
-
return (
|
|
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
|
-
|
|
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
|
-
|
|
1178
|
-
|
|
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' },
|
|
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
|
|
1230
|
-
const itemGroups =
|
|
1231
|
-
|
|
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' },
|
|
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
|
|
1232
|
+
function bulkGroupListHtml(groups, selItems, weights) {
|
|
1260
1233
|
const sel = selItems || [];
|
|
1261
|
-
|
|
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.
|
|
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
|
|
2553
|
-
|
|
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
|
|
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
|
|
2729
|
-
// listener per consumer root covers the
|
|
2730
|
-
//
|
|
2731
|
-
//
|
|
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 = (
|
|
3079
|
-
const 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
|
|
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],
|
|
3615
|
-
//
|
|
3616
|
-
//
|
|
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 &&
|
|
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
|
|
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
|
|
4373
|
-
if (!
|
|
4374
|
-
await
|
|
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 (
|
|
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
|
-
|
|
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
|
|
4718
|
-
|
|
4719
|
-
if (
|
|
4720
|
-
await
|
|
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
|
-
//
|
|
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">${
|
|
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
|
|
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">${
|
|
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
|
|
4807
|
-
//
|
|
4808
|
-
const FILTER_TARGET = '.filter-tag[data-filter], .filter-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
|
-
|
|
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
|
-
|
|
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
|
|
5217
|
-
|
|
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
|
|
6973
|
-
|
|
6974
|
-
|
|
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.
|
|
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
|
-
|
|
7016
|
-
|
|
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
|
-
|
|
7049
|
-
|
|
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,
|