feat(entries): multiline Notes field for any entry, masked by default

The note body of a note entry is the encrypted_password column, so logins had no way to carry notes. Rather than a dedicated encrypted column and the seven call sites that come with it, this rides on custom_fields, which rotation, duplicate, export/import and sync already carry.

Both the open-time normalisation and the save-time rebuild drop any key they do not name, so multiline is carried explicitly in each - otherwise the field silently reopens single-line.
This commit is contained in:
r-zakarya
2026-07-28 23:06:39 +01:00
parent 415dc6c8ba
commit d8643b96d1
2 changed files with 65 additions and 10 deletions
+11 -1
View File
@@ -1601,6 +1601,15 @@ input[type="range"]::-webkit-slider-thumb {
padding: 4px 8px;
font-size: 12px;
}
/* Multiline custom field (Notes). The row stays a grid, so the textarea just
grows in its own column and the buttons keep their places. */
.so-custom-multiline {
min-height: 72px;
resize: vertical;
align-self: stretch;
font-family: inherit;
line-height: 1.5;
}
/* Editable combobox for option-backed custom fields (card brand, expiry
year…). Input fills the cell; the arrow drops a menu of ALL options. */
.so-combo { position: relative; display: flex; align-items: center; }
@@ -1875,7 +1884,8 @@ input[type="range"]::-webkit-slider-thumb {
}
/* A textarea has no type="password"; -webkit-text-security is the Chromium
equivalent (WebView2 is Chromium, so it's always available here). */
.so-note-body.is-masked { -webkit-text-security: disc; }
.so-note-body.is-masked,
.so-custom-multiline.is-masked { -webkit-text-security: disc; }
.so-note-label { display: flex; align-items: center; justify-content: space-between; }
/* ---- + New dropdown ---------------------------------- */
+54 -9
View File
@@ -3983,6 +3983,10 @@ async function openSlideOver(id, opts) {
};
if (Array.isArray(f.options) && f.options.length > 0)
out.options = f.options.slice();
// Carried explicitly: this normalisation drops every key it does not
// name, so a multiline field would silently come back single-line on
// the next open.
if (f.multiline) out.multiline = true;
return out;
});
soState.originalCustomJson = JSON.stringify(soState.customFields);
@@ -4128,6 +4132,31 @@ function soCustomFieldsField() {
}, 0);
});
addWrap.appendChild(addBtn);
// Notes for a login: a multiline custom field rather than a column of its
// own. The note body of a note entry IS encrypted_password, so a dedicated
// notes column would mean a new encrypted field and the seven call sites
// CLAUDE.md lists — while custom_fields already rides along with rotation,
// duplicate, export/import and sync for free. Secret by default.
const noteBtn = el('button', { class: 'btn btn-ghost btn-sm', type: 'button' });
noteBtn.appendChild(icon('i-edit'));
noteBtn.appendChild(document.createTextNode(' Add note'));
noteBtn.addEventListener('click', ev => {
ev.stopPropagation();
soState.customFields = soState.customFields || [];
soState.customFields.push({
label: 'Notes', value: '', is_secret: true, multiline: true,
});
renderRows();
soDirtyCheck();
setTimeout(() => {
const areas = list.querySelectorAll('.so-custom-multiline');
const last = areas[areas.length - 1];
if (last) last.focus();
}, 0);
});
addWrap.appendChild(noteBtn);
wrap.appendChild(addWrap);
return wrap;
}
@@ -4154,19 +4183,31 @@ function buildCustomFieldRow(field, idx, rerender) {
let valueInput;
let valueSlot; // what actually goes into the row (input or combo wrap)
const hasOptions = Array.isArray(field.options) && field.options.length > 0;
valueInput = el('input', {
type: field.is_secret ? 'password' : 'text',
class: 'so-input so-custom-value',
placeholder: hasOptions ? 'Pick or type…' : 'Value',
autocomplete: field.is_secret ? 'new-password' : 'off',
spellcheck: 'false',
});
// Multiline fields (Notes) are a <textarea>, which has no type=password:
// masking goes through the same -webkit-text-security class as the note
// body of a note entry. Storage shape is unchanged — still field.value.
if (field.multiline) {
valueInput = el('textarea', {
class: 'so-input so-custom-value so-custom-multiline',
rows: 5, placeholder: 'Value',
autocomplete: 'off', autocorrect: 'off', spellcheck: 'false',
});
valueInput.classList.toggle('is-masked', !!field.is_secret);
} else {
valueInput = el('input', {
type: field.is_secret ? 'password' : 'text',
class: 'so-input so-custom-value',
placeholder: hasOptions ? 'Pick or type…' : 'Value',
autocomplete: field.is_secret ? 'new-password' : 'off',
spellcheck: 'false',
});
}
valueInput.value = field.value || '';
valueInput.addEventListener('input', () => {
field.value = valueInput.value;
soDirtyCheck();
});
if (hasOptions && !field.is_secret) {
if (hasOptions && !field.is_secret && !field.multiline) {
const combo = el('div', { class: 'so-combo' });
valueInput.classList.add('so-combo-input');
const arrow = el('button', {
@@ -4213,11 +4254,12 @@ function buildCustomFieldRow(field, idx, rerender) {
eye.addEventListener('click', ev => {
ev.stopPropagation();
if (!field.is_secret) return;
if (field.multiline) { valueInput.classList.toggle('is-masked'); return; }
// No-op on <select> — it has no type=password equivalent.
if (valueInput.tagName !== 'INPUT') return;
valueInput.type = valueInput.type === 'password' ? 'text' : 'password';
});
if (!field.is_secret || valueInput.tagName !== 'INPUT')
if (!field.is_secret || (valueInput.tagName !== 'INPUT' && !field.multiline))
eye.style.visibility = 'hidden';
// Secret-flag toggle (chip-style) — flips both the storage flag and
@@ -4896,6 +4938,9 @@ async function soSaveInner() {
if (Array.isArray(f.options) && f.options.length > 0) {
out.options = f.options.slice();
}
// Same reason as options: without this the field is stored flat and
// reopens as a one-line input.
if (f.multiline) out.multiline = true;
return out;
});
let cfEnc = '', cfIv = '';