Update notes rendering

This commit is contained in:
Leendert de Borst
2026-01-30 00:04:31 +01:00
parent 05c81cc76c
commit 46d7195928
3 changed files with 157 additions and 55 deletions

View File

@@ -31,6 +31,41 @@ let popupListeners = new WeakMap<HTMLElement, EventListener>();
*/
const clickValidator = ClickValidator.getInstance();
/**
* Create a suggestion pill element using safe DOM methods.
*/
const createSuggestionPill = (value: string): HTMLElement => {
const pill = document.createElement('span');
pill.className = 'av-suggestion-pill';
const textSpan = document.createElement('span');
textSpan.className = 'av-suggestion-pill-text';
textSpan.dataset.value = value;
textSpan.textContent = value;
const deleteSpan = document.createElement('span');
deleteSpan.className = 'av-suggestion-pill-delete';
deleteSpan.dataset.value = value;
deleteSpan.title = 'Remove';
deleteSpan.textContent = '×';
pill.appendChild(textSpan);
pill.appendChild(deleteSpan);
return pill;
};
/**
* Create a suggested name element using safe DOM methods.
*/
const createSuggestedNameSpan = (name: string): HTMLElement => {
const span = document.createElement('span');
span.className = 'av-suggested-name';
span.dataset.name = name;
span.textContent = name;
return span;
};
/**
* Open (or refresh) the autofill popup including check if vault is locked.
*/
@@ -865,8 +900,6 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon
const passwordLengthText = await t('items.passwordLength');
const changePasswordComplexityText = await t('items.changePasswordComplexity');
const suggestedNamesHtml = await getSuggestedNamesHtml(suggestedNames, suggestedNames[0] ?? '');
// Create the main content
popup.innerHTML = `
<div class="av-create-popup-header">
@@ -925,7 +958,7 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon
class="av-create-popup-input"
placeholder="${enterServiceNameText}"
>
${suggestedNames.length > 1 ? `<div class="av-suggested-names">${suggestedNamesHtml}</div>` : ''}
${suggestedNames.length > 1 ? '<div class="av-suggested-names"></div>' : ''}
</div>
<div class="av-create-popup-mode av-create-popup-random-mode">
@@ -1029,6 +1062,12 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon
const emailSuggestions = popup.querySelector('#email-suggestions') as HTMLElement;
const usernameSuggestions = popup.querySelector('#username-suggestions') as HTMLElement;
// Populate suggested names
const suggestedNamesContainer = popup.querySelector('.av-suggested-names') as HTMLElement;
if (suggestedNamesContainer) {
await populateSuggestedNames(suggestedNamesContainer, suggestedNames, suggestedNames[0] ?? '');
}
/**
* Update history with new value (max 2 unique entries)
*/
@@ -1063,40 +1102,38 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon
};
/**
* Format suggestions HTML as pill-style buttons
* Update suggestions display using safe DOM methods.
*/
const formatSuggestionsHtml = async (history: string[], currentValue: string): Promise<string> => {
const updateSuggestions = (input: HTMLInputElement, suggestionsContainer: HTMLElement, history: string[]): void => {
const currentValue = input.value.trim();
// Filter out the current value from history and limit to 2 items
const filteredHistory = history
.filter(item => item.toLowerCase() !== currentValue.toLowerCase())
.slice(0, 2);
// Clear existing content
suggestionsContainer.textContent = '';
if (filteredHistory.length === 0) {
return '';
suggestionsContainer.style.display = 'none';
return;
}
// Build HTML with pill-style buttons
return filteredHistory.map(item =>
`<span class="av-suggestion-pill">
<span class="av-suggestion-pill-text" data-value="${item}">${item}</span>
<span class="av-suggestion-pill-delete" data-value="${item}" title="Remove">×</span>
</span>`
).join(' ');
};
// Build pill elements
filteredHistory.forEach((item, index) => {
if (index > 0) {
suggestionsContainer.appendChild(document.createTextNode(' '));
}
suggestionsContainer.appendChild(createSuggestionPill(item));
});
/**
* Update suggestions display
*/
const updateSuggestions = async (input: HTMLInputElement, suggestionsContainer: HTMLElement, history: string[]): Promise<void> => {
const currentValue = input.value.trim();
const html = await formatSuggestionsHtml(history, currentValue);
suggestionsContainer.innerHTML = html;
suggestionsContainer.style.display = html ? 'flex' : 'none';
suggestionsContainer.style.display = 'flex';
};
// Initial display of suggestions
await updateSuggestions(customEmail, emailSuggestions, emailHistory);
await updateSuggestions(customUsername, usernameSuggestions, usernameHistory);
updateSuggestions(customEmail, emailSuggestions, emailHistory);
updateSuggestions(customUsername, usernameSuggestions, usernameHistory);
// Handle popout button click
popoutBtn.addEventListener('click', (e) => {
@@ -1112,13 +1149,13 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon
});
// Handle email input
customEmail.addEventListener('input', async () => {
await updateSuggestions(customEmail, emailSuggestions, emailHistory);
customEmail.addEventListener('input', () => {
updateSuggestions(customEmail, emailSuggestions, emailHistory);
});
// Handle username input
customUsername.addEventListener('input', async () => {
await updateSuggestions(customUsername, usernameSuggestions, usernameHistory);
customUsername.addEventListener('input', () => {
updateSuggestions(customUsername, usernameSuggestions, usernameHistory);
});
// Handle suggestion clicks for email
@@ -1133,7 +1170,7 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon
if (value) {
const updatedHistory = await removeFromHistory(value, CUSTOM_EMAIL_HISTORY_KEY);
emailHistory.splice(0, emailHistory.length, ...updatedHistory);
await updateSuggestions(customEmail, emailSuggestions, emailHistory);
updateSuggestions(customEmail, emailSuggestions, emailHistory);
}
} else {
// Check if pill or pill text was clicked
@@ -1143,7 +1180,7 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon
const value = textElement?.dataset.value;
if (value) {
customEmail.value = value;
await updateSuggestions(customEmail, emailSuggestions, emailHistory);
updateSuggestions(customEmail, emailSuggestions, emailHistory);
}
}
}
@@ -1161,7 +1198,7 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon
if (value) {
const updatedHistory = await removeFromHistory(value, CUSTOM_USERNAME_HISTORY_KEY);
usernameHistory.splice(0, usernameHistory.length, ...updatedHistory);
await updateSuggestions(customUsername, usernameSuggestions, usernameHistory);
updateSuggestions(customUsername, usernameSuggestions, usernameHistory);
}
} else {
// Check if pill or pill text was clicked
@@ -1171,7 +1208,7 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon
const value = textElement?.dataset.value;
if (value) {
customUsername.value = value;
await updateSuggestions(customUsername, usernameSuggestions, usernameHistory);
updateSuggestions(customUsername, usernameSuggestions, usernameHistory);
}
}
}
@@ -1671,10 +1708,9 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon
customUsername.value = name;
// Update the suggested names section
const suggestedNamesContainer = target.closest('.av-suggested-names');
const suggestedNamesContainer = target.closest('.av-suggested-names') as HTMLElement;
if (suggestedNamesContainer) {
// Update the suggestions HTML using the helper function
suggestedNamesContainer.innerHTML = await getSuggestedNamesHtml(suggestedNames, name);
await populateSuggestedNames(suggestedNamesContainer, suggestedNames, name);
}
}
}
@@ -1689,21 +1725,33 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon
}
/**
* Get suggested names HTML with current input value excluded
* Populate a suggested names container using safe DOM methods.
*/
async function getSuggestedNamesHtml(suggestedNames: string[], currentValue: string): Promise<string> {
async function populateSuggestedNames(container: HTMLElement, suggestedNames: string[], currentValue: string): Promise<void> {
// Filter out the current value and create unique set of remaining suggestions
const filteredSuggestions = [...new Set(suggestedNames.filter(n => n !== currentValue))];
// Clear existing content
container.textContent = '';
if (filteredSuggestions.length === 0) {
return '';
return;
}
const orLabel = await t('content.or');
return `${orLabel} ${filteredSuggestions.map((name, index) =>
`<span class="av-suggested-name" data-name="${name}">${name}</span>${index < filteredSuggestions.length - 1 ? ', ' : ''}`
).join('')}?`;
// Add "or" label as text node
container.appendChild(document.createTextNode(orLabel + ' '));
// Add each suggestion
filteredSuggestions.forEach((name, index) => {
container.appendChild(createSuggestedNameSpan(name));
if (index < filteredSuggestions.length - 1) {
container.appendChild(document.createTextNode(', '));
}
});
container.appendChild(document.createTextNode('?'));
}
/**

View File

@@ -16,16 +16,68 @@ type FieldBlockProps = {
hideLabel?: boolean;
}
/**
* Convert URLs in text to clickable links (same as NotesBlock).
*/
const convertUrlsToLinks = (text: string): string => {
const urlPattern = /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/g;
/** URL pattern for detecting links in text */
const URL_PATTERN = /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/g;
return text.replace(urlPattern, (url) => {
/**
* Split text into parts, separating URLs from regular text.
*/
const splitTextAndUrls = (text: string): { type: 'text' | 'url'; content: string; href?: string }[] => {
const parts: { type: 'text' | 'url'; content: string; href?: string }[] = [];
let lastIndex = 0;
let match;
// Reset regex state
URL_PATTERN.lastIndex = 0;
while ((match = URL_PATTERN.exec(text)) !== null) {
// Add text before the URL
if (match.index > lastIndex) {
parts.push({ type: 'text', content: text.slice(lastIndex, match.index) });
}
// Add the URL
const url = match[0];
const href = url.startsWith('http') ? url : `http://${url}`;
return `<a href="${href}" target="_blank" rel="noopener noreferrer" class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">${url}</a>`;
});
parts.push({ type: 'url', content: url, href });
lastIndex = match.index + url.length;
}
// Add remaining text
if (lastIndex < text.length) {
parts.push({ type: 'text', content: text.slice(lastIndex) });
}
return parts;
};
/**
* Render text with clickable links.
*/
const TextWithLinks: React.FC<{ text: string }> = ({ text }) => {
const parts = splitTextAndUrls(text);
return (
<>
{parts.map((part, index) => {
if (part.type === 'url') {
return (
<a
key={index}
href={part.href}
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
{part.content}
</a>
);
}
return <React.Fragment key={index}>{part.content}</React.Fragment>;
})}
</>
);
};
/**
@@ -152,7 +204,7 @@ const FieldBlock: React.FC<FieldBlockProps> = ({ field, itemId, hideLabel = fals
);
case FieldTypes.TextArea:
// Use NotesBlock-style rendering for multi-line text
// Use safe React rendering for multi-line text with clickable links
return (
<div>
{!hideLabel && (
@@ -161,10 +213,9 @@ const FieldBlock: React.FC<FieldBlockProps> = ({ field, itemId, hideLabel = fals
</label>
)}
<div className="p-4 bg-gray-50 rounded-lg dark:bg-gray-700">
<p
className="text-gray-900 dark:text-gray-100 whitespace-pre-wrap"
dangerouslySetInnerHTML={{ __html: convertUrlsToLinks(value) }}
/>
<p className="text-gray-900 dark:text-gray-100 whitespace-pre-wrap">
<TextWithLinks text={value} />
</p>
</div>
</div>
);

View File

@@ -20,8 +20,11 @@
private static string ConvertUrlsToLinks(string text)
{
// HTML-encode the text before processing URLs
var encodedText = System.Web.HttpUtility.HtmlEncode(text);
string urlPattern = @"(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})";
return Regex.Replace(text, urlPattern, match =>
return Regex.Replace(encodedText, urlPattern, match =>
{
string url = match.Value;
if (!url.StartsWith("http://") && !url.StartsWith("https://"))