From c83c15bced21cbc6a7452af3fe4ce7d9e196efc2 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Tue, 14 Jul 2026 22:41:17 +0800 Subject: [PATCH] fix: use modal forms for notes --- plugins/notes/frontend/src/index.js | 223 +++++++++++++++++++--------- plugins/notes/locales/en.json | 5 +- plugins/notes/locales/ru.json | 5 +- plugins/notes/plugin.json | 2 +- scripts/smoke-notes-plugin.js | 37 +++-- 5 files changed, 182 insertions(+), 90 deletions(-) diff --git a/plugins/notes/frontend/src/index.js b/plugins/notes/frontend/src/index.js index af5de61..6a8ee53 100644 --- a/plugins/notes/frontend/src/index.js +++ b/plugins/notes/frontend/src/index.js @@ -43,14 +43,16 @@ '.notes-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--vt-color-text-muted,#7f8aa3);font-size:.85rem;padding:2rem;gap:.5rem;text-align:center}', '.notes-empty-hint{font-size:.75rem;color:var(--vt-color-text-muted,#7f8aa3)}', '.notes-error{flex:1;display:flex;align-items:center;justify-content:center;color:var(--vt-color-danger,#e94560);padding:1rem;font-size:.85rem}', - '.notes-panel{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;border-top:1px solid var(--vt-color-border,#202b46);flex-shrink:0;background:var(--vt-color-surface-muted,#111629)}', - '.notes-input{flex:1;font-size:.78rem;padding:.32rem .5rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);outline:none;min-width:120px}', - '.notes-input:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}', '.notes-title-bar{padding:.4rem .75rem;font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3);background:var(--vt-color-surface-muted,#111629);border-bottom:1px solid var(--vt-color-border,#202b46);text-transform:uppercase;letter-spacing:.04em}', '.notes-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:10000;display:flex;align-items:center;justify-content:center}', '.notes-modal{width:380px;max-width:90vw;padding:20px;background:#1a1a2e;border:1px solid #333;border-radius:10px;color:#e0e0e0;font-family:inherit;box-shadow:0 12px 40px rgba(0,0,0,.5)}', '.notes-modal-title{font-size:.9rem;font-weight:600;margin-bottom:12px}', '.notes-modal-msg{font-size:.82rem;color:#aaa;margin-bottom:16px;word-wrap:break-word}', + '.notes-modal-form{display:grid;gap:.55rem}', + '.notes-modal-input{width:100%;font:inherit;font-size:.84rem;padding:.5rem .6rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);outline:none}', + '.notes-modal-input:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}', + '.notes-modal-input[aria-invalid="true"]{border-color:var(--vt-color-danger,#e94560)}', + '.notes-modal-error{display:none;font-size:.78rem;line-height:1.35;color:#ffc6ce}', '.notes-modal-actions{display:flex;justify-content:flex-end;gap:8px}', '.notes-modal-btn{font-size:.8rem;padding:.35rem .9rem;border:1px solid #333;border-radius:5px;cursor:pointer;font-family:inherit}', '.notes-modal-btn.cancel{background:#2a2a4e;color:#ccc}', @@ -171,7 +173,7 @@ var workspaceNode = props && props.workspaceNode; var workspaceRoot = (workspaceNode && (workspaceNode.rootPath || workspaceNode.name || workspaceNode.id)) || ''; - var workspaceName = workspaceRoot || (workspaceNode && (workspaceNode.name || workspaceNode.title)) || 'Workspace'; + var workspaceName = workspaceRoot || (workspaceNode && (workspaceNode.name || workspaceNode.title)) || ''; var notes = []; var selectedPath = ''; @@ -287,23 +289,12 @@ var listContainer = el('div', { className: 'notes-list', 'data-notes-list': '' }); containerEl.appendChild(listContainer); - var createPanel = el('div', { className: 'notes-panel', style: { display: 'none' } }); - var createInput = el('input', { className: 'notes-input', 'data-notes-create-input': '', placeholder: tr('ui.noteTitle', null, 'Note title') }); - var createConfirm = el('button', { className: 'notes-btn', textContent: tr('ui.create', null, 'Create') }); - var createCancel = el('button', { className: 'notes-btn', textContent: tr('ui.cancel', null, 'Cancel') }); - createPanel.appendChild(createInput); - createPanel.appendChild(createConfirm); - createPanel.appendChild(createCancel); - containerEl.appendChild(createPanel); - - var renamePanel = el('div', { className: 'notes-panel', style: { display: 'none' } }); - var renameInput = el('input', { className: 'notes-input', 'data-notes-rename-input': '', placeholder: tr('ui.newTitle', null, 'New title') }); - var renameConfirm = el('button', { className: 'notes-btn', textContent: tr('ui.rename', null, 'Rename') }); - var renameCancel = el('button', { className: 'notes-btn', textContent: tr('ui.cancel', null, 'Cancel') }); - renamePanel.appendChild(renameInput); - renamePanel.appendChild(renameConfirm); - renamePanel.appendChild(renameCancel); - containerEl.appendChild(renamePanel); + var createModal = null; + var createInput = null; + var createError = null; + var renameModal = null; + var renameInput = null; + var renameError = null; // ─── Core Functions ───────────────────────────────────── @@ -495,28 +486,134 @@ }).catch(function (err) { console.error('[notes] openResource:', err); }); } + function conflictMessage(title, existingPath) { + return [ + tr('ui.conflictMessage', { title: title }, 'A note with the title "' + title + '" already exists.'), + existingPath ? tr('ui.existingFile', { path: existingPath }, ' Existing file: ' + existingPath + '.') : '', + tr('ui.chooseDifferent', null, ' Please choose a different title.') + ].join(''); + } + + function showNoteFormModal(mode, note) { + var isRename = mode === 'rename'; + var modalTitle = isRename + ? tr('ui.renameTitle', null, 'Rename note') + : tr('ui.createTitle', null, 'Create note'); + var overlay = el('div', { + className: 'notes-modal-overlay', + role: 'presentation' + }); + overlay.setAttribute('data-notes-' + mode + '-modal', ''); + var input = el('input', { + className: 'notes-modal-input', + type: 'text', + value: isRename ? (note.title || fileName(note.path)) : '', + placeholder: isRename + ? tr('ui.newTitle', null, 'New title') + : tr('ui.noteTitle', null, 'Note title'), + autocomplete: 'off', + 'aria-invalid': 'false' + }); + input.setAttribute('data-notes-' + mode + '-input', ''); + input.value = isRename ? (note.title || fileName(note.path)) : ''; + var error = el('div', { + className: 'notes-modal-error', + role: 'alert' + }); + error.setAttribute('data-notes-' + mode + '-error', ''); + var confirm = el('button', { + className: 'notes-modal-btn confirm', + type: 'button', + textContent: isRename ? tr('ui.rename', null, 'Rename') : tr('ui.create', null, 'Create'), + onClick: function () { + if (isRename) confirmRename(); + else confirmCreate(); + } + }); + var cancel = el('button', { + className: 'notes-modal-btn cancel', + type: 'button', + textContent: tr('ui.cancel', null, 'Cancel'), + onClick: function () { + if (isRename) hideRename(); + else hideCreate(); + } + }); + function handleFormKeydown(event) { + if (event.key === 'Enter') { + if (event.preventDefault) event.preventDefault(); + if (isRename) confirmRename(); + else confirmCreate(); + } + if (event.key === 'Escape') { + if (event.preventDefault) event.preventDefault(); + if (isRename) hideRename(); + else hideCreate(); + } + } + input.addEventListener('keydown', handleFormKeydown); + var modal = el('div', { + className: 'notes-modal notes-modal-form', + role: 'dialog', + 'aria-modal': 'true', + 'aria-label': modalTitle + }, [ + el('div', { className: 'notes-modal-title', textContent: modalTitle }), + input, + error, + el('div', { className: 'notes-modal-actions' }, [cancel, confirm]) + ]); + overlay.appendChild(modal); + document.body.appendChild(overlay); + if (isRename) { + renameInput = input; + renameError = error; + } else { + createInput = input; + createError = error; + } + input.focus(); + if (isRename && input.select) input.select(); + return overlay; + } + // ─── Create ──────────────────────────────────────────── function showCreate() { - createInput.value = ''; - createPanel.style.display = 'flex'; - createInput.focus(); + hideCreate(); + createModal = showNoteFormModal('create'); } function hideCreate() { - createPanel.style.display = 'none'; + if (createModal) createModal.remove(); + createModal = null; + createInput = null; + createError = null; + } + + function setCreateError(message) { + if (!createError || !createInput) return; + createError.textContent = message || ''; + createError.style.display = message ? 'block' : 'none'; + createInput.setAttribute('aria-invalid', message ? 'true' : 'false'); } function confirmCreate() { + if (!createInput) return; var title = createInput.value.trim(); - if (!title) return; + if (!title) { + setCreateError(tr('ui.titleRequired', null, 'Enter a note title.')); + createInput.focus(); + return; + } setStatus(tr('ui.creating', null, 'Creating note...'), 'loading'); var parent = notesParent(); createNote(parent, title).then(function (data) { if (disposed) return; data = data || {}; if (data.conflict) { - showConflictModal(title, data.path, createInput); + setCreateError(conflictMessage(title, data.path)); + createInput.focus(); return; } hideCreate(); @@ -539,42 +636,55 @@ }).catch(function () {}); } }).catch(function (err) { - setStatus(userFacingError('ui.createError', 'Could not create the note. Please try again.', err), 'error'); + setCreateError(userFacingError('ui.createError', 'Could not create the note. Please try again.', err)); }); } // ─── Rename ───────────────────────────────────────────── function beginRename(note) { + hideRename(); renameTarget = note; - renameInput.value = note.title || fileName(note.path); - renamePanel.style.display = 'flex'; - renameInput.focus(); - renameInput.select(); + renameModal = showNoteFormModal('rename', note); } function hideRename() { renameTarget = null; - renamePanel.style.display = 'none'; + if (renameModal) renameModal.remove(); + renameModal = null; + renameInput = null; + renameError = null; + } + + function setRenameError(message) { + if (!renameError || !renameInput) return; + renameError.textContent = message || ''; + renameError.style.display = message ? 'block' : 'none'; + renameInput.setAttribute('aria-invalid', message ? 'true' : 'false'); } function confirmRename() { - if (!renameTarget) return; + if (!renameTarget || !renameInput) return; var newTitle = renameInput.value.trim(); - if (!newTitle) return; + if (!newTitle) { + setRenameError(tr('ui.titleRequired', null, 'Enter a note title.')); + renameInput.focus(); + return; + } setStatus(tr('ui.renaming', null, 'Renaming...'), 'loading'); renameNote(renameTarget.path, newTitle).then(function (data) { if (disposed) return; data = data || {}; if (data.conflict) { - showConflictModal(newTitle, data.path, renameInput); + setRenameError(conflictMessage(newTitle, data.path)); + renameInput.focus(); return; } hideRename(); setStatus(tr('ui.renamed', null, 'Note renamed'), 'success'); loadNotes(); }).catch(function (err) { - setStatus(userFacingError('ui.renameError', 'Could not rename the note. Please try again.', err), 'error'); + setRenameError(userFacingError('ui.renameError', 'Could not rename the note. Please try again.', err)); }); } @@ -596,25 +706,6 @@ }); } - // ─── Conflict Modal ───────────────────────────────────── - - function showConflictModal(title, existingPath, focusTarget) { - var overlay = el('div', { className: 'notes-modal-overlay' }); - var modal = el('div', { className: 'notes-modal' }, [ - el('div', { className: 'notes-modal-title' }, [tr('ui.conflictTitle', null, 'Name Conflict')]), - el('div', { className: 'notes-modal-msg' }, [ - tr('ui.conflictMessage', { title: title }, 'A note with the title "' + title + '" already exists.'), - existingPath ? tr('ui.existingFile', { path: existingPath }, ' Existing file: ' + existingPath + '.') : '', - tr('ui.chooseDifferent', null, ' Please choose a different title.') - ].join('')), - el('div', { className: 'notes-modal-actions' }, [ - el('button', { className: 'notes-modal-btn confirm', textContent: 'OK', onClick: function () { overlay.remove(); (focusTarget || createInput).focus(); } }) - ]) - ]); - overlay.appendChild(modal); - document.body.appendChild(overlay); - } - function showTrashModal(note) { return new Promise(function (resolve) { var overlay = el('div', { className: 'notes-modal-overlay' }); @@ -648,18 +739,6 @@ sortMode = sortSelect.value || 'title-asc'; renderList(); }); - createConfirm.addEventListener('click', confirmCreate); - createCancel.addEventListener('click', hideCreate); - renameConfirm.addEventListener('click', confirmRename); - renameCancel.addEventListener('click', hideRename); - createInput.addEventListener('keydown', function (e) { - if (e.key === 'Enter') confirmCreate(); - if (e.key === 'Escape') hideCreate(); - }); - renameInput.addEventListener('keydown', function (e) { - if (e.key === 'Enter') confirmRename(); - if (e.key === 'Escape') hideRename(); - }); // ─── Init ─────────────────────────────────────────────── @@ -672,12 +751,6 @@ createBtn.innerHTML = iconSvg('add') + ' ' + tr('ui.newNote', null, 'New Note'); filterInput.setAttribute('placeholder', tr('ui.filter', null, 'Filter notes')); titleBar.textContent = tr('ui.title', { workspace: workspaceName }, 'Notes in ' + workspaceName); - createInput.setAttribute('placeholder', tr('ui.noteTitle', null, 'Note title')); - renameInput.setAttribute('placeholder', tr('ui.newTitle', null, 'New title')); - createConfirm.textContent = tr('ui.create', null, 'Create'); - createCancel.textContent = tr('ui.cancel', null, 'Cancel'); - renameConfirm.textContent = tr('ui.rename', null, 'Rename'); - renameCancel.textContent = tr('ui.cancel', null, 'Cancel'); renderList(); }); } @@ -696,6 +769,8 @@ containerEl.__notesCleanup = function () { disposed = true; + hideCreate(); + hideRename(); if (typeof localeUnsubscribe === 'function') localeUnsubscribe(); if (typeof fileChangedUnsubscribe === 'function') fileChangedUnsubscribe(); }; diff --git a/plugins/notes/locales/en.json b/plugins/notes/locales/en.json index 451c9f3..1ab623b 100644 --- a/plugins/notes/locales/en.json +++ b/plugins/notes/locales/en.json @@ -1,11 +1,14 @@ { "manifest.name": "Notes", - "manifest.description": "Workspace-scoped notes manager for Markdown files in canonical Notes folders with create, rename, trash, and Workbench integration.", + "manifest.description": "Deal-scoped notes manager for Markdown files in canonical Notes folders with create, rename, trash, and Workbench integration.", "contributions.workspaceItems.verstak.notes.workspace.title": "Notes", "ui.newNote": "New Note", "ui.filter": "Filter notes", "ui.title": "Notes in {workspace}", "ui.noteTitle": "Note title", + "ui.createTitle": "Create note", + "ui.renameTitle": "Rename note", + "ui.titleRequired": "Enter a note title.", "ui.create": "Create", "ui.cancel": "Cancel", "ui.newTitle": "New title", diff --git a/plugins/notes/locales/ru.json b/plugins/notes/locales/ru.json index 267b864..e96450d 100644 --- a/plugins/notes/locales/ru.json +++ b/plugins/notes/locales/ru.json @@ -1,11 +1,14 @@ { "manifest.name": "Заметки", - "manifest.description": "Менеджер Markdown-заметок рабочего пространства с созданием, переименованием и корзиной.", + "manifest.description": "Менеджер Markdown-заметок Дела с созданием, переименованием и корзиной.", "contributions.workspaceItems.verstak.notes.workspace.title": "Заметки", "ui.newNote": "Новая заметка", "ui.filter": "Фильтр заметок", "ui.title": "Заметки · {workspace}", "ui.noteTitle": "Название заметки", + "ui.createTitle": "Создать заметку", + "ui.renameTitle": "Переименовать заметку", + "ui.titleRequired": "Введите название заметки.", "ui.create": "Создать", "ui.cancel": "Отмена", "ui.newTitle": "Новое название", diff --git a/plugins/notes/plugin.json b/plugins/notes/plugin.json index 2a366e6..6bf9e3d 100644 --- a/plugins/notes/plugin.json +++ b/plugins/notes/plugin.json @@ -4,7 +4,7 @@ "name": "Notes", "version": "0.1.0", "apiVersion": "0.1.0", - "description": "Workspace-scoped notes manager for Markdown files in canonical Notes folders with create, rename, trash, and Workbench integration.", + "description": "Deal-scoped notes manager for Markdown files in canonical Notes folders with create, rename, trash, and Workbench integration.", "localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } }, "source": "official", "icon": "edit", diff --git a/scripts/smoke-notes-plugin.js b/scripts/smoke-notes-plugin.js index e92520a..b66531b 100755 --- a/scripts/smoke-notes-plugin.js +++ b/scripts/smoke-notes-plugin.js @@ -264,12 +264,19 @@ async function mountNotes(api) { const createButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-action') === 'create'); if (!createButton) throw new Error('create button not found'); createButton.click(); - const input = walk(container, (node) => node.getAttribute && node.getAttribute('data-notes-create-input') !== undefined); + const createModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-notes-create-modal') !== undefined); + if (!createModal) throw new Error('create modal not found'); + const input = walk(createModal, (node) => node.getAttribute && node.getAttribute('data-notes-create-input') !== undefined); if (!input) throw new Error('create input not found'); - input.value = 'First Note'; - const confirm = walk(container, (node) => node.tagName === 'BUTTON' && node.textContent === 'Create'); + const confirm = walk(createModal, (node) => node.tagName === 'BUTTON' && node.textContent === 'Create'); if (!confirm) throw new Error('create confirm button not found'); confirm.click(); + const createError = walk(createModal, (node) => node.getAttribute && node.getAttribute('data-notes-create-error') !== undefined); + if (!createError || !createError.textContent.includes('Enter a note title') || input.getAttribute('aria-invalid') !== 'true') { + throw new Error('create modal does not show a validation error for an empty title'); + } + input.value = 'First Note'; + input.dispatchEvent('keydown', { key: 'Enter' }); await flush(); const created = createApi.entries.get('Project/Notes/First_Note.md'); @@ -319,21 +326,25 @@ async function mountNotes(api) { const renameButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-note-action') === 'rename'); if (!renameButton) throw new Error('rename note button not found'); renameButton.click(); - const renameInput = walk(container, (node) => node.getAttribute && node.getAttribute('data-notes-rename-input') !== undefined); + const renameModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-notes-rename-modal') !== undefined); + if (!renameModal) throw new Error('rename modal not found'); + const renameInput = walk(renameModal, (node) => node.getAttribute && node.getAttribute('data-notes-rename-input') !== undefined); if (!renameInput) throw new Error('rename input not found'); renameInput.value = 'Second Note'; - const renameConfirm = walk(container, (node) => node.tagName === 'BUTTON' && node.textContent === 'Rename'); - if (!renameConfirm) throw new Error('rename confirm button not found'); - renameConfirm.click(); + renameInput.dispatchEvent('keydown', { key: 'Enter' }); await flush(); - const conflictModal = walk(document.body, (node) => node.className === 'notes-modal-msg'); - if (!conflictModal || !conflictModal.textContent.includes('Project/Notes/Second_Note.md')) { - throw new Error(`rename conflict modal should include existing path, got ${conflictModal && conflictModal.textContent}`); + const renameError = walk(renameModal, (node) => node.getAttribute && node.getAttribute('data-notes-rename-error') !== undefined); + if (!renameError || !renameError.textContent.includes('Project/Notes/Second_Note.md')) { + throw new Error(`rename conflict should stay in the form modal, got ${renameError && renameError.textContent}`); } - const conflictOk = walk(document.body, (node) => node.tagName === 'BUTTON' && node.textContent === 'OK'); - if (!conflictOk) throw new Error('rename conflict OK button not found'); - conflictOk.click(); + if (walk(document.body, (node) => node.className === 'notes-modal-msg')) { + throw new Error('rename conflict opened a second modal instead of reporting in the form'); + } + renameInput.dispatchEvent('keydown', { key: 'Escape' }); await flush(); + if (walk(document.body, (node) => node.getAttribute && node.getAttribute('data-notes-rename-modal') !== undefined)) { + throw new Error('Escape did not close the rename modal'); + } const providerAction = walk(container, (node) => node.getAttribute && node.getAttribute('data-note-contribution-action') === 'provider.note.action'); if (!providerAction) throw new Error('provider note action button not found');