components_hyperlink_dialog.js
// Module-level cache so results persist across dialog instances within the session.
// Key: normalised URL string. Value: {accessible, title}.
const _titleCache = new Map();
/**
* Modal dialog for adding or editing a hyperlink on a transcript segment or
* text selection. Prompts for a URL (required), an optional display name,
* an optional description, and optional editor notes.
*
* When the URL field is filled, the dialog:
* - Shows a spinner while the server proxy checks the URL
* - Shows a green check (accessible) or red × (not accessible) after the check
* - If a page title is found, shows a dismissible suggestion below the URL field
* - Caches results so re-visiting the same URL is instant
*/
export class HyperlinkDialog {
/**
* @param {object} callbacks - Callback functions and initial field values for the dialog.
* @param {function} [callbacks.onSave] - Called with {url, name, description, editorNotes}
* @param {function} [callbacks.onDismiss] - Called when the dialog is cancelled
* @param {function} [callbacks.fetchTitle] - async (url) => {accessible, title} via server proxy
* @param {string} [callbacks.initialUrl] - Pre-fill the URL field (edit mode)
* @param {string} [callbacks.initialName] - Pre-fill the name field (edit mode)
* @param {string} [callbacks.initialDescription] - Pre-fill the description field (edit mode)
* @param {string} [callbacks.initialEditorNotes] - Pre-fill the editor notes field (edit mode)
*/
constructor({ onSave, onDismiss, fetchTitle, initialUrl = '', initialName = '', initialDescription = '', initialEditorNotes = '' } = {}) {
this._onSave = onSave ?? (() => {});
this._onDismiss = onDismiss ?? (() => {});
this._fetchTitle = fetchTitle ?? null;
this._initialUrl = initialUrl;
this._initialName = initialName;
this._initialDescription = initialDescription;
this._initialEditorNotes = initialEditorNotes;
this.#buildDialog();
}
/** Builds and attaches the dialog overlay and modal to the document body. */
#buildDialog() {
const overlay = document.createElement('div');
overlay.className = 'confirm-dialog-overlay';
const modal = document.createElement('div');
modal.className = 'confirm-dialog-modal';
modal.style.cssText = 'max-height:none;width:360px;padding:0;';
const header = document.createElement('div');
header.className = 'confirm-dialog-header';
header.innerHTML = `<span>${this._initialUrl ? 'Edit hyperlink' : 'Add hyperlink'}</span>`;
modal.appendChild(header);
const body = document.createElement('div');
body.style.cssText = 'padding:1rem 1.25rem;display:flex;flex-direction:column;gap:0.75rem;';
const inputStyle = 'background:var(--surface);border:1px solid var(--border);border-radius:2px;' +
'color:var(--text);font-family:var(--font-mono);font-size:var(--fs-caption);' +
'padding:0.3rem 0.5rem;outline:none;width:100%;box-sizing:border-box;transition:border-color 0.15s;';
const labelStyle = 'display:flex;flex-direction:column;gap:0.3rem;font-family:var(--font-mono);' +
'font-size:var(--fs-caption);color:var(--muted);letter-spacing:0.04em;';
const makeInput = (type, placeholder, value) => {
const el = document.createElement('input');
el.type = type; el.placeholder = placeholder; el.value = value;
el.style.cssText = inputStyle;
el.onfocus = () => { el.style.borderColor = 'var(--accent2-active)'; };
el.onblur = () => { el.style.borderColor = ''; };
return el;
};
const makeTextarea = (placeholder, value, rows = 3) => {
const el = document.createElement('textarea');
el.placeholder = placeholder; el.value = value; el.rows = rows;
el.style.cssText = inputStyle + 'resize:vertical;';
el.onfocus = () => { el.style.borderColor = 'var(--accent2-active)'; };
el.onblur = () => { el.style.borderColor = ''; };
return el;
};
const makeLabel = (text, input) => {
const label = document.createElement('label');
label.style.cssText = labelStyle;
label.textContent = text;
label.appendChild(input);
return label;
};
// ── URL field with inline status icon ──────────────────���──────────────
const urlInput = makeInput('url', 'https://', this._initialUrl);
urlInput.style.cssText = inputStyle + 'flex:1;width:auto;';
// Status icon: spinner | ✓ | ✗ | empty
const urlStatus = document.createElement('span');
urlStatus.style.cssText = 'flex-shrink:0;width:18px;height:18px;display:flex;align-items:center;' +
'justify-content:center;font-size:0.85rem;line-height:1;';
const urlInputRow = document.createElement('div');
urlInputRow.style.cssText = 'display:flex;align-items:center;gap:0.4rem;';
urlInputRow.appendChild(urlInput);
urlInputRow.appendChild(urlStatus);
// Title suggestion row (hidden until a title is found)
const titleSuggestion = document.createElement('button');
titleSuggestion.type = 'button';
titleSuggestion.style.cssText = 'display:none;align-self:flex-start;background:var(--accent2-faint);' +
'border:1px solid var(--accent2-border);border-radius:2px;color:var(--accent2);' +
'font-family:var(--font-mono);font-size:var(--fs-hint);padding:0.2rem 0.45rem;' +
'cursor:pointer;text-align:left;transition:background 0.1s;white-space:nowrap;' +
'overflow:hidden;text-overflow:ellipsis;max-width:100%;';
titleSuggestion.onmouseenter = () => { titleSuggestion.style.background = 'var(--accent2-hover)'; };
titleSuggestion.onmouseleave = () => { titleSuggestion.style.background = 'var(--accent2-faint)'; };
const urlLabel = document.createElement('label');
urlLabel.style.cssText = labelStyle;
urlLabel.textContent = 'URL';
urlLabel.appendChild(urlInputRow);
urlLabel.appendChild(titleSuggestion);
const nameInput = makeInput('text', 'Display name', this._initialName);
const descInput = makeTextarea('Brief description…', this._initialDescription);
const notesInput = makeTextarea('Notes visible only to editors…', this._initialEditorNotes, 2);
body.appendChild(urlLabel);
body.appendChild(makeLabel('Display name (optional)', nameInput));
body.appendChild(makeLabel('Description (optional)', descInput));
body.appendChild(makeLabel('Editor notes (optional)', notesInput));
// Actions
const actions = document.createElement('div');
actions.style.cssText = 'display:flex;gap:0.5rem;justify-content:flex-end;margin-top:0.25rem;';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'sample-btn';
cancelBtn.textContent = 'Cancel';
cancelBtn.style.cssText = 'padding:0.35rem 0.75rem;font-size:var(--fs-label);';
cancelBtn.addEventListener('click', () => { overlay.remove(); this._onDismiss(); });
actions.appendChild(cancelBtn);
const saveBtn = document.createElement('button');
saveBtn.className = 'sample-btn';
saveBtn.textContent = 'Save';
saveBtn.style.cssText = 'padding:0.35rem 0.75rem;font-size:var(--fs-label);' +
'border-color:var(--accent2-border);color:var(--accent2);';
saveBtn.onmouseenter = () => { saveBtn.style.background = 'var(--accent2-faint)'; saveBtn.style.borderColor = 'var(--accent2-glow)'; };
saveBtn.onmouseleave = () => { saveBtn.style.background = ''; saveBtn.style.borderColor = 'var(--accent2-border)'; };
actions.appendChild(saveBtn);
body.appendChild(actions);
modal.appendChild(body);
overlay.appendChild(modal);
const doSave = () => {
const url = urlInput.value.trim();
if (!url) { urlInput.focus(); urlInput.style.borderColor = 'var(--danger-border)'; return; }
overlay.remove();
this._onSave({
url,
name: nameInput.value.trim() || null,
description: descInput.value.trim() || null,
editorNotes: notesInput.value.trim() || null,
});
};
saveBtn.addEventListener('click', doSave);
const cancel = () => { overlay.remove(); this._onDismiss(); };
// ── URL status helpers ───────────────────────��─────────────────────────
const setStatusIdle = () => { urlStatus.innerHTML = ''; urlStatus.textContent = ''; urlStatus.style.fontWeight = ''; };
const setStatusLoading = () => {
urlStatus.style.fontWeight = '';
urlStatus.innerHTML = '<span style="display:inline-block;width:12px;height:12px;' +
'border:1.5px solid var(--border);border-top-color:var(--accent2);border-radius:50%;' +
'animation:hlnk-spin 0.7s linear infinite;"></span>';
};
const setStatusValid = () => {
urlStatus.style.fontWeight = '';
urlStatus.innerHTML = '<span class="icon icon-check" style="width:13px;height:13px;color:var(--success,#4caf50);"></span>';
};
const setStatusUnknown = () => {
urlStatus.innerHTML = '';
urlStatus.textContent = '?';
urlStatus.style.color = 'var(--muted)';
urlStatus.style.fontWeight = '600';
};
const setStatusInvalid = () => {
urlStatus.style.fontWeight = '';
urlStatus.innerHTML = '<span class="icon icon-close" style="width:13px;height:13px;color:var(--danger,#e05252);"></span>';
};
// ── Auto-fetch page title ──────────────────────────────────────────────
if (this._fetchTitle) {
let _fetchTimer = null;
let _fetchSeq = 0;
let _isPaste = false;
const setSuggestionLoading = () => {
titleSuggestion.textContent = 'Checking URL…';
titleSuggestion.style.display = 'block';
titleSuggestion.style.color = 'var(--muted)';
titleSuggestion.style.background = 'var(--surface2)';
titleSuggestion.style.borderColor = 'var(--border)';
titleSuggestion.style.cursor = 'default';
titleSuggestion.onmouseenter = null;
titleSuggestion.onmouseleave = null;
titleSuggestion.onclick = null;
};
const setSuggestionUnknown = () => {
titleSuggestion.textContent = 'Could not verify URL';
titleSuggestion.style.display = 'block';
titleSuggestion.style.color = 'var(--muted)';
titleSuggestion.style.background = 'var(--surface2)';
titleSuggestion.style.borderColor = 'var(--border)';
titleSuggestion.style.cursor = 'default';
titleSuggestion.onmouseenter = null;
titleSuggestion.onmouseleave = null;
titleSuggestion.onclick = null;
};
const setSuggestionInvalid = () => {
titleSuggestion.textContent = 'Invalid URL';
titleSuggestion.style.display = 'block';
titleSuggestion.style.color = 'var(--danger, #e05252)';
titleSuggestion.style.background = 'var(--danger-subtle, rgba(224,82,82,0.08))';
titleSuggestion.style.borderColor = 'var(--danger-border, rgba(224,82,82,0.35))';
titleSuggestion.style.cursor = 'default';
titleSuggestion.onmouseenter = null;
titleSuggestion.onmouseleave = null;
titleSuggestion.onclick = null;
};
const setSuggestionActive = (title) => {
titleSuggestion.textContent = `+ Add as title: "${title}"`;
titleSuggestion.style.display = 'block';
titleSuggestion.style.color = 'var(--accent2)';
titleSuggestion.style.background = 'var(--accent2-faint)';
titleSuggestion.style.borderColor = 'var(--accent2-border)';
titleSuggestion.style.cursor = 'pointer';
titleSuggestion.onmouseenter = () => { titleSuggestion.style.background = 'var(--accent2-hover)'; };
titleSuggestion.onmouseleave = () => { titleSuggestion.style.background = 'var(--accent2-faint)'; };
titleSuggestion.onclick = () => { nameInput.value = title; titleSuggestion.style.display = 'none'; };
};
const applyResult = (seq, { accessible, title }) => {
if (seq !== _fetchSeq) return;
if (accessible === true) {
setStatusValid();
if (title && title !== nameInput.value.trim()) { setSuggestionActive(title); } else { titleSuggestion.style.display = 'none'; }
} else if (accessible === null) {
setStatusUnknown();
if (title && title !== nameInput.value.trim()) { setSuggestionActive(title); } else { setSuggestionUnknown(); }
} else {
setStatusInvalid();
setSuggestionInvalid();
}
};
const doFetch = async (url, seq) => {
if (_titleCache.has(url)) { applyResult(seq, _titleCache.get(url)); return; }
setStatusLoading();
setSuggestionLoading();
try {
const result = await this._fetchTitle(url);
if (result.accessible === true) _titleCache.set(url, result); // only cache confirmed successes
applyResult(seq, result);
} catch {
if (seq === _fetchSeq) { setStatusInvalid(); setSuggestionInvalid(); }
}
};
// When the display name is manually edited, re-show the suggestion if
// the cached title for the current URL differs from the new value.
nameInput.addEventListener('input', () => {
const url = urlInput.value.trim();
if (!url) return;
const cached = _titleCache.get(url);
if (!cached?.title) return;
if (cached.title !== nameInput.value.trim()) {
setSuggestionActive(cached.title);
} else {
titleSuggestion.style.display = 'none';
}
});
urlInput.addEventListener('paste', () => { _isPaste = true; });
urlInput.addEventListener('input', () => {
clearTimeout(_fetchTimer);
_fetchSeq++;
const url = urlInput.value.trim();
if (!url) { setStatusIdle(); titleSuggestion.style.display = 'none'; _isPaste = false; return; }
if (_titleCache.has(url)) {
applyResult(_fetchSeq, _titleCache.get(url));
_isPaste = false;
return;
}
setStatusLoading();
setSuggestionLoading();
const delay = _isPaste ? 50 : 700;
_isPaste = false;
_fetchTimer = setTimeout(() => doFetch(url, _fetchSeq), delay);
});
}
// ── Keyboard navigation ──────────────────────────────���─────────────────
const handleNav = (e, idx, fields) => {
if (e.key === 'Escape') { e.preventDefault(); cancel(); return; }
if (e.key === 'Tab') {
e.preventDefault();
const next = e.shiftKey ? fields[idx - 1] : fields[idx + 1];
if (next) next.focus(); else if (!e.shiftKey) saveBtn.focus();
return;
}
e.stopPropagation();
};
const allFields = [urlInput, nameInput, descInput, notesInput];
urlInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); doSave(); return; }
handleNav(e, 0, allFields);
});
nameInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); doSave(); return; }
handleNav(e, 1, allFields);
});
descInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { e.preventDefault(); cancel(); return; }
if (e.key === 'Tab') { e.preventDefault(); e.shiftKey ? nameInput.focus() : notesInput.focus(); return; }
e.stopPropagation();
});
notesInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { e.preventDefault(); cancel(); return; }
if (e.key === 'Tab') { e.preventDefault(); e.shiftKey ? descInput.focus() : saveBtn.focus(); return; }
e.stopPropagation();
});
saveBtn.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { e.preventDefault(); cancel(); return; }
if (e.key === 'Tab' && e.shiftKey) { e.preventDefault(); notesInput.focus(); return; }
if (e.key === 'Enter') { e.preventDefault(); doSave(); return; }
e.stopPropagation();
});
let _mouseDownOnOverlay = false;
overlay.addEventListener('mousedown', (e) => { _mouseDownOnOverlay = e.target === overlay; });
overlay.addEventListener('click', (e) => {
if (e.target === overlay && _mouseDownOnOverlay) { overlay.remove(); this._onDismiss(); }
});
document.body.appendChild(overlay);
requestAnimationFrame(() => urlInput.focus());
}
}