diff --git a/__TEST__/e2e/storage.spec.mjs b/__TEST__/e2e/storage.spec.mjs index c193574..f0492f1 100644 --- a/__TEST__/e2e/storage.spec.mjs +++ b/__TEST__/e2e/storage.spec.mjs @@ -99,7 +99,8 @@ test('rename via the row action edits the name in place; the key is untouched (# const keysBefore = await page.evaluate(() => Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')).sort()); const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'alpha' }) }); await row.hover(); - await row.locator('.recents-rename').click(); + await row.locator('.recents-kebab').click(); + await page.locator('#recents-menu .recents-menu-rename').click(); const input = page.locator('.recents-rename-input'); await input.fill('interview notes'); await input.press('Enter'); @@ -117,7 +118,8 @@ test('escape cancels a rename without changing the name (#434)', async ({ page } await seed(page); const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'alpha' }) }); await row.hover(); - await row.locator('.recents-rename').click(); + await row.locator('.recents-kebab').click(); + await page.locator('#recents-menu .recents-menu-rename').click(); const input = page.locator('.recents-rename-input'); await input.fill('should-not-stick'); await input.press('Escape'); @@ -130,7 +132,8 @@ test('delete is two-step and removes the entry (#434)', async ({ page }) => { await seed(page); const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'alpha' }) }); await row.hover(); - const del = row.locator('.recents-delete'); + await row.locator('.recents-kebab').click(); + const del = page.locator('#recents-menu .recents-menu-delete'); await del.click(); // armed, not deleted await expect(del).toHaveText('Delete?'); @@ -149,7 +152,8 @@ test('deleting the loaded entry offers Restore; restoring re-saves the on-screen await page.waitForTimeout(300); const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'beta' }) }); await row.hover(); - const del = row.locator('.recents-delete'); + await row.locator('.recents-kebab').click(); + const del = page.locator('#recents-menu .recents-menu-delete'); await del.click(); await del.click(); // confirm await expect(page.locator('.file-item', { hasText: 'beta' })).toHaveCount(0); @@ -177,7 +181,8 @@ test('a pending Restore is withdrawn when the screen holds a different document await page.waitForTimeout(300); const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'beta' }) }); await row.hover(); - const del = row.locator('.recents-delete'); + await row.locator('.recents-kebab').click(); + const del = page.locator('#recents-menu .recents-menu-delete'); await del.click(); await del.click(); await expect(page.locator('#recents-notice')).toContainText('no longer being saved'); @@ -188,6 +193,91 @@ test('a pending Restore is withdrawn when the screen holds a different document await expect(page.locator('#recents-notice')).toHaveCount(0); }); +test('a very long name truncates with ellipsis instead of pushing the actions off-screen (#436)', async ({ page }) => { + await seed(page); + await page.evaluate(() => { + localStorage.setItem('hyperaudio:doc:long', JSON.stringify({ + hypertranscript: '

x

', + video: 'https://example.com/a.mp3', summary: '', topics: [], + meta: { name: 'A_really_long_interview_' + 'x'.repeat(80) + '.mp4', updated: 9999 }, + })); + loadLocalStorageOptions(); + }); + const r = await page.evaluate(() => { + const picker = document.querySelector('#file-picker').getBoundingClientRect(); + const name = [...document.querySelectorAll('.file-item')] + .find((a) => a.textContent.startsWith('A_really_long_interview_')); + const actions = name.closest('.recents-row').querySelector('.recents-actions').getBoundingClientRect(); + return { + pickerVisible: picker.width > 0, + actionsInside: actions.right <= picker.right + 1, + truncated: name.scrollWidth > name.clientWidth, + }; + }); + expect(r.pickerVisible).toBe(true); + expect(r.actionsInside).toBe(true); + expect(r.truncated).toBe(true); +}); + +test('the row Duplicate action copies an entry with a suffixed name, original untouched (#436)', async ({ page }) => { + await seed(page); + const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'beta' }) }); + await row.hover(); + await row.locator('.recents-kebab').click(); + await page.locator('#recents-menu .recents-menu-duplicate').click(); + const names = await page.evaluate(() => + [...document.querySelectorAll('.file-item')].map((a) => a.textContent)); + expect(names[0]).toBe('beta (2)'); // fresh stamps put the copy on top + expect(names).toContain('beta'); + expect(names).toContain('alpha'); +}); + +test('editing a never-saved document (the demo) auto-creates its Recents entry (#436)', async ({ page }) => { + await page.evaluate(() => { localStorage.clear(); loadLocalStorageOptions(); }); + await page.evaluate(() => { + const span = document.querySelector('#hypertranscript span[data-m]'); + span.textContent = 'DEMO-EDIT '; + span.dispatchEvent(new Event('input', { bubbles: true })); + }); + await page.waitForTimeout(2600); + const saved = await page.evaluate(() => { + const keys = Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')); + return { count: keys.length, entry: keys.length ? JSON.parse(localStorage.getItem(keys[0])) : null }; + }); + expect(saved.count).toBe(1); + expect(saved.entry.hypertranscript).toContain('DEMO-EDIT'); +}); + +test('a pending Restore suppresses auto-create; dismissing it re-enables (#436)', async ({ page }) => { + await seed(page); + await page.evaluate(() => { + [...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'beta').click(); + }); + await page.waitForTimeout(300); + const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'beta' }) }); + await row.hover(); + await row.locator('.recents-kebab').click(); + const del = page.locator('#recents-menu .recents-menu-delete'); + await del.click(); + await del.click(); // confirm — Restore offer now pending + const edit = () => page.evaluate(() => { + const span = document.querySelector('#hypertranscript span[data-m]'); + span.textContent = 'EDIT-AFTER-DELETE '; + span.dispatchEvent(new Event('input', { bubbles: true })); + }); + await edit(); + await page.waitForTimeout(2600); + // deleted on purpose: the edit must NOT silently recreate the entry + expect(await page.evaluate(() => + Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')).length)).toBe(1); + await page.locator('#recents-notice .recents-notice-dismiss').click(); + await edit(); + await page.waitForTimeout(2600); + // offer declined: the doc is now just an unsaved document — edits re-enter Recents + expect(await page.evaluate(() => + Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')).length)).toBe(2); +}); + test('clicking a row loads it and marks it active; the highlight survives a re-render (#434)', async ({ page }) => { await seed(page); await page.evaluate(() => { diff --git a/__TEST__/unit/storage.test.mjs b/__TEST__/unit/storage.test.mjs index 7ac125d..9bd4d8d 100644 --- a/__TEST__/unit/storage.test.mjs +++ b/__TEST__/unit/storage.test.mjs @@ -17,6 +17,8 @@ const { migrateLegacyEntries, renameTranscriptEntry, deleteTranscriptEntry, + duplicateTranscriptEntry, + mediaKeyInUse, mediaNameFromRef, } = require('../../js/hyperaudio-lite-editor-storage.js'); @@ -140,6 +142,38 @@ test('mediaNameFromRef: URL basename (decoded), plain filename passthrough, Unti assert.equal(mediaNameFromRef(null), 'Untitled'); }); +test('duplicate: fresh ID and timestamps, suffixed name, shared mediaKey (#436)', () => { + const s = fakeStorage({ + 'hyperaudio:doc:one': entry({ meta: { name: 'interview', mediaKey: 'm1', created: 100, updated: 200 } }), + }); + const newKey = duplicateTranscriptEntry('hyperaudio:doc:one', s); + assert.ok(newKey !== null && newKey !== 'hyperaudio:doc:one'); + const copy = JSON.parse(s.getItem(newKey)); + assert.equal(copy.meta.name, 'interview (2)'); + assert.equal(copy.meta.mediaKey, 'm1'); // shares the source's media + assert.ok(copy.meta.created > 100); // fresh stamps → sorts to the top + assert.equal(copy.meta.updated, copy.meta.created); + // the original is untouched + const original = JSON.parse(s.getItem('hyperaudio:doc:one')); + assert.equal(original.meta.name, 'interview'); + assert.equal(original.meta.updated, 200); + // an unusable source duplicates to nothing + s.setItem('broken.hyperaudio', '{not json'); + assert.equal(duplicateTranscriptEntry('broken.hyperaudio', s), null); +}); + +test('delete refcounts shared media: the blob outlives one of two duplicates (#436)', () => { + const s = fakeStorage({ + 'hyperaudio:doc:one': entry({ meta: { name: 'a', mediaKey: 'm1' } }), + 'hyperaudio:doc:two': entry({ meta: { name: 'a (2)', mediaKey: 'm1' } }), + }); + assert.equal(mediaKeyInUse('m1', 'hyperaudio:doc:one', s), true); // the twin still refs it + deleteTranscriptEntry('hyperaudio:doc:one', s); + assert.equal(s.getItem('hyperaudio:doc:one'), null); + assert.equal(mediaKeyInUse('m1', 'hyperaudio:doc:two', s), false); // last reference + assert.equal(mediaKeyInUse('m1', null, s), true); // still referenced overall +}); + test('entryName / entryMediaKey fall back sensibly for malformed entries', () => { assert.equal(entryName('alpha.hyperaudio', null), 'alpha'); assert.equal(entryName('hyperaudio:doc:x', null), 'Untitled'); diff --git a/css/hyperaudio-lite-editor.css b/css/hyperaudio-lite-editor.css index 31dcd17..ffc7b26 100644 --- a/css/hyperaudio-lite-editor.css +++ b/css/hyperaudio-lite-editor.css @@ -961,6 +961,10 @@ label[data-a11y-wired]:focus-visible { flex-direction: row; align-items: center; flex-wrap: nowrap; + /* the daisyUI menu wraps with flex-shrink:0 items, so a long name sizes the + row to its content and pushes the action icons off-screen — clamp the row + to the list and let the name's ellipsis absorb the difference */ + max-width: 100%; } #file-picker .recents-row .file-item { display: block; @@ -974,9 +978,17 @@ label[data-a11y-wired]:focus-visible { display: flex; align-items: center; gap: 2px; - padding-right: 4px; opacity: 0; } +/* daisyUI styles every direct child of a menu li as a menu item — strip that + from the actions span so hovering the kebab shows ONE affordance (the + button's own), not a pill around a pill */ +#file-picker .recents-actions, +#file-picker .recents-actions:hover, +#file-picker .recents-actions:active { + background: none; + padding: 0 4px 0 0; +} #file-picker .recents-row:hover .recents-actions, #file-picker .recents-row:focus-within .recents-actions { opacity: 1; @@ -1001,12 +1013,6 @@ label[data-a11y-wired]:focus-visible { background-color: oklch(var(--b2)); color: oklch(var(--bc)); } -/* armed delete: text swaps in for the icon, in the error colour */ -#file-picker .recents-actions button.confirming, -#file-picker .recents-actions button.confirming:hover { - color: oklch(var(--er)); - font-weight: 600; -} .recents-rename-input { width: 100%; box-sizing: border-box; @@ -1092,3 +1098,45 @@ label[data-a11y-wired]:focus-visible { #recents-notice .recents-notice-action + .recents-notice-dismiss { margin-left: 4px; } + +/* Row kebab menu (#436): one shared, fixed-position menu so it never clips + against the Recents scroll container. */ +#file-picker .recents-row:has(.recents-kebab[aria-expanded="true"]) .recents-actions { + opacity: 1; /* keep the anchor visible while its menu is open */ +} +#recents-menu { + position: fixed; + z-index: 50; + display: flex; + flex-direction: column; + min-width: 150px; + padding: 4px; + border-radius: 0.5rem; + background-color: oklch(var(--b1)); + box-shadow: 0 4px 16px oklch(var(--bc) / 0.15), 0 0 0 1px oklch(var(--bc) / 0.06); +} +#recents-menu button { + display: flex; + align-items: center; + gap: 8px; + border: none; + background: transparent; + margin: 0; + padding: 6px 10px; + border-radius: 0.375rem; + font-size: 13px; + text-align: left; + color: oklch(var(--bc) / 0.85); + cursor: pointer; +} +#recents-menu button:hover, +#recents-menu button:focus-visible { + border: none; + background-color: oklch(var(--b2)); + color: oklch(var(--bc)); +} +#recents-menu button.confirming, +#recents-menu button.confirming:hover { + color: oklch(var(--er)); + font-weight: 600; +} diff --git a/index.html b/index.html index e28b7c1..135c83b 100644 --- a/index.html +++ b/index.html @@ -977,50 +977,9 @@

Caption Regeneration

- - -
- -
- - - - -
- -
- - - - - + diff --git a/js/editor-file-menu.js b/js/editor-file-menu.js index c850603..17775dc 100644 --- a/js/editor-file-menu.js +++ b/js/editor-file-menu.js @@ -1,32 +1,14 @@ /* Extracted verbatim from index.html (#334) — loaded as a classic script in the same document order. */ - document.querySelector('#file-dropdown').insertAdjacentHTML("beforeend", '
  • '); - - document.querySelector('#save-localstorage-filename').value = getLocalStorageSaveFilename(document.querySelector("#hyperplayer").src); - + // The FILE menu's Save/Load Local Storage dialogs are gone (#436): Recents + // autosaves everything (#435), rows rename/duplicate/delete inline (#434), + // and deleting the loaded entry offers Restore — so the dialogs had no + // remaining job. This just renders the initial Recents list. loadLocalStorageOptions(); - document - .querySelector("#file-save-localstorage") - .addEventListener("click", function () { - let filename = document.querySelector('#save-localstorage-filename').value; - saveHyperTranscriptToLocalStorage(filename); - loadLocalStorageOptions(); - }); - - document - .querySelector("#file-load-localstorage") - .addEventListener("click", function () { - let filenameIndex = document.querySelector('#load-localstorage-filename').value; - loadHyperTranscriptFromLocalStorage(filenameIndex); - }); - document .querySelector("#regenerate-captions") .addEventListener("click", function () { captionCache = null; hyperaudioGenerateCaptionsFromTranscript(); }); - - setFileSelectListeners(); - diff --git a/js/hyperaudio-lite-editor-storage.js b/js/hyperaudio-lite-editor-storage.js index 4a1f220..5702d10 100644 --- a/js/hyperaudio-lite-editor-storage.js +++ b/js/hyperaudio-lite-editor-storage.js @@ -300,18 +300,6 @@ function renderTranscript( } -// Prefill for the save dialog: the active entry's name if one is loaded, -// otherwise the media filename. -function getLocalStorageSaveFilename(url){ - if (activeDocKey !== null) { - const entry = readTranscriptEntry(activeDocKey, window.localStorage); - if (entry !== null) { - return entryName(activeDocKey, entry); - } - } - return url.substring(url.lastIndexOf("/") + 1); -} - function getTopicsString(topics) { let topicsString = ""; if (topics && topics !== "undefined" && Object.keys(topics).length > 0) { @@ -568,20 +556,65 @@ function renameTranscriptEntry(fileKey, newName, storage = window.localStorage) return true; } +// True if any OTHER entry references the same cached media — duplicates share +// their source's mediaKey, so shared media must survive a single delete. +function mediaKeyInUse(mediaKey, excludeKey, storage) { + if (!mediaKey) return false; + for (let i = 0; i < storage.length; i++) { + const key = storage.key(i); + if (key === excludeKey) continue; + if (!isDocKey(key) && !isLegacyKey(key)) continue; + if (entryMediaKey(key, readTranscriptEntry(key, storage)) === mediaKey) return true; + } + return false; +} + /* - * Delete an entry and its cached media. Works for unparseable legacy entries - * too (their media key is derived from the legacy name). + * Delete an entry, and its cached media unless another entry (a duplicate) + * still references it. Works for unparseable legacy entries too (their media + * key is derived from the legacy name). */ function deleteTranscriptEntry(fileKey, storage = window.localStorage) { const entry = readTranscriptEntry(fileKey, storage); const mediaKey = entryMediaKey(fileKey, entry); storage.removeItem(fileKey); - deleteMedia(MEDIA_DATABASE, MEDIA_STORE, mediaKey); + if (!mediaKeyInUse(mediaKey, fileKey, storage)) { + deleteMedia(MEDIA_DATABASE, MEDIA_STORE, mediaKey); + } if (activeDocKey === fileKey) { activeDocKey = null; } } +/* + * Duplicate an entry under a fresh ID as "name (2)". The copy shares the + * original's cached media (deletes are refcounted via mediaKeyInUse) and + * stamps fresh timestamps, so it appears at the top of the list. + * @return {string|null} the new entry's key, or null if the source is unusable + */ +function duplicateTranscriptEntry(fileKey, storage = window.localStorage) { + const entry = readTranscriptEntry(fileKey, storage); + if (entry === null || typeof entry.hypertranscript !== 'string') return null; + const now = Date.now(); + const newKey = newDocKey(); + entry.meta = Object.assign({}, entry.meta, { + name: uniqueEntryName(entryName(fileKey, entry), storage, newKey), + mediaKey: entryMediaKey(fileKey, entry) || undefined, + created: now, + updated: now, + }); + try { + storage.setItem(newKey, JSON.stringify(entry)); + } catch (error) { + console.error('Error duplicating transcript:', error); + if (error.name === 'QuotaExceededError') { + showStorageNotice('Browser storage is full — delete an entry from Recents to make room.'); + } + return null; + } + return newKey; +} + // Non-blocking notice above the Recents list — replaces the old blocking // alert(). Error tone auto-dismisses; opts {tone:'info', sticky:true} gives a // neutral note that stays until its ✕ is clicked (the autosave disclosure). @@ -689,12 +722,23 @@ function maybeShowAutosaveNotice(storage = window.localStorage) { let autosaveTimer = null; function scheduleAutosave() { - if (activeDocKey === null) return; // nothing has landed yet this session clearTimeout(autosaveTimer); autosaveTimer = setTimeout(() => { - if (activeDocKey === null) return; // deleted while the timer was pending - const entry = readTranscriptEntry(activeDocKey, window.localStorage); - saveHyperTranscriptToLocalStorage(entry !== null ? entryName(activeDocKey, entry) : undefined); + // A pending Restore means the on-screen doc was just deleted on purpose — + // silently recreating it on the next keystroke would fight that; the + // Restore button is the explicit path back. + if (document.querySelector('#recents-notice[data-has-action="true"]') !== null) return; + + if (activeDocKey === null) { + // A never-saved document (the intro demo, or a deleted doc whose Restore + // offer was dismissed): the first edit brings it into Recents (#436), so + // "everything you touch is in Recents" holds with no manual save. + saveHyperTranscriptToLocalStorage(mediaDisplayName()); + maybeShowAutosaveNotice(); + } else { + const entry = readTranscriptEntry(activeDocKey, window.localStorage); + saveHyperTranscriptToLocalStorage(entry !== null ? entryName(activeDocKey, entry) : undefined); + } loadLocalStorageOptions(); // reflect the new "updated" order }, AUTOSAVE_DEBOUNCE_MS); } @@ -712,10 +756,6 @@ if (typeof document !== 'undefined') { suppressDerivedCapture = false; } loadLocalStorageOptions(); - const saveInput = document.querySelector('#save-localstorage-filename'); - if (saveInput !== null && activeDocKey !== null) { - saveInput.value = entryName(activeDocKey, readTranscriptEntry(activeDocKey, window.localStorage)); - } maybeShowAutosaveNotice(); }, false); @@ -727,6 +767,21 @@ if (typeof document !== 'undefined') { scheduleAutosave(); } }); + + // Kebab menu teardown: outside click, Escape, or any scroll (the fixed menu + // is anchored to the kebab's on-screen position, which scrolling moves). + document.addEventListener('click', (event) => { + if (recentsMenuKey === null) return; + const t = event.target; + if (t && t.closest && (t.closest('#recents-menu') !== null || t.closest('.recents-kebab') !== null)) return; + closeRecentsMenu(); + }); + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') closeRecentsMenu(); + }); + document.addEventListener('scroll', () => { + if (recentsMenuKey !== null) closeRecentsMenu(); + }, true); } // Escape text/keys interpolated into picker markup (#410) — a saved filename @@ -740,16 +795,16 @@ function escapeStorageMarkup(text) { } const RECENTS_RENAME_SVG = ''; +const RECENTS_DUPLICATE_SVG = ''; +const RECENTS_KEBAB_SVG = ''; const RECENTS_DELETE_SVG = ''; function loadLocalStorageOptions(storage = window.localStorage) { migrateLegacyEntries(storage); + closeRecentsMenu(); // the rows it was anchored to are about to be replaced - let fileSelect = document.querySelector("#load-localstorage-filename"); let filePicker = document.querySelector("#file-picker"); - - fileSelect.innerHTML = ''; filePicker.innerHTML = ""; // Entries are referenced by their KEY STRING, never by storage.key(i) @@ -761,12 +816,10 @@ function loadLocalStorageOptions(storage = window.localStorage) { rows.forEach(({ key, name }) => { const keyAttr = escapeStorageMarkup(key); const nameHtml = escapeStorageMarkup(name); - fileSelect.insertAdjacentHTML("beforeend", ``); filePicker.insertAdjacentHTML("beforeend", `
  • ${nameHtml}` + `` + - `` + - `` + + `` + `
  • `); }); @@ -799,14 +852,9 @@ function setFileSelectListeners() { file.addEventListener('mouseover', fileSelectHandleHover); }); - document.querySelectorAll('.recents-rename').forEach((btn) => { - btn.removeEventListener('click', recentsRenameHandleClick); - btn.addEventListener('click', recentsRenameHandleClick); - }); - - document.querySelectorAll('.recents-delete').forEach((btn) => { - btn.removeEventListener('click', recentsDeleteHandleClick); - btn.addEventListener('click', recentsDeleteHandleClick); + document.querySelectorAll('.recents-kebab').forEach((btn) => { + btn.removeEventListener('click', recentsKebabHandleClick); + btn.addEventListener('click', recentsKebabHandleClick); }); } @@ -835,10 +883,80 @@ function findRecentsItem(fileKey) { .find((el) => el.getAttribute('data-key') === fileKey) || null; } -function recentsRenameHandleClick(event) { +/* ---- Row kebab menu: one shared, fixed-position menu (#436). The list lives + in a scroll container, so a dropdown positioned inside it would be clipped + at the card edge for rows near the bottom — a fixed menu anchored to the + kebab's rect behaves for every row, flipping upward near the viewport + bottom. Closed by outside click, Escape, any scroll (the anchor moves), or + a list re-render. ---- */ + +let recentsMenuKey = null; // storage key the open menu acts on, null when closed + +function closeRecentsMenu() { + const menu = document.getElementById('recents-menu'); + if (menu !== null) menu.remove(); + const kebab = document.querySelector('.recents-kebab[aria-expanded="true"]'); + if (kebab !== null) kebab.setAttribute('aria-expanded', 'false'); + recentsMenuKey = null; +} + +function openRecentsMenu(kebabBtn) { + closeRecentsMenu(); + const fileKey = kebabBtn.getAttribute('data-key'); + recentsMenuKey = fileKey; + kebabBtn.setAttribute('aria-expanded', 'true'); + + const menu = document.createElement('div'); + menu.id = 'recents-menu'; + menu.setAttribute('role', 'menu'); + menu.innerHTML = + `` + + `` + + ``; + document.body.appendChild(menu); + + const anchor = kebabBtn.getBoundingClientRect(); + const size = menu.getBoundingClientRect(); + menu.style.left = Math.max(8, anchor.right - size.width) + 'px'; + menu.style.top = (anchor.bottom + 4 + size.height > window.innerHeight + ? anchor.top - size.height - 4 + : anchor.bottom + 4) + 'px'; + + menu.querySelector('.recents-menu-rename').addEventListener('click', () => { + closeRecentsMenu(); + startRecentsRename(fileKey); + }); + menu.querySelector('.recents-menu-duplicate').addEventListener('click', () => { + closeRecentsMenu(); + duplicateTranscriptEntry(fileKey); + loadLocalStorageOptions(); + }); + // two-step delete lives inside the menu: first click arms ("Delete?"), the + // second executes; closing the menu by any route disarms it + const del = menu.querySelector('.recents-menu-delete'); + del.addEventListener('click', () => { + if (del.dataset.confirming !== 'true') { + del.dataset.confirming = 'true'; + del.classList.add('confirming'); + del.innerHTML = `${RECENTS_DELETE_SVG}Delete?`; + return; + } + closeRecentsMenu(); + performRecentsDelete(fileKey); + }); + + menu.querySelector('.recents-menu-rename').focus(); +} + +function recentsKebabHandleClick(event) { event.preventDefault(); event.stopPropagation(); - startRecentsRename(event.currentTarget.getAttribute('data-key')); + const btn = event.currentTarget; + if (recentsMenuKey === btn.getAttribute('data-key')) { + closeRecentsMenu(); // second click on the same kebab toggles it shut + return; + } + openRecentsMenu(btn); } // Swap the row label for a text input; Enter/blur commits, Escape cancels. @@ -864,12 +982,6 @@ function startRecentsRename(fileKey, storage = window.localStorage) { finished = true; if (commit) { renameTranscriptEntry(fileKey, input.value, storage); - if (activeDocKey === fileKey) { - const saveInput = document.querySelector('#save-localstorage-filename'); - if (saveInput !== null) { - saveInput.value = entryName(fileKey, readTranscriptEntry(fileKey, storage)); - } - } } loadLocalStorageOptions(storage); }; @@ -882,28 +994,7 @@ function startRecentsRename(fileKey, storage = window.localStorage) { input.addEventListener('click', (e) => e.stopPropagation()); } -// First click arms the button ("Delete?"), second click within the window -// deletes; the timeout restores the button without re-rendering the list (a -// re-render could interrupt a rename in progress on another row). -function recentsDeleteHandleClick(event) { - event.preventDefault(); - event.stopPropagation(); - const btn = event.currentTarget; - - if (btn.dataset.confirming !== 'true') { - btn.dataset.confirming = 'true'; - btn.classList.add('confirming'); - btn.textContent = 'Delete?'; - btn._resetTimer = setTimeout(() => { - btn.dataset.confirming = 'false'; - btn.classList.remove('confirming'); - btn.innerHTML = RECENTS_DELETE_SVG; - }, 4000); - return; - } - - clearTimeout(btn._resetTimer); - const fileKey = btn.getAttribute('data-key'); +function performRecentsDelete(fileKey) { const wasActive = activeDocKey === fileKey; const name = entryName(fileKey, readTranscriptEntry(fileKey, window.localStorage)); deleteTranscriptEntry(fileKey); @@ -963,8 +1054,6 @@ function loadHyperTranscriptFromLocalStorage(fileKey, storage = window.localStor hideRestoreNotice(); activeDocKey = fileKey; renderTranscript(hypertranscriptstorage, entryMediaKey(fileKey, hypertranscriptstorage)); - - document.querySelector('#save-localstorage-filename').value = entryName(fileKey, hypertranscriptstorage); } else if (hypertranscriptstorage) { console.warn(`Saved entry "${fileKey}" is missing transcript/video fields — not loading.`); } @@ -1006,6 +1095,8 @@ if (typeof module !== 'undefined' && module.exports) { migrateLegacyEntries, renameTranscriptEntry, deleteTranscriptEntry, + duplicateTranscriptEntry, + mediaKeyInUse, readTranscriptEntry, escapeStorageMarkup, mediaNameFromRef,