From 58bb87bf36d6ceff2be70cf334708b7deeeded4c Mon Sep 17 00:00:00 2001 From: Charles Kelsoe Date: Thu, 4 Jun 2026 12:09:18 -0400 Subject: [PATCH] Release 2.5.0: per-format ribbon icons and full icon picker Add a Show in ribbon toggle per custom format that registers a left-ribbon icon copying that format from the active note. Off by default; icons register in format-list order. Add a Browse all icons button to the format icon picker, opening a searchable virtualized grid of every Obsidian icon (getIconIds) so a format can use any icon. Pure filter/window helpers are unit tested. --- CHANGELOG.md | 6 ++ README.md | 13 ++-- __tests__/icon-collect.test.ts | 48 ++++++++++++ __tests__/menu-placement.test.ts | 1 + __tests__/seed-utils.test.ts | 3 +- __tests__/virtual-window.test.ts | 77 +++++++++++++++++++ icon-collect.ts | 17 +++++ main.ts | 57 +++++++++++++- manifest.json | 2 +- package-lock.json | 4 +- package.json | 2 +- seed-utils.ts | 3 + select-icon-modal.ts | 125 +++++++++++++++++++++++++++++++ styles.css | 100 +++++++++++++++++++++++++ versions.json | 3 +- virtual-list.ts | 86 +++++++++++++++++++++ virtual-window.ts | 50 +++++++++++++ 17 files changed, 582 insertions(+), 15 deletions(-) create mode 100644 __tests__/icon-collect.test.ts create mode 100644 __tests__/virtual-window.test.ts create mode 100644 icon-collect.ts create mode 100644 select-icon-modal.ts create mode 100644 virtual-list.ts create mode 100644 virtual-window.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b5f2c9d..5a37cce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.5.0] - 2026-06-04 + +### Added +- Per-format ribbon icons. Each custom format has a "Show in ribbon" toggle in its settings editor; when on, it adds a left-ribbon icon that copies that format from the active note in one click. Off by default. Ribbon icons register in the same order as the format list. +- "Browse all icons" button in the format icon picker. The existing dropdown still offers common icons; the button opens a searchable grid of every Obsidian icon so a format can use any icon for its menu, command, and ribbon entries. + ## [2.4.2] - 2026-05-30 ### Changed diff --git a/README.md b/README.md index 2273651..95a8588 100644 --- a/README.md +++ b/README.md @@ -50,11 +50,12 @@ BRAT installs pre-release builds before they reach the Community Plugins marketp ## Quick start -You can copy from three places: +You can copy from four places: - **Right-click a file or folder** in the file explorer. - **Right-click inside an open note.** The formats act on that note, and the heading-aware formats link to the heading your cursor is in. - **Command palette** (`Ctrl/Cmd+P`): type `Copy:` and pick a format. It acts on the active file. +- **Ribbon** (optional): turn on **Show in ribbon** for a format to add a left-ribbon icon that copies it from the active note in one click. Off by default, so the ribbon stays uncluttered until you opt a format in. In the right-click menu, the enabled formats sit inside a **Copy path as** submenu to keep the menu tidy. Pick the format you want and the result lands on your clipboard. You can pin individual formats to the root menu, or turn the submenu off entirely, in settings. There is also an option to fold every format into Obsidian's native **Copy path** submenu instead, so all path-copy choices (the built-in ones and yours) live in one place. @@ -96,16 +97,16 @@ A custom format is a token template. Build one in settings: 1. Open Settings → Community plugins → Shell Path Copy → Options. 2. In **Custom formats**, click **Add custom format**, or click any existing format to expand its editor. 3. Set the fields: - - **Name**: shown in the menu and command palette. - - **Icon**: the icon shown next to the format in the menu. + - **Name**: shown in the menu, command palette, and ribbon. + - **Icon**: the icon shown next to the format in the menu, command palette, and ribbon. Pick a common one from the dropdown, or click **Browse all icons** to search the full set of Obsidian icons and choose any of them. - **Template**: the token template. The token palette below the field inserts a token at the cursor. A live preview shows the rendered result, and a Desktop / Mobile row shows where the template works. - **Wrapping**: none, double quotes, single quotes, or backticks, applied around the whole result. - - **Show in menu** and **Show in command palette**: where the format appears. + - **Show in menu**, **Show in command palette**, and **Show in ribbon**: where the format appears. The ribbon adds a left-rail icon that copies the format in one click; it is off by default. - **Pin to root menu**: also show this format at the top of the right-click menu, not only inside the **Copy path as** submenu. Useful for the one or two formats you reach for most. - **Show on**: limit the format to files, folders, or both. Formats that use file-specific tokens (like ``, wiki links, or line/heading tokens) are files-only automatically, since those do not resolve for folders. -4. Reload Obsidian so the command registers. +4. Reload Obsidian so the command and ribbon icon register. -The format list is compact and drag-to-reorder; list order is the menu order. Click a format to expand its editor. +The format list is compact and drag-to-reorder; list order is the menu order, and ribbon icons register in the same order. To place the ribbon group relative to other ribbon icons, drag it within Obsidian's ribbon. Click a format to expand its editor. ### Tokens diff --git a/__tests__/icon-collect.test.ts b/__tests__/icon-collect.test.ts new file mode 100644 index 0000000..50e176d --- /dev/null +++ b/__tests__/icon-collect.test.ts @@ -0,0 +1,48 @@ +import { filterIcons, sortIcons } from '../icon-collect'; + +describe('filterIcons', () => { + const ids = ['lucide-activity', 'lucide-arrow-up', 'lucide-camera', 'info', 'search']; + + test('returns the list unchanged for an empty query', () => { + expect(filterIcons(ids, '')).toEqual(ids); + expect(filterIcons(ids, ' ')).toEqual(ids); + }); + + test('matches a case-insensitive substring', () => { + expect(filterIcons(ids, 'ARROW')).toEqual(['lucide-arrow-up']); + expect(filterIcons(ids, 'info')).toEqual(['info']); + }); + + test('matches across the lucide- prefix and the bare name', () => { + expect(filterIcons(ids, 'lucide-')).toEqual([ + 'lucide-activity', + 'lucide-arrow-up', + 'lucide-camera', + ]); + expect(filterIcons(ids, 'camera')).toEqual(['lucide-camera']); + }); + + test('returns an empty array when nothing matches', () => { + expect(filterIcons(ids, 'zzz-nope')).toEqual([]); + }); + + test('does not mutate the input array', () => { + const original = [...ids]; + filterIcons(ids, 'arrow'); + expect(ids).toEqual(original); + }); +}); + +describe('sortIcons', () => { + test('sorts alphabetically without mutating the input', () => { + const input = ['search', 'info', 'lucide-camera', 'lucide-activity']; + const snapshot = [...input]; + expect(sortIcons(input)).toEqual([ + 'info', + 'lucide-activity', + 'lucide-camera', + 'search', + ]); + expect(input).toEqual(snapshot); + }); +}); diff --git a/__tests__/menu-placement.test.ts b/__tests__/menu-placement.test.ts index c9f3339..4a19a7e 100644 --- a/__tests__/menu-placement.test.ts +++ b/__tests__/menu-placement.test.ts @@ -14,6 +14,7 @@ function makeFormat(overrides: Partial): CustomFormat { enabled: true, showInMenu: true, showInCommands: true, + showInRibbon: false, pinToRoot: false, appliesTo: 'both', ...overrides, diff --git a/__tests__/seed-utils.test.ts b/__tests__/seed-utils.test.ts index 943db9a..1cc1670 100644 --- a/__tests__/seed-utils.test.ts +++ b/__tests__/seed-utils.test.ts @@ -159,7 +159,7 @@ describe('normalizeCustomFormats', () => { it('preserves a valid format', () => { const result = normalizeCustomFormats([ - { id: 'x1', name: 'Keep', template: '', wrapping: 'backticks', icon: 'file', enabled: false, showInMenu: false, showInCommands: true, pinToRoot: true, appliesTo: 'folders' }, + { id: 'x1', name: 'Keep', template: '', wrapping: 'backticks', icon: 'file', enabled: false, showInMenu: false, showInCommands: true, showInRibbon: true, pinToRoot: true, appliesTo: 'folders' }, ]); expect(result[0]).toEqual({ id: 'x1', @@ -170,6 +170,7 @@ describe('normalizeCustomFormats', () => { enabled: false, showInMenu: false, showInCommands: true, + showInRibbon: true, pinToRoot: true, appliesTo: 'folders', }); diff --git a/__tests__/virtual-window.test.ts b/__tests__/virtual-window.test.ts new file mode 100644 index 0000000..9a45551 --- /dev/null +++ b/__tests__/virtual-window.test.ts @@ -0,0 +1,77 @@ +import { computeVirtualWindow } from '../virtual-window'; + +describe('computeVirtualWindow', () => { + test('renders only the viewport plus overscan, not the whole list', () => { + // 1000 rows of 30px, 300px viewport scrolled to the top. Visible rows are + // 0..9; with the default overscan of 4 the window is 0..13. + const win = computeVirtualWindow({ + scrollTop: 0, + viewportHeight: 300, + rowHeight: 30, + rowCount: 1000, + }); + expect(win.firstRow).toBe(0); + expect(win.lastRow).toBe(13); + expect(win.padTop).toBe(0); + expect(win.totalHeight).toBe(30000); + }); + + test('offsets the window and padTop when scrolled into the middle', () => { + // Scrolled 3000px: first visible row is 100, last visible is 109. Overscan 4 + // widens to 96..113, and padTop aligns to the first rendered row. + const win = computeVirtualWindow({ + scrollTop: 3000, + viewportHeight: 300, + rowHeight: 30, + rowCount: 1000, + }); + expect(win.firstRow).toBe(96); + expect(win.lastRow).toBe(113); + expect(win.padTop).toBe(96 * 30); + }); + + test('clamps the last row to the end of the list', () => { + const win = computeVirtualWindow({ + scrollTop: 29700, + viewportHeight: 300, + rowHeight: 30, + rowCount: 1000, + }); + expect(win.lastRow).toBe(999); + }); + + test('returns an empty window when there are no rows', () => { + const win = computeVirtualWindow({ + scrollTop: 0, + viewportHeight: 300, + rowHeight: 30, + rowCount: 0, + }); + expect(win.lastRow).toBe(-1); + expect(win.totalHeight).toBe(0); + }); + + test('returns an empty window when the viewport is not yet measured', () => { + const win = computeVirtualWindow({ + scrollTop: 0, + viewportHeight: 0, + rowHeight: 30, + rowCount: 1000, + }); + expect(win.lastRow).toBe(-1); + // totalHeight is still known so the spacer can reserve scroll range. + expect(win.totalHeight).toBe(30000); + }); + + test('treats a negative scrollTop (overscroll) as the top', () => { + const win = computeVirtualWindow({ + scrollTop: -50, + viewportHeight: 300, + rowHeight: 30, + rowCount: 1000, + overscan: 0, + }); + expect(win.firstRow).toBe(0); + expect(win.padTop).toBe(0); + }); +}); diff --git a/icon-collect.ts b/icon-collect.ts new file mode 100644 index 0000000..e16c0b1 --- /dev/null +++ b/icon-collect.ts @@ -0,0 +1,17 @@ +// Pure filtering for the icon picker (no Obsidian import), unit-testable. The +// icon id list itself comes from getIconIds() at runtime; this module only filters +// and sorts what it is handed. Adapted from the developer-toolbox icon browser. + +// Case-insensitive substring match on the icon id. An empty query returns the +// list unchanged so the grid shows everything by default. +export function filterIcons(ids: string[], query: string): string[] { + const q = query.trim().toLowerCase(); + if (!q) return ids; + return ids.filter((id) => id.toLowerCase().includes(q)); +} + +// Stable alphabetical order. getIconIds() returns ids in an arbitrary, +// runtime-dependent order, so sort once for predictable browsing. +export function sortIcons(ids: string[]): string[] { + return [...ids].sort((a, b) => a.localeCompare(b)); +} diff --git a/main.ts b/main.ts index 1e064e7..9e0b2f0 100644 --- a/main.ts +++ b/main.ts @@ -11,6 +11,7 @@ import { } from './seed-utils'; import { resolveBlockTargetLine, findExistingBlockId, generateBlockId } from './block-utils'; import { pickRootFormats, matchesTarget } from './menu-utils'; +import { SelectIconModal } from './select-icon-modal'; // Node 'path' is only available on desktop. Load lazily behind a Platform.isDesktop // guard so mobile builds do not pull in unavailable Node built-ins. The narrow @@ -98,6 +99,9 @@ export default class ShellPathCopyPlugin extends Plugin { // Register command palette commands this.registerCommands(); + // Add a left-ribbon icon for each format opted into the ribbon + this.registerRibbonIcons(); + // Add settings tab this.addSettingTab(new ShellPathCopySettingTab(this.app, this)); } @@ -187,6 +191,29 @@ export default class ShellPathCopyPlugin extends Plugin { } } + private registerRibbonIcons() { + // One ribbon icon per enabled format opted into the ribbon. Like commands, + // these register once at onload; toggling needs an Obsidian reload. + for (const fmt of this.settings.customFormats) { + if (!fmt.enabled || !fmt.showInRibbon) { + continue; + } + const formatId = fmt.id; + const command = fmt; + // The ribbon only ever acts on the open note (a TFile). A folders-only + // format, or a missing active file, has nothing to act on, so warn + // instead of copying. + this.addRibbonIcon(fmt.icon, `Copy: ${fmt.name}`, () => { + const file = this.app.workspace.getActiveFile(); + if (!file || !matchesTarget(command, false)) { + new Notice('Open a note this format applies to first.'); + return; + } + void this.copyCustomFormat(formatId, file); + }); + } + } + // Adds the enabled custom-format items to a context menu. `editor` is passed // from the in-document right-click so the cursor's heading and line resolve. // Three layouts, picked by settings: @@ -412,7 +439,7 @@ export default class ShellPathCopyPlugin extends Plugin { } -const RELOAD_NOTICE = 'Please reload Obsidian for command palette changes to take effect'; +const RELOAD_NOTICE = 'Please reload Obsidian for command palette and ribbon changes to take effect'; class ShellPathCopySettingTab extends PluginSettingTab { plugin: ShellPathCopyPlugin; @@ -540,6 +567,7 @@ class ShellPathCopySettingTab extends PluginSettingTab { enabled: true, showInMenu: true, showInCommands: true, + showInRibbon: false, pinToRoot: false, appliesTo: 'both' }; @@ -677,7 +705,7 @@ class ShellPathCopySettingTab extends PluginSettingTab { new Setting(editor) .setName('Icon') - .setDesc('Icon shown next to this format in the menu') + .setDesc('Icon shown next to this format in the menu, command palette, and ribbon. Pick a common one or browse the full set.') .addDropdown(dropdown => { for (const icon of ICON_CHOICES) { dropdown.addOption(icon, icon); @@ -688,7 +716,19 @@ class ShellPathCopySettingTab extends PluginSettingTab { setIcon(iconEl, value); await this.plugin.saveSettings(); }); - }); + }) + .addButton(button => button + .setButtonText('Browse all icons') + .onClick(() => { + new SelectIconModal(this.app, fmt.icon, (chosen) => { + fmt.icon = chosen; + setIcon(iconEl, chosen); + void this.plugin.saveSettings(); + // Re-render so the dropdown reflects the new icon (or shows the + // curated default when the chosen icon is outside ICON_CHOICES). + this.display(); + }).open(); + })); // Refreshes the "Show on" control in place. Assigned where the control is // built below; invoked when the template changes so the files/folders choice @@ -828,6 +868,17 @@ class ShellPathCopySettingTab extends PluginSettingTab { new Notice(RELOAD_NOTICE); })); + new Setting(editor) + .setName('Show in ribbon') + .setDesc('Add a left-ribbon icon that copies this format') + .addToggle(toggle => toggle + .setValue(fmt.showInRibbon) + .onChange(async (value) => { + fmt.showInRibbon = value; + await this.plugin.saveSettings(); + new Notice(RELOAD_NOTICE); + })); + new Setting(editor) .setName('Delete this format') .addButton(button => button diff --git a/manifest.json b/manifest.json index a879d02..b02090a 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "shell-path-copy", "name": "Shell Path Copy", - "version": "2.4.2", + "version": "2.5.0", "minAppVersion": "1.6.0", "description": "Copy file and folder paths, URLs, and links from your vault using customizable token templates. Works on desktop and mobile.", "author": "Charles Kelsoe (ckelsoe)", diff --git a/package-lock.json b/package-lock.json index dc56300..f3727df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "shell-path-copy", - "version": "2.4.2", + "version": "2.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "shell-path-copy", - "version": "2.4.2", + "version": "2.5.0", "license": "MIT", "devDependencies": { "@eslint/js": "^9.39.2", diff --git a/package.json b/package.json index 0a1ccae..99a4593 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "shell-path-copy", - "version": "2.4.2", + "version": "2.5.0", "description": "Copy file and folder paths with shell-friendly formatting for Linux and Windows", "main": "main.js", "scripts": { diff --git a/seed-utils.ts b/seed-utils.ts index 1d6d7e9..3ede18f 100644 --- a/seed-utils.ts +++ b/seed-utils.ts @@ -29,6 +29,7 @@ export interface CustomFormat { enabled: boolean; showInMenu: boolean; showInCommands: boolean; + showInRibbon: boolean; // also add a left-ribbon icon that copies this format pinToRoot: boolean; // also show at the root menu when the submenu is on appliesTo: FormatTarget; // files, folders, or both (subject to token capability) } @@ -121,6 +122,7 @@ function makeSeed(spec: SeedSpec, legacy: Record | null): Custo enabled, showInMenu: true, showInCommands: true, + showInRibbon: false, pinToRoot: false, appliesTo: 'both' }; @@ -163,6 +165,7 @@ export function normalizeCustomFormats(value: unknown): CustomFormat[] { enabled: item.enabled !== false, showInMenu: item.showInMenu !== false, showInCommands: item.showInCommands !== false, + showInRibbon: item.showInRibbon === true, pinToRoot: item.pinToRoot === true, appliesTo: VALID_TARGETS.includes(item.appliesTo as FormatTarget) ? (item.appliesTo as FormatTarget) diff --git a/select-icon-modal.ts b/select-icon-modal.ts new file mode 100644 index 0000000..0d5a448 --- /dev/null +++ b/select-icon-modal.ts @@ -0,0 +1,125 @@ +import { App, Modal, SearchComponent, getIconIds, setIcon } from 'obsidian'; +import { createVirtualList, type VirtualListController } from './virtual-list'; +import { filterIcons, sortIcons } from './icon-collect'; + +// Fixed grid-row height for the virtual list (preview glyph plus a two-line +// clamped id label). Each virtual row holds `columns` cells. +const ROW_HEIGHT = 88; + +// Approximate cell footprint (min cell width plus gap). Columns is derived from +// the container width divided by this, so the grid reflows as the modal resizes. +const CELL_FOOTPRINT = 100; + +// Browse and pick any Lucide / Obsidian icon id usable in setIcon and addRibbonIcon. +// getIconIds() and setIcon() are public obsidian exports. The grid is virtualized +// as rows of `columns` cells, so the full ~1500-icon set scrolls smoothly without a +// render cap. Clicking a cell calls onChoose(id) and closes. Adapted from the +// developer-toolbox icon browser, which copies to the clipboard instead of choosing. +export class SelectIconModal extends Modal { + private allIds: string[]; + private current: string; + private onChoose: (id: string) => void; + private listEl!: HTMLElement; + private countEl!: HTMLElement; + private matches: string[] = []; + private columns = 6; + private vlist: VirtualListController | null = null; + private resizeObserver: ResizeObserver | null = null; + + constructor(app: App, current: string, onChoose: (id: string) => void) { + super(app); + this.current = current; + this.onChoose = onChoose; + // Enumerate live and sort once. Never hardcode: the set is runtime + // dependent (core icons plus any addIcon registrations from other plugins). + this.allIds = sortIcons(getIconIds()); + } + + onOpen(): void { + this.modalEl.addClass('shell-path-copy-icon-dialog'); + this.titleEl.setText('Choose an icon'); + + const { contentEl } = this; + contentEl.empty(); + + const search = new SearchComponent(contentEl); + search.setPlaceholder('Search icons by ID'); + search.inputEl.addClass('shell-path-copy-icon-search'); + search.onChange((value) => this.renderList(value)); + + this.countEl = contentEl.createDiv({ cls: 'shell-path-copy-icon-count' }); + this.listEl = contentEl.createDiv({ cls: 'shell-path-copy-icon-grid' }); + + this.vlist = createVirtualList({ + scrollEl: this.listEl, + rowHeight: ROW_HEIGHT, + renderRow: (index, rowEl) => this.renderRow(index, rowEl), + }); + + // Recompute columns and row count whenever the grid's width changes (modal + // resize, window resize). Observing also fires once with the initial size, + // which corrects the column count after first layout. + this.resizeObserver = new ResizeObserver(() => this.relayout()); + this.resizeObserver.observe(this.listEl); + + this.renderList(''); + } + + onClose(): void { + this.resizeObserver?.disconnect(); + this.resizeObserver = null; + this.vlist?.destroy(); + this.vlist = null; + this.contentEl.empty(); + } + + private renderList(query: string): void { + this.matches = filterIcons(this.allIds, query); + this.countEl.setText(`${this.matches.length} icons`); + this.relayout(); + } + + // Map the current match count onto grid rows for the active column count. + private relayout(): void { + this.columns = this.computeColumns(); + const rowCount = Math.ceil(this.matches.length / this.columns); + this.vlist?.setRowCount(rowCount); + } + + private computeColumns(): number { + const width = this.listEl?.clientWidth ?? 0; + // Before first layout the width is 0; keep the last known column count so + // the grid is not briefly single-column. The ResizeObserver corrects it. + if (width <= 0) return this.columns; + return Math.max(1, Math.floor(width / CELL_FOOTPRINT)); + } + + private renderRow(rowIndex: number, rowEl: HTMLElement): void { + rowEl.addClass('shell-path-copy-icon-row'); + const start = rowIndex * this.columns; + for (let c = 0; c < this.columns; c++) { + const idx = start + c; + if (idx >= this.matches.length) { + // Pad the trailing slots so the real cells keep their width. + rowEl.createDiv({ cls: 'shell-path-copy-icon-cell-spacer' }); + continue; + } + const id = this.matches[idx]; + if (id === undefined) continue; + const cell = rowEl.createDiv({ + cls: 'shell-path-copy-icon-cell', + attr: { 'aria-label': `Choose ${id}`, title: id }, + }); + if (id === this.current) { + cell.addClass('is-selected'); + } + const preview = cell.createDiv({ cls: 'shell-path-copy-icon-preview' }); + setIcon(preview, id); + cell.createSpan({ cls: 'shell-path-copy-icon-label', text: id }); + cell.addEventListener('click', () => { + this.onChoose(id); + this.close(); + }); + } + } +} diff --git a/styles.css b/styles.css index 10bbeaf..089f036 100644 --- a/styles.css +++ b/styles.css @@ -149,3 +149,103 @@ Shell Path Copy Plugin Styles padding: var(--size-2-1) var(--size-2-2); align-self: flex-start; } + +/* Icon picker modal (Browse all icons). A wider dialog so long icon ids do not + wrap, a search field, and a virtualized grid of every registered icon. */ +.shell-path-copy-icon-dialog { + width: min(900px, 92vw); + max-width: min(900px, 92vw); +} + +.shell-path-copy-icon-search { + width: 100%; + margin-bottom: 8px; +} + +.shell-path-copy-icon-count { + font-size: var(--font-ui-small); + color: var(--text-muted); + margin-bottom: 8px; +} + +.shell-path-copy-icon-grid { + max-height: 50vh; + overflow-y: auto; +} + +.shell-path-copy-icon-row { + display: flex; + gap: 4px; +} + +/* Fills the trailing slots of a partial last row so real cells keep their width. */ +.shell-path-copy-icon-cell-spacer { + flex: 1 1 0; + min-width: 0; +} + +.shell-path-copy-icon-cell { + display: flex; + flex: 1 1 0; + min-width: 0; + height: 100%; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 6px; + border-radius: 6px; + cursor: pointer; + overflow: hidden; +} + +.shell-path-copy-icon-cell:hover { + background: var(--background-modifier-hover); +} + +.shell-path-copy-icon-cell.is-selected { + background: var(--background-modifier-active-hover); + box-shadow: inset 0 0 0 2px var(--interactive-accent); +} + +.shell-path-copy-icon-preview { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-normal); +} + +.shell-path-copy-icon-preview svg { + width: 24px; + height: 24px; +} + +.shell-path-copy-icon-label { + font-size: var(--font-ui-smaller); + font-family: var(--font-monospace); + color: var(--text-muted); + text-align: center; + line-height: 1.2; + word-break: break-all; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.shell-path-copy-vlist-sizer { + position: relative; + width: 100%; +} + +.shell-path-copy-vlist-rows { + position: absolute; + top: 0; + left: 0; + width: 100%; +} + +.shell-path-copy-vlist-row { + box-sizing: border-box; + width: 100%; +} diff --git a/versions.json b/versions.json index 34bcf5c..c0d2c13 100644 --- a/versions.json +++ b/versions.json @@ -41,5 +41,6 @@ "2.3.0": "1.6.0", "2.4.0": "1.6.0", "2.4.1": "1.6.0", - "2.4.2": "1.6.0" + "2.4.2": "1.6.0", + "2.5.0": "1.6.0" } \ No newline at end of file diff --git a/virtual-list.ts b/virtual-list.ts new file mode 100644 index 0000000..ecb3528 --- /dev/null +++ b/virtual-list.ts @@ -0,0 +1,86 @@ +import { computeVirtualWindow } from './virtual-window'; + +// Fixed-height virtual list. Renders only the rows in (or near) the viewport so a +// list of thousands stays at a small, constant DOM node count. The caller owns row +// content via renderRow; this owns the scroll handler and spacer geometry. The +// windowing math is the pure computeVirtualWindow. +// +// A grid is driven through the same controller by treating each grid row as one +// fixed-height row of N cells (the caller lays the cells out inside renderRow). +// Adapted from the developer-toolbox virtual list. + +export interface VirtualListController { + // Set the number of rows and repaint from the top. Call on every filter change. + setRowCount(rowCount: number): void; + // Recompute against the current scroll position and size. Call after a layout + // change (for example once the modal has been measured). + refresh(): void; + // Detach the scroll handler and remove the injected elements. + destroy(): void; +} + +export interface VirtualListOptions { + // Scroll container. Must have a bounded height and overflow-y: auto in CSS. + scrollEl: HTMLElement; + // Fixed pixel height of every row. Rows must not vary in height. + rowHeight: number; + // Fill rowEl with the content for rowIndex. rowEl is already height-sized. + renderRow: (rowIndex: number, rowEl: HTMLElement) => void; + overscan?: number; +} + +export function createVirtualList(opts: VirtualListOptions): VirtualListController { + const { scrollEl, rowHeight, renderRow } = opts; + const overscan = opts.overscan ?? 4; + + // sizer reserves the full scroll range; rowsEl is the translated slice that + // holds the handful of rendered rows. + const sizer = scrollEl.createDiv({ cls: 'shell-path-copy-vlist-sizer' }); + const rowsEl = sizer.createDiv({ cls: 'shell-path-copy-vlist-rows' }); + + let rowCount = 0; + + const render = (): void => { + const win = computeVirtualWindow({ + scrollTop: scrollEl.scrollTop, + viewportHeight: scrollEl.clientHeight, + rowHeight, + rowCount, + overscan, + }); + + sizer.style.height = `${win.totalHeight}px`; + rowsEl.style.transform = `translateY(${win.padTop}px)`; + rowsEl.empty(); + + for (let i = win.firstRow; i <= win.lastRow; i++) { + const rowEl = rowsEl.createDiv({ cls: 'shell-path-copy-vlist-row' }); + rowEl.style.height = `${rowHeight}px`; + renderRow(i, rowEl); + } + }; + + const onScroll = (): void => render(); + scrollEl.addEventListener('scroll', onScroll, { passive: true }); + + const scheduleMeasuredRefresh = (): void => { + // A modal often opens with clientHeight 0 (not yet painted), which yields an + // empty window. Repaint once the browser has laid the container out. + const win = scrollEl.ownerDocument.defaultView; + if (win) win.requestAnimationFrame(() => render()); + }; + + return { + setRowCount: (n: number): void => { + rowCount = Math.max(0, n); + scrollEl.scrollTop = 0; + render(); + scheduleMeasuredRefresh(); + }, + refresh: render, + destroy: (): void => { + scrollEl.removeEventListener('scroll', onScroll); + sizer.remove(); + }, + }; +} diff --git a/virtual-window.ts b/virtual-window.ts new file mode 100644 index 0000000..2494ff0 --- /dev/null +++ b/virtual-window.ts @@ -0,0 +1,50 @@ +// Pure windowing math for a fixed-row-height virtual list. Given the scroll +// position and viewport size, returns which rows to render plus the spacer +// geometry, so a list of thousands keeps a small constant DOM node count. No DOM +// here on purpose: this is unit-tested, and the DOM wiring lives in +// virtual-list.ts. A grid is modeled as rows of N cells, so the same math drives +// the icon grid. Adapted from the developer-toolbox virtual list. + +export interface VirtualWindow { + // First row index to render, inclusive. + firstRow: number; + // Last row index to render, inclusive. -1 when there is nothing to render. + lastRow: number; + // Pixel offset of the first rendered row from the top of the full list. + padTop: number; + // Pixel height of the full virtual list (all rows), used to size the spacer. + totalHeight: number; +} + +export interface VirtualWindowArgs { + scrollTop: number; + viewportHeight: number; + rowHeight: number; + rowCount: number; + // Rows rendered above and below the viewport to avoid blank edges while + // scrolling. Defaults to 4. + overscan?: number; +} + +export function computeVirtualWindow(args: VirtualWindowArgs): VirtualWindow { + const { rowHeight, rowCount, viewportHeight } = args; + const overscan = Math.max(0, args.overscan ?? 4); + const totalHeight = Math.max(0, rowCount * rowHeight); + + // Nothing to lay out: no rows, or geometry not measurable yet (a modal can + // open with a zero-height viewport before first paint). Return an empty window + // rather than guessing; the caller refreshes once real measurements exist. + if (rowCount <= 0 || rowHeight <= 0 || viewportHeight <= 0) { + return { firstRow: 0, lastRow: -1, padTop: 0, totalHeight }; + } + + const scrollTop = Math.max(0, args.scrollTop); + const firstVisible = Math.floor(scrollTop / rowHeight); + const lastVisible = Math.ceil((scrollTop + viewportHeight) / rowHeight) - 1; + + const firstRow = Math.max(0, firstVisible - overscan); + const lastRow = Math.min(rowCount - 1, lastVisible + overscan); + const padTop = firstRow * rowHeight; + + return { firstRow, lastRow, padTop, totalHeight }; +}