Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 95 additions & 5 deletions __TEST__/e2e/storage.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand All @@ -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?');
Expand All @@ -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);
Expand Down Expand Up @@ -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');
Expand All @@ -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: '<article><section><p><span data-m="0" data-d="1">x </span></p></section></article>',
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(() => {
Expand Down
34 changes: 34 additions & 0 deletions __TEST__/unit/storage.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const {
migrateLegacyEntries,
renameTranscriptEntry,
deleteTranscriptEntry,
duplicateTranscriptEntry,
mediaKeyInUse,
mediaNameFromRef,
} = require('../../js/hyperaudio-lite-editor-storage.js');

Expand Down Expand Up @@ -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');
Expand Down
62 changes: 55 additions & 7 deletions css/hyperaudio-lite-editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
43 changes: 1 addition & 42 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -977,50 +977,9 @@ <h3 class="font-bold text-lg">Caption Regeneration </h3>
<import-srt></import-srt>
<import-vtt></import-vtt>

<!-- comment out if localstorage not required -->

<div class="hidden-label-holder">
<label for="file-save-dialog">Open Save to Local Storage Dialog</label>
</div>
<input type="checkbox" id="file-save-dialog" class="modal-toggle" tabindex="-1" aria-hidden="true" />
<div class="modal">
<div class="modal-box">
<div class="flex flex-col gap-4 w-full">
<label for="file-save-dialog" class="btn btn-sm btn-circle absolute right-2 top-2" aria-label="Close">✕</label>
<h3 class="font-bold text-lg">Save to Local Storage</h3>
<input type="text" id="save-localstorage-filename" name="save-localstorage-filename" placeholder="File name" class="input input-bordered w-full max-w-xs" />
</div>
<div class="modal-action">
<label for="file-save-dialog" class="btn btn-ghost">Cancel</label>
<label id="file-save-localstorage" for="file-save-dialog" class="btn btn-primary">Confirm</label>
</div>
</div>
</div>


<div class="hidden-label-holder">
<label for="file-load-dialog">Open Load from Local Storage Dialog</label>
</div>
<input type="checkbox" id="file-load-dialog" class="modal-toggle" tabindex="-1" aria-hidden="true" />
<div class="modal">
<div class="modal-box">
<div class="flex flex-col gap-4 w-full">
<label for="file-load-dialog" class="btn btn-sm btn-circle absolute right-2 top-2" aria-label="Close">✕</label>
<h3 class="font-bold text-lg">Load from Local Storage</h3>
<select id="load-localstorage-filename" aria-label="Load a saved project" class="select select-bordered w-full max-w-xs">
</select>
</div>
<div class="modal-action">
<label for="file-load-dialog" class="btn btn-ghost">Cancel</label>
<label id="file-load-localstorage" for="file-load-dialog" class="btn btn-primary">Confirm</label>
</div>
</div>
</div>


<script src="./js/hyperaudio-lite-editor-storage.js?v=0.9.0"></script>

<script src="js/editor-file-menu.js?v=0.6.12"></script>
<script src="js/editor-file-menu.js?v=0.9.0"></script>

<script src="./js/hyperaudio-lite-editor-export.js?v=0.8.7" type="module"> </script>
<!-- end of localstorage additions -->
Expand Down
26 changes: 4 additions & 22 deletions js/editor-file-menu.js
Original file line number Diff line number Diff line change
@@ -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", '<hr class="my-2 h-0 border border-t-0 border-solid border-neutral-700 opacity-25 dark:border-neutral-200" /><li class="menu-title"><span>Local Storage</span></li><li><label for="file-save-dialog">Save to Local Storage</label></li><li><label for="file-load-dialog">Load from Local Storage</label></li>');

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();

Loading
Loading