@brftech/filex-core 0.41.2 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/{ArchiveViewer-Dq1c4-G5.js → ArchiveViewer-CGl_J_qE.js} +2 -2
  2. package/dist/{ArchiveViewer-Dq1c4-G5.js.map → ArchiveViewer-CGl_J_qE.js.map} +1 -1
  3. package/dist/{CsvViewer-CeHslAc0.js → CsvViewer-D-na6SEG.js} +2 -2
  4. package/dist/{CsvViewer-CeHslAc0.js.map → CsvViewer-D-na6SEG.js.map} +1 -1
  5. package/dist/{DrawioViewer-p03u7Oda.js → DrawioViewer-BFkTSrNc.js} +2 -2
  6. package/dist/{DrawioViewer-p03u7Oda.js.map → DrawioViewer-BFkTSrNc.js.map} +1 -1
  7. package/dist/{EpubViewer-D08Y-Tww.js → EpubViewer-BfRE0DTL.js} +2 -2
  8. package/dist/{EpubViewer-D08Y-Tww.js.map → EpubViewer-BfRE0DTL.js.map} +1 -1
  9. package/dist/{IpynbViewer-mG7p3Sil.js → IpynbViewer-BpBlICH-.js} +2 -2
  10. package/dist/{IpynbViewer-mG7p3Sil.js.map → IpynbViewer-BpBlICH-.js.map} +1 -1
  11. package/dist/{MermaidViewer-CCPg1Q3W.js → MermaidViewer-Ry5Ma463.js} +2 -2
  12. package/dist/{MermaidViewer-CCPg1Q3W.js.map → MermaidViewer-Ry5Ma463.js.map} +1 -1
  13. package/dist/{PsdViewer-CDEHTiUN.js → PsdViewer-BmTlq1Gh.js} +2 -2
  14. package/dist/{PsdViewer-CDEHTiUN.js.map → PsdViewer-BmTlq1Gh.js.map} +1 -1
  15. package/dist/{TiffViewer-DnWRUGOK.js → TiffViewer-DE5bKDDx.js} +2 -2
  16. package/dist/{TiffViewer-DnWRUGOK.js.map → TiffViewer-DE5bKDDx.js.map} +1 -1
  17. package/dist/{Viewer3D-COCQp53D.js → Viewer3D-DefcNYOm.js} +2 -2
  18. package/dist/{Viewer3D-COCQp53D.js.map → Viewer3D-DefcNYOm.js.map} +1 -1
  19. package/dist/filex-core.js +1 -1
  20. package/dist/filex-core.umd.cjs +46 -46
  21. package/dist/filex-core.umd.cjs.map +1 -1
  22. package/dist/{index-JUiZzLmF.js → index-B0N5qiyj.js} +9234 -9148
  23. package/dist/index-B0N5qiyj.js.map +1 -0
  24. package/dist/index.d.ts +59 -4
  25. package/dist/style.css +1 -1
  26. package/package.json +1 -1
  27. package/src/FileExplorer.vue +19 -0
  28. package/src/components/FilePane.vue +34 -31
  29. package/src/components/GalleryView.vue +44 -8
  30. package/src/components/GridView.vue +58 -19
  31. package/src/components/HomeView.vue +5 -4
  32. package/src/components/ItemCheck.vue +47 -0
  33. package/src/components/ListView.vue +51 -48
  34. package/src/composables/useRowTouch.ts +85 -35
  35. package/src/styles/base.css +111 -6
  36. package/src/types/ExplorerConfig.ts +32 -0
  37. package/dist/index-JUiZzLmF.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brftech/filex-core",
3
- "version": "0.41.2",
3
+ "version": "0.42.0",
4
4
  "description": "filex core — Vue 3 source of truth for the filex file manager (FileExplorer + ConnectionsPanel SFCs, composables, types)",
5
5
  "type": "module",
6
6
  "main": "./dist/filex-core.umd.cjs",
@@ -3090,6 +3090,16 @@ function openNode(n: FileNode) {
3090
3090
  }
3091
3091
  if (e2eLocked.value && n.type === 'file') return;
3092
3092
  /* /wiring:e2 */
3093
+ // Host-owned open (desktop app): a file opens in the host's OWN window per
3094
+ // document, not in the in-page overlay. Emit and stop — the host listens on
3095
+ // `file-opened` and spawns the window. Directories still navigate inline
3096
+ // (handled above); Space quick-look still peeks in-page. E2E files fell into
3097
+ // the decrypted in-page branch above, so a host window never gets ciphertext.
3098
+ if (props.config.openInHost && n.type === 'file') {
3099
+ emit('file-opened', { path: n.path, basename: n.basename });
3100
+ void markRecent(n);
3101
+ return;
3102
+ }
3093
3103
  // "Aç" / double-click contract: open in a new tab against the
3094
3104
  // standalone editor route, regardless of file type. The editor page
3095
3105
  // picks the right viewer (OnlyOffice for office, Monaco for code/
@@ -3204,6 +3214,13 @@ function openNodeInNewTab(n: FileNode) {
3204
3214
  return;
3205
3215
  }
3206
3216
  /* /wiring:e2 */
3217
+ // Host-owned open (desktop): the context-menu "Aç" opens the host window
3218
+ // too, so Enter/double-click/"Aç" are ONE open path — not a window here and
3219
+ // a system-browser tab there. openNode carries the `file-opened` emit.
3220
+ if (props.config.openInHost) {
3221
+ openNode(n);
3222
+ return;
3223
+ }
3207
3224
  // RBAC: a viewer (no edit on this item) can't use the editable "Aç"
3208
3225
  // surface — drop to the read-only in-page preview instead.
3209
3226
  if (!permCanEdit((n.perm as string) ?? dirPerm.value)) {
@@ -6365,6 +6382,7 @@ function closeRecoveryKey() {
6365
6382
  :order="listingOrder"
6366
6383
  :view-mode="viewMode"
6367
6384
  :view-modes="allowedViewModes"
6385
+ :open-trigger="props.config.openTrigger"
6368
6386
  :show-crumbs="navView !== 'home' /* Home has no address: its cards come
6369
6387
  from every folder in every storage, so a trail would have to name
6370
6388
  one */"
@@ -6843,6 +6861,7 @@ function closeRecoveryKey() {
6843
6861
  :nav-offers-trash="navOffersTrash"
6844
6862
  :view-mode="paneViewMode"
6845
6863
  :view-modes="allowedViewModes"
6864
+ :open-trigger="props.config.openTrigger"
6846
6865
  :clipped="clippedPaths"
6847
6866
  :can-write="canWriteHere"
6848
6867
  :can-paste="!!clipboard.mode"
@@ -217,12 +217,18 @@ const props = withDefaults(
217
217
  * encrypted lock screen) — those are WINDOW states with no listing
218
218
  * behind them, so the pane's own state chain must not also run. */
219
219
  bodyOverride?: boolean;
220
+
221
+ /** Mouse open gesture — 'double' (single selects, double opens) or
222
+ * 'single' (first click opens). Touch always opens on tap; the checkbox
223
+ * always selects. See onViewClick. */
224
+ openTrigger?: 'single' | 'double';
220
225
  }>(),
221
226
  {
222
227
  showCrumbs: true,
223
228
  showFilterBar: true,
224
229
  showViewSwitcher: true,
225
230
  trashVisible: true,
231
+ openTrigger: 'double',
226
232
  },
227
233
  );
228
234
 
@@ -237,7 +243,7 @@ const emit = defineEmits<{
237
243
  /** The virtual `.trash` row was opened — the trash view carries the restore
238
244
  * actions and belongs to the main pane, so the host decides. */
239
245
  (e: 'open-trash'): void;
240
- (e: 'click-row', node: FileNode, mod: { ctrl: boolean; shift: boolean; touch?: boolean }): void;
246
+ (e: 'click-row', node: FileNode, mod: { ctrl: boolean; shift: boolean }): void;
241
247
  /** Right-click. `null` = empty space (nothing selected → "Paste" only). */
242
248
  (e: 'context', node: FileNode | null, ev: MouseEvent): void;
243
249
  (e: 'clear-selection'): void;
@@ -444,48 +450,45 @@ const paneSubfolders = computed(() =>
444
450
  * `useSelection` instance for this pane answers. One set of Ctrl/Shift
445
451
  * semantics for both panes — the old right pane had a simplified copy in
446
452
  * which Shift behaved as Ctrl. */
447
- /* issue #26, second round a press on the NAME opens, so the second click of
448
- * a habitual double-click lands on whatever the NEW listing put under the
453
+ /* issue #26 any click that is not on the checkbox OPENS, so the second click
454
+ * of a habitual double-click lands on whatever the NEW listing put under the
449
455
  * pointer (the folder already opened on the first click). Without this guard
450
- * that click would open or select a row the user never aimed at, and a file's
451
- * name would open twice (two viewer tabs). */
452
- const NAME_OPEN_GUARD_MS = 500;
453
- let nameOpenedAt = 0;
456
+ * that click would open a row the user never aimed at or tick its box — and
457
+ * a file would open twice (two viewer tabs). */
458
+ const OPEN_GUARD_MS = 500;
459
+ let openedAt = 0;
454
460
 
455
- function withinNameOpenGuard(): boolean {
456
- return Date.now() - nameOpenedAt < NAME_OPEN_GUARD_MS;
461
+ function withinOpenGuard(): boolean {
462
+ return Date.now() - openedAt < OPEN_GUARD_MS;
457
463
  }
458
464
 
459
465
  function onViewClick(n: FileNode, mod: ClickMod) {
460
- if (withinNameOpenGuard()) return;
461
- /* issue #26, second round the reporter's rule on every device: a press on
462
- * the item's name opens it, mouse or finger, selection or not. Ctrl/shift
463
- * still mean "add to / extend the selection", so a modifier click on a name
464
- * selects as it always did. The checkbox selects on its own path. */
465
- if (mod.name && !mod.ctrl && !mod.shift) {
466
- nameOpenedAt = Date.now();
467
- onRowOpen(n);
466
+ if (withinOpenGuard()) return;
467
+ /* The checkbox is the one click that always reaches the selection issue
468
+ * #26's rule, kept on every device and in either open mode. A shift-tick
469
+ * still extends the range; a plain tick toggles (ctrl: true). */
470
+ if (mod.check) {
471
+ emit('activate');
472
+ emit('click-row', n, { ctrl: true, shift: mod.shift });
468
473
  return;
469
474
  }
470
- /* issue #26 — a finger has no double-click, so a TAP elsewhere on the item
471
- * is the open gesture too: with nothing selected it opens what it lands on,
472
- * exactly as a double-click would. Once something is selected (a long press
473
- * selects, via its menu) such a tap adds to or removes from the selection,
474
- * so picking several files still works. A mouse click beside the name keeps
475
- * click-to-select; see composables/useRowTouch. */
476
- if (mod.touch && !mod.ctrl && !mod.shift) {
477
- if (props.selected.size === 0) {
478
- onRowOpen(n);
479
- return;
480
- }
481
- mod = { ...mod, ctrl: true };
475
+ /* A finger tap always OPENS (the mobile convention there is no
476
+ * hover-to-select on a touchscreen). For a mouse the host's `openTrigger`
477
+ * decides: 'single' opens on this click; 'double' (default) makes a single
478
+ * click SELECT and leaves opening to onViewDbl. issue #26's "any click
479
+ * opens" is `openTrigger: 'single'`. */
480
+ if (mod.touch || props.openTrigger === 'single') {
481
+ openedAt = Date.now();
482
+ onRowOpen(n);
483
+ return;
482
484
  }
483
485
  emit('activate');
484
- emit('click-row', n, mod);
486
+ emit('click-row', n, { ctrl: mod.ctrl, shift: mod.shift });
485
487
  }
486
488
 
487
489
  function onViewDbl(n: FileNode) {
488
- if (withinNameOpenGuard()) return;
490
+ if (withinOpenGuard()) return;
491
+ openedAt = Date.now();
489
492
  onRowOpen(n);
490
493
  }
491
494
 
@@ -13,7 +13,8 @@ import { hasInternalDrag } from '../lib/dragOut';
13
13
  import type { FileNode } from '../types/FileNode';
14
14
  import type { LocaleCode } from '../types/ExplorerConfig';
15
15
  import { useLocale } from '../composables/useLocale';
16
- import { clickMod, useRowTouch, type ClickMod } from '../composables/useRowTouch';
16
+ import { checkMod, clickMod, useRowTouch, type ClickMod } from '../composables/useRowTouch';
17
+ import ItemCheck from './ItemCheck.vue';
17
18
  import { encryptedFolderTile, fileIconTile, isEncryptedFolder } from '../lib/fileIcons';
18
19
  import {
19
20
  createFilePreviews,
@@ -30,7 +31,7 @@ import {
30
31
  } from '../lib/dateGroups'; /* gruplama */
31
32
  import { useSortStore, type ListingOrder } from '../lib/sortOrder'; /* gruplama */
32
33
 
33
- const props = defineProps<{
34
+ const props = withDefaults(defineProps<{
34
35
  files: FileNode[];
35
36
  selected: Set<string>;
36
37
  clipped?: Set<string>;
@@ -74,7 +75,16 @@ const props = defineProps<{
74
75
  * order the tiles are not in. Omitted = an ordinary folder listing.
75
76
  */
76
77
  order?: ListingOrder;
77
- }>();
78
+ /**
79
+ * issue #26 — draw the checkbox on each card, the one click that selects.
80
+ * Default on. Same prop, same meaning as GridView's.
81
+ */
82
+ selectable?: boolean;
83
+ }>(), {
84
+ // ⚠ Not left to `undefined`: Vue casts an absent boolean prop to false, and
85
+ // the listing would lose its checkboxes wherever the host said nothing.
86
+ selectable: true,
87
+ });
78
88
 
79
89
  const emit = defineEmits<{
80
90
  (e: 'click-card', node: FileNode, mod: ClickMod): void;
@@ -158,8 +168,17 @@ function isSelected(n: FileNode): boolean {
158
168
  return props.selected.has(n.path);
159
169
  }
160
170
 
171
+ /**
172
+ * issue #26 — the card's checkbox is the one click that selects (a click
173
+ * anywhere else on the card opens it), routed exactly like the list row's:
174
+ * one `click-card` emit marked `check`, one `useSelection.click`.
175
+ */
176
+ function onCheckClick(n: FileNode, ev: MouseEvent) {
177
+ emit('click-card', n, checkMod(ev));
178
+ }
179
+
161
180
  function onClick(n: FileNode, ev: MouseEvent) {
162
- emit('click-card', n, clickMod(ev, touch.isTap(ev), '.fe-gal__label'));
181
+ emit('click-card', n, clickMod(ev, touch.isTap(ev)));
163
182
  }
164
183
 
165
184
  function onDbl(n: FileNode) {
@@ -207,8 +226,13 @@ function onItemDrop(n: FileNode, ev: DragEvent) {
207
226
  }
208
227
 
209
228
  /* Long press → the card's menu; a tap is reported as a tap (issue #26). */
210
- const touch = useRowTouch<FileNode>((n, at) =>
211
- emit('context-card', n, { ...at, preventDefault: () => {}, stopPropagation: () => {} } as unknown as MouseEvent),
229
+ const touch = useRowTouch<FileNode>(
230
+ (n, at) =>
231
+ emit('context-card', n, { ...at, preventDefault: () => {}, stopPropagation: () => {} } as unknown as MouseEvent),
232
+ {
233
+ // A finger lifted on the item, off its controls, opens at touchend — see useRowTouch.
234
+ onTap: (n) => emit('click-card', n, { ctrl: false, shift: false, touch: true }),
235
+ },
212
236
  );
213
237
 
214
238
  /**
@@ -233,7 +257,7 @@ function metaFor(n: FileNode): string {
233
257
  <template>
234
258
  <div
235
259
  class="fe-gal"
236
- :class="{ 'is-loading': loading }"
260
+ :class="{ 'is-loading': loading, 'has-selection': selectable && selected.size > 0 }"
237
261
  role="listbox"
238
262
  aria-multiselectable="true"
239
263
  :aria-label="t('gallery.aria')"
@@ -268,7 +292,7 @@ function metaFor(n: FileNode): string {
268
292
  @dragleave="onItemDragLeave(n)"
269
293
  @drop="onItemDrop(n, $event)"
270
294
  @touchstart.passive="touch.onTouchStart(n, $event)"
271
- @touchend.passive="touch.onTouchEnd"
295
+ @touchend="touch.onTouchEnd"
272
296
  @touchmove.passive="touch.onTouchMove"
273
297
  >
274
298
  <div class="fe-gal__thumb" :ref="(el) => bindPreview(el, n)">
@@ -346,6 +370,18 @@ function metaFor(n: FileNode): string {
346
370
  class="fe-thumb__play"
347
371
  aria-hidden="true"
348
372
  ></span>
373
+ <!-- issue #26 — the card's checkbox, the one click that selects: the
374
+ corner opposite the star, on the same reveal rule plus "anything
375
+ is selected" (styles/base.css .fe-item-check). -->
376
+ <span
377
+ v-if="selectable"
378
+ class="fe-item-check fe-gal__check"
379
+ data-fe-control
380
+ @click.stop="onCheckClick(n, $event)"
381
+ @dblclick.stop
382
+ >
383
+ <ItemCheck :on="isSelected(n)" :label="nodeDisplayName(n)" />
384
+ </span>
349
385
  <div v-if="canStar(n)" class="fe-gal__star" @click.stop @dblclick.stop>
350
386
  <StarButton
351
387
  :starred="!!starredIds?.has(n.id!)"
@@ -20,7 +20,8 @@ import type { FileNode } from '../types/FileNode';
20
20
  import { hasInternalDrag } from '../lib/dragOut';
21
21
  import type { LocaleCode } from '../types/ExplorerConfig';
22
22
  import { useLocale } from '../composables/useLocale';
23
- import { clickMod, useRowTouch, type ClickMod } from '../composables/useRowTouch';
23
+ import { checkMod, clickMod, useRowTouch, type ClickMod } from '../composables/useRowTouch';
24
+ import ItemCheck from './ItemCheck.vue';
24
25
  import { encryptedFolderTile, fileIconTile, isEncryptedFolder } from '../lib/fileIcons';
25
26
  import {
26
27
  createFilePreviews,
@@ -38,7 +39,7 @@ import StarButton from './StarButton.vue';
38
39
  import { snippetSegments } from '../lib/snippet'; /* bul:s3 */
39
40
  import { applyDragGhost } from '../lib/dragGhost'; /* wiring:c4 */
40
41
 
41
- const props = defineProps<{
42
+ const props = withDefaults(defineProps<{
42
43
  files: FileNode[];
43
44
  selected: Set<string>;
44
45
  clipped?: Set<string>;
@@ -108,7 +109,17 @@ const props = defineProps<{
108
109
  * type tiles. Absent/false = the ordinary case.
109
110
  */
110
111
  e2eActive?: boolean;
111
- }>();
112
+ /**
113
+ * issue #26 — draw the checkbox on each card, the one click that selects.
114
+ * Default on. Home passes false: its cards open on a click and there is no
115
+ * selection there to put anything into.
116
+ */
117
+ selectable?: boolean;
118
+ }>(), {
119
+ // ⚠ Not left to `undefined`: Vue casts an absent boolean prop to false, and
120
+ // the listing would lose its checkboxes wherever the host said nothing.
121
+ selectable: true,
122
+ });
112
123
 
113
124
  const emit = defineEmits<{
114
125
  (e: 'click-card', node: FileNode, mod: ClickMod): void;
@@ -267,8 +278,17 @@ function isSelected(n: FileNode): boolean {
267
278
  return props.selected.has(n.path);
268
279
  }
269
280
 
281
+ /**
282
+ * issue #26 — the card's checkbox is the one click that selects (a click
283
+ * anywhere else on the card opens it), routed exactly like the list row's:
284
+ * one `click-card` emit marked `check`, one `useSelection.click`.
285
+ */
286
+ function onCheckClick(n: FileNode, ev: MouseEvent) {
287
+ emit('click-card', n, checkMod(ev));
288
+ }
289
+
270
290
  function onClick(n: FileNode, ev: MouseEvent) {
271
- emit('click-card', n, clickMod(ev, touch.isTap(ev), '.fe-grid__label'));
291
+ emit('click-card', n, clickMod(ev, touch.isTap(ev)));
272
292
  }
273
293
 
274
294
  function onDbl(n: FileNode) {
@@ -329,8 +349,13 @@ function onItemDrop(n: FileNode, ev: DragEvent) {
329
349
  }
330
350
 
331
351
  /* Long press → the card's menu; a tap is reported as a tap (issue #26). */
332
- const touch = useRowTouch<FileNode>((n, at) =>
333
- emit('context-card', n, { ...at, preventDefault: () => {}, stopPropagation: () => {} } as unknown as MouseEvent),
352
+ const touch = useRowTouch<FileNode>(
353
+ (n, at) =>
354
+ emit('context-card', n, { ...at, preventDefault: () => {}, stopPropagation: () => {} } as unknown as MouseEvent),
355
+ {
356
+ // A finger lifted on the item, off its controls, opens at touchend — see useRowTouch.
357
+ onTap: (n) => emit('click-card', n, { ctrl: false, shift: false, touch: true }),
358
+ },
334
359
  );
335
360
 
336
361
  /**
@@ -382,7 +407,7 @@ function snippetTitle(snippet: string): string {
382
407
  localized label + busy state. Structure/layout untouched. -->
383
408
  <div
384
409
  class="fe-grid"
385
- :class="{ 'is-loading': loading }"
410
+ :class="{ 'is-loading': loading, 'has-selection': selectable && selected.size > 0 }"
386
411
  role="listbox"
387
412
  aria-multiselectable="true"
388
413
  :aria-label="t('grid.aria')"
@@ -418,7 +443,7 @@ function snippetTitle(snippet: string): string {
418
443
  @dragleave="onItemDragLeave(n) /* wiring:c4 */"
419
444
  @drop="onItemDrop(n, $event)"
420
445
  @touchstart.passive="touch.onTouchStart(n, $event)"
421
- @touchend.passive="touch.onTouchEnd"
446
+ @touchend="touch.onTouchEnd"
422
447
  @touchmove.passive="touch.onTouchMove"
423
448
  >
424
449
  <!-- gorunum:v1 — the preview, files only: 184×108. A thumbnail when
@@ -496,7 +521,7 @@ function snippetTitle(snippet: string): string {
496
521
  the person looking for what they starred, so the chip is always
497
522
  painted once the file IS starred and only appears on hover/focus
498
523
  otherwise (see .fe-grid__star in styles/base.css). @click.stop so
499
- starring never doubles as a card selection. -->
524
+ starring never doubles as opening the card. -->
500
525
  <!-- gorunum:v1-preview — a frame lifted out of a video is, on a card,
501
526
  indistinguishable from a photograph. The badge is the difference,
502
527
  and it is drawn only over a real frame: a video that fell back to
@@ -525,16 +550,30 @@ function snippetTitle(snippet: string): string {
525
550
  On a file it is the 56px footer under the preview; on a folder it is
526
551
  the whole 56px card. -->
527
552
  <div class="fe-grid__foot">
528
- <!-- eslint-disable-next-line vue/no-v-htmlstatic markup from lib/fileIcons -->
529
- <span
530
- v-if="isEncryptedFolder(n)"
531
- class="fe-grid__tile fe-grid__tile--svg"
532
- role="img"
533
- :aria-label="t('e2e.badge')"
534
- v-html="encryptedFolderTile()"
535
- ></span>
536
- <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
537
- <span v-else class="fe-grid__tile fe-grid__tile--svg" v-html="fileIconTile(n)"></span>
553
+ <!-- issue #26the checkbox takes the tile's place while it shows
554
+ (hovered, focused, selected, or once anything is selected), so
555
+ the name does not move when it appears. -->
556
+ <span class="fe-grid__lead" :class="{ 'fe-grid__lead--pick': selectable }">
557
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
558
+ <span
559
+ v-if="isEncryptedFolder(n)"
560
+ class="fe-grid__tile fe-grid__tile--svg"
561
+ role="img"
562
+ :aria-label="t('e2e.badge')"
563
+ v-html="encryptedFolderTile()"
564
+ ></span>
565
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
566
+ <span v-else class="fe-grid__tile fe-grid__tile--svg" v-html="fileIconTile(n)"></span>
567
+ <span
568
+ v-if="selectable"
569
+ class="fe-item-check"
570
+ data-fe-control
571
+ @click.stop="onCheckClick(n, $event)"
572
+ @dblclick.stop
573
+ >
574
+ <ItemCheck :on="isSelected(n)" :label="nodeDisplayName(n)" />
575
+ </span>
576
+ </span>
538
577
  <div class="fe-grid__main">
539
578
  <div class="fe-grid__label" :title="n.basename">
540
579
  {{ nodeDisplayName(n) }}
@@ -152,10 +152,9 @@ function storageCaption(s: HomeStorage): string {
152
152
  /**
153
153
  * A card opens what it names.
154
154
  *
155
- * SINGLE click, and that is not an inconsistency with the listing behind it.
156
- * A card in the listing has two jobs click selects, double click opens and
157
- * on Home the first of those does not exist (see NO_SELECTION), so a click
158
- * that only selected would be a click that did nothing at all.
155
+ * A single click, exactly as in the listing behind it (issue #26: a click
156
+ * anywhere but the checkbox opens). Home's cards carry no checkbox at all
157
+ * there is no selection here to tick anything into (see NO_SELECTION).
159
158
  *
160
159
  * ⚠ Which is why the double click has to be swallowed: a real double click
161
160
  * fires `click-card` twice, and two opens of a file are two viewer tabs. The
@@ -246,6 +245,7 @@ function openNode(n: FileNode) {
246
245
  v-if="shownRecent.length"
247
246
  :files="shownRecent"
248
247
  :selected="NO_SELECTION"
248
+ :selectable="false"
249
249
  :locale="locale"
250
250
  :thumb-src="thumbSrc"
251
251
  @click-card="openNode"
@@ -292,6 +292,7 @@ function openNode(n: FileNode) {
292
292
  v-if="shownStarred.length"
293
293
  :files="shownStarred"
294
294
  :selected="NO_SELECTION"
295
+ :selectable="false"
295
296
  :locale="locale"
296
297
  :thumb-src="thumbSrc"
297
298
  @click-card="openNode"
@@ -0,0 +1,47 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * ItemCheck — the checkbox on a listing item: the list row's tick column and
4
+ * the grid and gallery cards' corner (issue #26, fourth round: "only clicking
5
+ * on checkbox selects it"). One element for the three views, so the tick a
6
+ * card shows cannot drift from the one a row shows.
7
+ *
8
+ * The view binds the click on the wrapper around it and routes it through
9
+ * `checkMod` — the same `click-row` emit and the same `useSelection.click`
10
+ * every selection goes through, so shift ranges and toggles are one
11
+ * implementation, not two.
12
+ *
13
+ * ⚠ A button with role="checkbox", NOT an <input type="checkbox">. The input
14
+ * owns a `checked` state of its own: the browser flips it before the click
15
+ * handler runs and, if the default is prevented, flips it back AFTER Vue has
16
+ * patched — so the row went selected while the box stayed empty (measured:
17
+ * rowSelected=true, domChecked=false). Drawing the state from the selection
18
+ * leaves nothing to drift. Space and Enter still tick it, because a button
19
+ * fires `click` for both.
20
+ *
21
+ * ⚠ A mouse press does not move focus onto it (`mousedown.prevent`). The tick
22
+ * is now the ONLY click that selects, so "tick, then Space" is the way to
23
+ * quick-look a file — and Space on a focused button is that button's own
24
+ * activation, which the shortcut layer rightly leaves alone: the peek would
25
+ * never open and the tick would flip back off. Tab still reaches it, and Space
26
+ * still ticks it from the keyboard.
27
+ */
28
+ defineProps<{
29
+ /** Whether the item is selected. */
30
+ on: boolean;
31
+ /** The item's display name — the checkbox's accessible name. */
32
+ label: string;
33
+ }>();
34
+ </script>
35
+
36
+ <template>
37
+ <button
38
+ type="button"
39
+ class="fe-list__check"
40
+ :class="{ 'is-on': on }"
41
+ role="checkbox"
42
+ :aria-checked="on ? 'true' : 'false'"
43
+ :aria-label="label"
44
+ :title="label"
45
+ @mousedown.prevent
46
+ ></button>
47
+ </template>
@@ -11,7 +11,8 @@ import { hasInternalDrag } from '../lib/dragOut';
11
11
  import type { FileNode } from '../types/FileNode';
12
12
  import type { LocaleCode, ThemeMode } from '../types/ExplorerConfig';
13
13
  import { useLocale } from '../composables/useLocale';
14
- import { clickMod, useRowTouch, type ClickMod } from '../composables/useRowTouch';
14
+ import { checkMod, clickMod, useRowTouch, type ClickMod } from '../composables/useRowTouch';
15
+ import ItemCheck from './ItemCheck.vue';
15
16
  import {
16
17
  arrivedFromOutside,
17
18
  ownedByViewer,
@@ -174,7 +175,7 @@ function isSelected(n: FileNode): boolean {
174
175
  }
175
176
 
176
177
  function onRowClick(n: FileNode, ev: MouseEvent) {
177
- emit('click-row', n, clickMod(ev, touch.isTap(ev), '.fe-list__name'));
178
+ emit('click-row', n, clickMod(ev, touch.isTap(ev)));
178
179
  }
179
180
 
180
181
  function onRowDbl(n: FileNode) {
@@ -212,17 +213,14 @@ function onRowMenu(n: FileNode, ev: MouseEvent) {
212
213
  }
213
214
 
214
215
  /**
215
- * gorunum:v1 — the row checkbox drives THE SAME selection the row click
216
- * drives: one `click-row` emit, one `useSelection.click`, one anchor. A
217
- * checkbox that kept its own Set would disagree with the row the first time
218
- * someone mixed the two, and the context menu acts on the row's set.
219
- *
220
- * `ctrl: true` because ticking a box adds to a selection rather than replacing
221
- * it; `shift` is passed through so shift-clicking a box still extends the
222
- * range from the anchor (the composable answers shift before ctrl).
216
+ * issue #26 — the checkbox is the one click that selects (a click anywhere
217
+ * else on the row opens it). It goes out as the same `click-row` emit every
218
+ * other gesture uses, marked `check`, so FilePane routes it to the one
219
+ * `useSelection.click` one anchor, one set, the one the context menu acts
220
+ * on. See composables/useRowTouch `checkMod`.
223
221
  */
224
222
  function onCheckClick(n: FileNode, ev: MouseEvent) {
225
- emit('click-row', n, { ctrl: true, shift: ev.shiftKey });
223
+ emit('click-row', n, checkMod(ev));
226
224
  }
227
225
 
228
226
  function onItemDragStart(n: FileNode, ev: DragEvent) {
@@ -266,8 +264,13 @@ function onItemDrop(n: FileNode, ev: DragEvent) {
266
264
  }
267
265
 
268
266
  /* Long press → the row's menu; a tap is reported as a tap (issue #26). */
269
- const touch = useRowTouch<FileNode>((n, at) =>
270
- emit('context-row', n, { ...at, preventDefault: () => {}, stopPropagation: () => {} } as unknown as MouseEvent),
267
+ const touch = useRowTouch<FileNode>(
268
+ (n, at) =>
269
+ emit('context-row', n, { ...at, preventDefault: () => {}, stopPropagation: () => {} } as unknown as MouseEvent),
270
+ {
271
+ // A finger lifted on the item, off its controls, opens at touchend — see useRowTouch.
272
+ onTap: (n) => emit('click-row', n, { ctrl: false, shift: false, touch: true }),
273
+ },
271
274
  );
272
275
 
273
276
  function keepGlyph(b: 'kept' | 'syncing' | 'cloud' | 'partial'): string {
@@ -1226,31 +1229,21 @@ const segments = computed<DateRun<FileNode>[]>(() =>
1226
1229
  @dragleave="onItemDragLeave(n) /* wiring:c4 */"
1227
1230
  @drop="onItemDrop(n, $event)"
1228
1231
  @touchstart.passive="touch.onTouchStart(n, $event)"
1229
- @touchend.passive="touch.onTouchEnd"
1232
+ @touchend="touch.onTouchEnd"
1230
1233
  @touchmove.passive="touch.onTouchMove"
1231
1234
  >
1232
- <!-- gorunum:v1 — the tick drives the SAME selection the row click
1233
- does (onCheckClick click-row useSelection.click), so shift
1234
- ranges and ctrl toggles are one implementation, not two.
1235
- A button with role="checkbox", NOT an <input type="checkbox">.
1236
- The input owns a `checked` state of its own: the browser flips it
1237
- before the click handler runs and, if the default is prevented,
1238
- flips it back AFTER Vue has patched — so the row went selected
1239
- while the box stayed empty (measured: rowSelected=true,
1240
- domChecked=false). Drawing the state from the selection leaves
1241
- nothing to drift. Space and Enter still tick it, because a button
1242
- fires `click` for both. -->
1243
- <div class="fe-list__col fe-list__col--check" role="gridcell" @click.stop @dblclick.stop>
1244
- <button
1245
- type="button"
1246
- class="fe-list__check"
1247
- :class="{ 'is-on': isSelected(n) }"
1248
- role="checkbox"
1249
- :aria-checked="isSelected(n) ? 'true' : 'false'"
1250
- :aria-label="nodeDisplayName(n)"
1251
- :title="nodeDisplayName(n)"
1252
- @click.stop="onCheckClick(n, $event)"
1253
- ></button>
1235
+ <!-- issue #26 — the tick is the one click that selects; the whole
1236
+ cell is its target, so a click that just misses the box does not
1237
+ open the row instead. `data-fe-control` keeps a finger on the
1238
+ cell from counting as a tap on the row (useRowTouch). -->
1239
+ <div
1240
+ class="fe-list__col fe-list__col--check"
1241
+ role="gridcell"
1242
+ data-fe-control
1243
+ @click.stop="onCheckClick(n, $event)"
1244
+ @dblclick.stop
1245
+ >
1246
+ <ItemCheck :on="isSelected(n)" :label="nodeDisplayName(n)" />
1254
1247
  </div>
1255
1248
  <div class="fe-list__col fe-list__col--name" role="gridcell" :style="colStyle('name')">
1256
1249
  <!-- ikon:emoji — an encrypted folder is still a FOLDER: it keeps the
@@ -1323,25 +1316,35 @@ const segments = computed<DateRun<FileNode>[]>(() =>
1323
1316
  class="fe-list__col fe-list__col--star"
1324
1317
  role="gridcell"
1325
1318
  :style="colStyle(id)"
1326
- @click.stop
1327
1319
  >
1328
- <StarButton
1320
+ <!-- issue #26 — only the star itself is a control; the rest of the
1321
+ cell (all of it on a folder, which has no star) opens the row
1322
+ like any other empty space on it. `display: contents`, so the
1323
+ wrapper stops the click without moving the star. -->
1324
+ <span
1329
1325
  v-if="starEnabled !== false && typeof n.id === 'number' && n.type === 'file'"
1330
- :starred="!!starredIds?.has(n.id)"
1331
- :node-id="n.id"
1332
- :api-base="apiBase"
1333
- :auth-headers="authHeaders"
1334
- :auth-credentials="authCredentials"
1335
- :locale="locale"
1336
- compact
1337
- @change="(val: boolean) => emit('star-change', n, val)"
1338
- />
1326
+ class="fe-list__star-hit"
1327
+ data-fe-control
1328
+ @click.stop
1329
+ @dblclick.stop
1330
+ >
1331
+ <StarButton
1332
+ :starred="!!starredIds?.has(n.id)"
1333
+ :node-id="n.id"
1334
+ :api-base="apiBase"
1335
+ :auth-headers="authHeaders"
1336
+ :auth-credentials="authCredentials"
1337
+ :locale="locale"
1338
+ compact
1339
+ @change="(val: boolean) => emit('star-change', n, val)"
1340
+ />
1341
+ </span>
1339
1342
  </div>
1340
1343
  <div v-else :class="colClass(id)" role="gridcell" :style="colStyle(id)" :title="cellTitle(id, n)">
1341
1344
  {{ cellText(id, n) }}
1342
1345
  </div>
1343
1346
  </template>
1344
- <div class="fe-list__col fe-list__col--menu" role="gridcell" @click.stop @dblclick.stop>
1347
+ <div class="fe-list__col fe-list__col--menu" role="gridcell" data-fe-control @click.stop @dblclick.stop>
1345
1348
  <button
1346
1349
  type="button"
1347
1350
  class="fe-list__menu"