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: '',
+ 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
-
-
-
-
-
-
-
-
-
-
-
Save to Local Storage
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Load from Local Storage
-
-
-
-
-
-
-
-
-
-
-
+
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,