feat(ext/popup): add pop-out to tab for forms
Forms can now be opened in a full browser tab via the ⤴ button, solving Chrome's popup closure on file picker interaction. Deep linking via URL params preserves view, item type, and item ID. Also removes the unused dropdown picker code from item-list.ts. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
/// Typed-item add/edit form dispatcher. Each type's renderForm lives in
|
||||
/// its own module under ./types/. Document stays "coming soon" until γ.
|
||||
|
||||
import { navigate, getState, setState, escapeHtml } from '../popup';
|
||||
import { navigate, getState, setState, escapeHtml, popOutToTab } from '../popup';
|
||||
import type { Item, ItemType } from '../../shared/types';
|
||||
|
||||
const TYPE_OPTIONS: Array<{ type: ItemType; icon: string; label: string }> = [
|
||||
@@ -57,6 +57,8 @@ function renderTypeSelection(app: HTMLElement): void {
|
||||
<div style="display:flex; align-items:center; gap:12px; margin-bottom:16px;">
|
||||
<button class="btn" id="back-btn">← back</button>
|
||||
<h3 style="margin:0;">new item</h3>
|
||||
<span style="flex:1;"></span>
|
||||
<button class="btn" id="popout-btn" title="Open in tab">⤴</button>
|
||||
</div>
|
||||
<div class="type-select-list">
|
||||
${TYPE_OPTIONS.map((opt) => `
|
||||
@@ -70,6 +72,7 @@ function renderTypeSelection(app: HTMLElement): void {
|
||||
`;
|
||||
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
|
||||
document.getElementById('popout-btn')?.addEventListener('click', popOutToTab);
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-type]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
|
||||
@@ -223,102 +223,6 @@ function handleListKeydown(e: KeyboardEvent): void {
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// New-item type picker popover
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const NEW_TYPE_OPTIONS: Array<{ type: ItemType; icon: string; label: string; disabled?: boolean; tooltip?: string }> = [
|
||||
{ type: 'login', icon: '🔑', label: 'login' },
|
||||
{ type: 'secure_note', icon: '📝', label: 'secure note' },
|
||||
{ type: 'identity', icon: '🪪', label: 'identity' },
|
||||
{ type: 'card', icon: '💳', label: 'card' },
|
||||
{ type: 'key', icon: '🗝', label: 'key' },
|
||||
{ type: 'totp', icon: '⏱', label: 'totp' },
|
||||
{ type: 'document', icon: '📄', label: 'document' },
|
||||
];
|
||||
|
||||
function showNewTypePicker(anchor: HTMLElement): void {
|
||||
document.querySelectorAll('.new-type-picker').forEach((el) => el.remove());
|
||||
|
||||
const picker = document.createElement('div');
|
||||
picker.className = 'new-type-picker';
|
||||
Object.assign(picker.style, {
|
||||
position: 'absolute',
|
||||
background: '#161b22',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: '6px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
|
||||
padding: '4px',
|
||||
minWidth: '160px',
|
||||
maxHeight: '280px',
|
||||
overflowY: 'auto',
|
||||
zIndex: '999999',
|
||||
fontSize: '12px',
|
||||
});
|
||||
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const dropdownHeight = 220; // approx height for 7 items
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
if (spaceBelow < dropdownHeight && rect.top > dropdownHeight) {
|
||||
picker.style.bottom = `${window.innerHeight - rect.top + 4}px`;
|
||||
} else {
|
||||
picker.style.top = `${rect.bottom + 4}px`;
|
||||
}
|
||||
picker.style.left = `${rect.left}px`;
|
||||
|
||||
for (const opt of NEW_TYPE_OPTIONS) {
|
||||
const row = document.createElement('div');
|
||||
Object.assign(row.style, {
|
||||
padding: '6px 10px',
|
||||
cursor: opt.disabled ? 'not-allowed' : 'pointer',
|
||||
color: opt.disabled ? '#484f58' : '#c9d1d9',
|
||||
borderRadius: '4px',
|
||||
display: 'flex', alignItems: 'center', gap: '8px',
|
||||
});
|
||||
if (opt.tooltip) row.title = opt.tooltip;
|
||||
const iconSpan = document.createElement('span');
|
||||
Object.assign(iconSpan.style, { fontSize: '14px', width: '16px', display: 'inline-block', textAlign: 'center' });
|
||||
iconSpan.textContent = opt.icon;
|
||||
const labelSpan = document.createElement('span');
|
||||
labelSpan.textContent = opt.label;
|
||||
row.appendChild(iconSpan);
|
||||
row.appendChild(labelSpan);
|
||||
if (!opt.disabled) {
|
||||
row.addEventListener('mouseenter', () => { row.style.background = '#21262d'; });
|
||||
row.addEventListener('mouseleave', () => { row.style.background = 'transparent'; });
|
||||
row.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
picker.remove();
|
||||
document.removeEventListener('click', closeOnOutside);
|
||||
document.removeEventListener('keydown', closeOnEsc);
|
||||
setState({ newType: opt.type });
|
||||
navigate('add');
|
||||
});
|
||||
}
|
||||
picker.appendChild(row);
|
||||
}
|
||||
|
||||
document.body.appendChild(picker);
|
||||
|
||||
const closeOnOutside = (ev: MouseEvent) => {
|
||||
if (!picker.contains(ev.target as Node)) {
|
||||
picker.remove();
|
||||
document.removeEventListener('click', closeOnOutside);
|
||||
document.removeEventListener('keydown', closeOnEsc);
|
||||
}
|
||||
};
|
||||
const closeOnEsc = (ev: KeyboardEvent) => {
|
||||
if (ev.key === 'Escape') {
|
||||
picker.remove();
|
||||
document.removeEventListener('click', closeOnOutside);
|
||||
document.removeEventListener('keydown', closeOnEsc);
|
||||
}
|
||||
};
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', closeOnOutside);
|
||||
document.addEventListener('keydown', closeOnEsc);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Settings picker popover (device vs vault)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/// Card: number / holder / expiry MonthYear / cvv / pin / kind.
|
||||
/// Detail view has a styled card-silhouette signature block.
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../popup';
|
||||
import type { Item, ItemId, ManifestEntry, CardKind, Section, AttachmentRef } from '../../../shared/types';
|
||||
import {
|
||||
renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections,
|
||||
@@ -173,7 +173,11 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new card' : 'edit card'}</div>
|
||||
<div style="display:flex; align-items:center; margin-bottom:16px;">
|
||||
<div class="detail-title">${mode === 'add' ? 'new card' : 'edit card'}</div>
|
||||
<span style="flex:1;"></span>
|
||||
<button class="btn" id="popout-btn" title="Open in tab">⤴</button>
|
||||
</div>
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
<div class="form-group"><label class="label" for="f-title">title <span class="req">*</span></label>
|
||||
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="Amex Gold"></div>
|
||||
@@ -235,6 +239,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
||||
setState({ error: null });
|
||||
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||
});
|
||||
document.getElementById('popout-btn')?.addEventListener('click', popOutToTab);
|
||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||
await saveCard(mode, existing, sectionsDraft, attachmentsDraft);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/// notes/tags + optional supplementary attachments.
|
||||
/// Primary attachment is referenced by ID from the item's attachments array.
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../popup';
|
||||
import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
|
||||
import {
|
||||
renderSectionsEditor, wireSectionsEditor,
|
||||
@@ -84,7 +84,11 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div class="detail-title" style="margin-bottom:16px;">${isEdit ? 'edit document' : 'new document'}</div>
|
||||
<div style="display:flex; align-items:center; margin-bottom:16px;">
|
||||
<div class="detail-title">${isEdit ? 'edit document' : 'new document'}</div>
|
||||
<span style="flex:1;"></span>
|
||||
<button class="btn" id="popout-btn" title="Open in tab">⤴</button>
|
||||
</div>
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-title">title <span class="req">*</span></label>
|
||||
@@ -180,6 +184,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
||||
setState({ error: null });
|
||||
navigate(isEdit ? 'detail' : 'list');
|
||||
});
|
||||
document.getElementById('popout-btn')?.addEventListener('click', popOutToTab);
|
||||
|
||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||
await saveDocument(mode, existing, primaryId, attachmentsDraft, sectionsDraft);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/// Identity: full_name, address (multiline), phone, email, date_of_birth.
|
||||
/// Detail view shows a "profile card" signature block + plain rows.
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../popup';
|
||||
import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
|
||||
import {
|
||||
renderRow, renderSignatureBlock, wireFieldHandlers, renderSections,
|
||||
@@ -133,7 +133,11 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new identity' : 'edit identity'}</div>
|
||||
<div style="display:flex; align-items:center; margin-bottom:16px;">
|
||||
<div class="detail-title">${mode === 'add' ? 'new identity' : 'edit identity'}</div>
|
||||
<span style="flex:1;"></span>
|
||||
<button class="btn" id="popout-btn" title="Open in tab">⤴</button>
|
||||
</div>
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
<div class="form-group"><label class="label" for="f-title">title <span class="req">*</span></label>
|
||||
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="Aaron Lee · personal"></div>
|
||||
@@ -190,6 +194,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
||||
setState({ error: null });
|
||||
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||
});
|
||||
document.getElementById('popout-btn')?.addEventListener('click', popOutToTab);
|
||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||
await saveIdentity(mode, existing, sectionsDraft, attachmentsDraft);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/// Form's key_material textarea uses CSS text-security to mask characters
|
||||
/// since <textarea type="password"> isn't a thing.
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../popup';
|
||||
import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
|
||||
import {
|
||||
renderRow, renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections,
|
||||
@@ -122,7 +122,11 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new key' : 'edit key'}</div>
|
||||
<div style="display:flex; align-items:center; margin-bottom:16px;">
|
||||
<div class="detail-title">${mode === 'add' ? 'new key' : 'edit key'}</div>
|
||||
<span style="flex:1;"></span>
|
||||
<button class="btn" id="popout-btn" title="Open in tab">⤴</button>
|
||||
</div>
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
<div class="form-group"><label class="label" for="f-title">title <span class="req">*</span></label>
|
||||
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="github ssh"></div>
|
||||
@@ -189,6 +193,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
||||
setState({ error: null });
|
||||
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||
});
|
||||
document.getElementById('popout-btn')?.addEventListener('click', popOutToTab);
|
||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||
await saveKey(mode, existing, sectionsDraft, attachmentsDraft);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/// Login type detail + form. Reference implementation for the shared
|
||||
/// field helpers introduced in Slice 2.
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../popup';
|
||||
import type { Item, ItemId, LoginCore, ManifestEntry, Section, TotpConfig, AttachmentRef } from '../../../shared/types';
|
||||
import { DEFAULT_PASSWORD_REQUEST } from '../../../shared/types';
|
||||
import { base32Decode, base32Encode } from '../../../shared/base32';
|
||||
@@ -243,7 +243,11 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new login' : 'edit login'}</div>
|
||||
<div style="display:flex; align-items:center; margin-bottom:16px;">
|
||||
<div class="detail-title">${mode === 'add' ? 'new login' : 'edit login'}</div>
|
||||
<span style="flex:1;"></span>
|
||||
<button class="btn" id="popout-btn" title="Open in tab">⤴</button>
|
||||
</div>
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
<div class="form-group"><label class="label" for="f-title">title <span class="req">*</span></label>
|
||||
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub"></div>
|
||||
@@ -327,6 +331,8 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
||||
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||
});
|
||||
|
||||
document.getElementById('popout-btn')?.addEventListener('click', popOutToTab);
|
||||
|
||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||
await saveLogin(mode, existing, sectionsDraft, attachmentsDraft);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/// SecureNote: a single multiline body field. Concealed by default in the
|
||||
/// detail view; the form is just a big <textarea>.
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../popup';
|
||||
import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
|
||||
import {
|
||||
renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections,
|
||||
@@ -111,7 +111,11 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new secure note' : 'edit secure note'}</div>
|
||||
<div style="display:flex; align-items:center; margin-bottom:16px;">
|
||||
<div class="detail-title">${mode === 'add' ? 'new secure note' : 'edit secure note'}</div>
|
||||
<span style="flex:1;"></span>
|
||||
<button class="btn" id="popout-btn" title="Open in tab">⤴</button>
|
||||
</div>
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
<div class="form-group"><label class="label" for="f-title">title <span class="req">*</span></label>
|
||||
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="My recovery codes"></div>
|
||||
@@ -160,6 +164,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
||||
setState({ error: null });
|
||||
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||
});
|
||||
document.getElementById('popout-btn')?.addEventListener('click', popOutToTab);
|
||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||
await saveSecureNote(mode, existing, sectionsDraft, attachmentsDraft);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/// signature block with a thin SVG countdown ring; form has a kind toggle
|
||||
/// (TOTP vs Steam Guard) and a single secret input.
|
||||
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
|
||||
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../popup';
|
||||
import type { Item, ItemId, ManifestEntry, Section, TotpKind, AttachmentRef } from '../../../shared/types';
|
||||
import { base32Decode, base32Encode } from '../../../shared/base32';
|
||||
import {
|
||||
@@ -212,7 +212,11 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
||||
|
||||
const renderInner = (): string => `
|
||||
<div class="pad">
|
||||
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new totp' : 'edit totp'}</div>
|
||||
<div style="display:flex; align-items:center; margin-bottom:16px;">
|
||||
<div class="detail-title">${mode === 'add' ? 'new totp' : 'edit totp'}</div>
|
||||
<span style="flex:1;"></span>
|
||||
<button class="btn" id="popout-btn" title="Open in tab">⤴</button>
|
||||
</div>
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
<div class="form-group"><label class="label" for="f-title">title <span class="req">*</span></label>
|
||||
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub"></div>
|
||||
@@ -328,6 +332,7 @@ function wireFormButtons(mode: 'add' | 'edit', existing: Item | null, sectionsDr
|
||||
setState({ error: null });
|
||||
navigate(mode === 'edit' ? 'detail' : 'list');
|
||||
});
|
||||
document.getElementById('popout-btn')?.addEventListener('click', popOutToTab);
|
||||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||||
await saveTotp(mode, existing, sectionsDraft, attachmentsDraft);
|
||||
});
|
||||
|
||||
@@ -28,6 +28,29 @@ export function escapeHtml(str: string): string {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// --- Pop out to tab ---
|
||||
|
||||
export function popOutToTab(): void {
|
||||
const state = getState();
|
||||
const params = new URLSearchParams();
|
||||
params.set('view', state.view);
|
||||
if (state.newType) params.set('type', state.newType);
|
||||
if (state.selectedId) params.set('id', state.selectedId);
|
||||
chrome.tabs.create({ url: `popup.html?${params.toString()}` });
|
||||
window.close();
|
||||
}
|
||||
|
||||
function parseUrlParams(): { view?: View; type?: string; id?: string } | null {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const view = params.get('view');
|
||||
if (!view) return null;
|
||||
return {
|
||||
view: view as View,
|
||||
type: params.get('type') ?? undefined,
|
||||
id: params.get('id') ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// --- State ---
|
||||
|
||||
export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings' | 'settings-vault' | 'trash' | 'devices' | 'field-history';
|
||||
@@ -214,6 +237,28 @@ async function init(): Promise<void> {
|
||||
currentState.vaultSettings = vs;
|
||||
currentState.generatorDefaults = vs.generator_defaults;
|
||||
}
|
||||
|
||||
// Check URL params for deep linking (when opened in tab)
|
||||
const urlParams = parseUrlParams();
|
||||
if (urlParams) {
|
||||
currentState.entries = listData.items;
|
||||
if (urlParams.view === 'add' && urlParams.type) {
|
||||
currentState.newType = urlParams.type as import('../shared/types').ItemType;
|
||||
navigate('add');
|
||||
return;
|
||||
}
|
||||
if ((urlParams.view === 'edit' || urlParams.view === 'detail') && urlParams.id) {
|
||||
// Fetch the item
|
||||
const itemResp = await sendMessage({ type: 'get_item', id: urlParams.id });
|
||||
if (itemResp.ok) {
|
||||
currentState.selectedId = urlParams.id;
|
||||
currentState.selectedItem = (itemResp.data as { item: Item }).item;
|
||||
navigate(urlParams.view);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
navigate('list', { entries: listData.items });
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user