Right-click me
Open the context menu to rename, recolour, pin, duplicate or delete this note.
A custom right-click menu on notes: rename, colour, pin, duplicate and delete, all saved in SQL Server.
Right-click a note (or long-press on a touch screen, or focus it and press Shift+F10). Right-click the empty area for more.
Open the context menu to rename, recolour, pin, duplicate or delete this note.
Focus a note with Tab and press Shift + F10 or the Menu key.
On a phone, press and hold a note to open the menu.
Every change is an UPDATE on the note table.
Coffee beans, oat milk, batteries.
Command palette, undo toast, sticky table header.
@PatchMapping("/notes/{id}")
public Note patchNote(@PathVariable int id, @Valid @RequestBody NotePatch patch) {
return notes.patch(id, patch.title(), patch.color(), patch.pinned());
}
@PostMapping("/notes/{id}/duplicate")
@ResponseStatus(HttpStatus.CREATED)
public Note duplicateNote(@PathVariable int id) {
return notes.duplicate(id);
}
@DeleteMapping("/notes/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteNote(@PathVariable int id) {
notes.delete(id);
}
@Transactional
public Note patch(int id, String title, String color, Boolean pinned) {
Note note = get(id);
if (title != null) note.setTitle(title.strip());
if (color != null) note.setColor(color);
if (pinned != null) note.setPinned(pinned);
return note; // dirty checking writes the UPDATE when the transaction commits
}
@Transactional
public Note duplicate(int id) {
Note source = get(id);
String title = source.getTitle().length() > 93 ? source.getTitle().substring(0, 93) : source.getTitle();
return repository.save(new Note(title + " (copy)", source.getBody(), source.getColor(), false));
}
@Transactional
public void delete(int id) {
repository.delete(get(id));
}
CREATE TABLE note (
id INT IDENTITY(1,1) NOT NULL CONSTRAINT pk_note PRIMARY KEY,
title NVARCHAR(100) NOT NULL,
body NVARCHAR(1000) NOT NULL CONSTRAINT df_note_body DEFAULT N'',
color NVARCHAR(10) NOT NULL CONSTRAINT df_note_color DEFAULT N'yellow',
pinned BIT NOT NULL CONSTRAINT df_note_pinned DEFAULT 0,
created_at DATETIME2(0) NOT NULL CONSTRAINT df_note_created DEFAULT SYSUTCDATETIME(),
updated_at DATETIME2(0) NOT NULL CONSTRAINT df_note_updated DEFAULT SYSUTCDATETIME(),
CONSTRAINT ck_note_color CHECK (color IN (N'yellow', N'green', N'blue', N'pink', N'purple', N'gray'))
);
.notes { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 13rem), 1fr)); gap: .9rem; }
.note {
--nc: #fef3c7; --nt: #713f12; background: var(--nc); color: var(--nt); border-radius: var(--radius); padding: .9rem 1rem; min-height: 8.5rem;
display: flex; flex-direction: column; gap: .35rem; position: relative; box-shadow: 0 1px 2px rgb(0 0 0 / .08), 0 8px 18px -12px rgb(0 0 0 / .35);
cursor: context-menu; user-select: none; -webkit-touch-callout: none; transition: transform .15s;
}
.note:hover { transform: rotate(-.6deg) translateY(-2px); }
.note.green { --nc: #dcfce7; --nt: #14532d; } .note.blue { --nc: #dbeafe; --nt: #1e3a8a; } .note.pink { --nc: #fce7f3; --nt: #831843; }
.note.purple { --nc: #ede9fe; --nt: #4c1d95; } .note.gray { --nc: #e5e7eb; --nt: #1f2937; }
html[data-theme="dark"] .note { --nc: #4a3a12; --nt: #fde68a; }
html[data-theme="dark"] .note.green { --nc: #16361f; --nt: #bbf7d0; } html[data-theme="dark"] .note.blue { --nc: #172a4f; --nt: #bfdbfe; }
html[data-theme="dark"] .note.pink { --nc: #4a1a35; --nt: #fbcfe8; } html[data-theme="dark"] .note.purple { --nc: #2e2257; --nt: #ddd6fe; }
html[data-theme="dark"] .note.gray { --nc: #2a3242; --nt: #e5e7eb; }
.note h4 { margin: 0; font-size: .95rem; padding-right: 1.4rem; word-break: break-word; }
.note p { margin: 0; font-size: .82rem; opacity: .85; word-break: break-word; }
.note .pin { position: absolute; top: .7rem; right: .7rem; opacity: .8; }
.note:focus-visible { outline: 3px solid var(--primary); outline-offset: 3px; }
.note.flash { animation: flash .6s; }
@keyframes flash { 50% { transform: scale(1.04); } }
.context-menu {
position: fixed; z-index: 500; min-width: 12.5rem; padding: .35rem; border-radius: .75rem; background: var(--surface); border: 1px solid var(--border);
box-shadow: var(--shadow-lg); animation: menu-in .12s ease; font-size: .86rem;
}
.context-menu [role="menuitem"] { display: flex; align-items: center; gap: .65rem; width: 100%; padding: .48rem .65rem; border: 0; border-radius: .5rem; background: none; color: var(--text); font: inherit; cursor: pointer; text-align: left; }
.context-menu [role="menuitem"]:hover, .context-menu [role="menuitem"]:focus { background: var(--primary-soft); color: var(--primary-text); outline: none; }
.context-menu [role="menuitem"].danger { color: var(--danger); }
.context-menu [role="menuitem"].danger:hover, .context-menu [role="menuitem"].danger:focus { background: var(--danger-soft); }
.context-menu [role="menuitem"] kbd { margin-left: auto; font-size: .65rem; }
.context-menu hr { border: 0; height: 1px; background: var(--border); margin: .3rem .2rem; }
.context-menu .menu-colors { display: flex; gap: .4rem; padding: .4rem .65rem .5rem; }
.context-menu .menu-colors button { width: 1.35rem; height: 1.35rem; border-radius: 50%; border: 2px solid transparent; cursor: pointer; background: var(--nc); padding: 0; }
.context-menu .menu-colors button:focus, .context-menu .menu-colors button[aria-pressed="true"] { border-color: var(--text); outline: none; }
.context-menu .menu-label { font-size: .7rem; color: var(--text-3); padding: .35rem .65rem 0; text-transform: uppercase; letter-spacing: .05em; font-weight: 700; }
@keyframes menu-in { from { opacity: 0; transform: scale(.96); } }
const contextMenu = (() => {
let menu = null, restoreFocus = null;
function close() {
if (!menu) return;
menu.remove(); menu = null;
document.removeEventListener('pointerdown', outside, true);
restoreFocus?.focus?.({ preventScroll: true });
}
function outside(e) { if (menu && !menu.contains(e.target)) close(); }
function open(x, y, items, focusBack = document.activeElement) {
close();
restoreFocus = focusBack;
menu = document.createElement('div');
menu.className = 'context-menu';
menu.setAttribute('role', 'menu');
for (const item of items) {
if (item === '-') { menu.appendChild(document.createElement('hr')); continue; }
if (item.render) { menu.appendChild(item.render(close)); continue; }
const b = document.createElement('button');
b.type = 'button';
b.setAttribute('role', 'menuitem');
b.className = item.danger ? 'danger' : '';
b.innerHTML = `${icon(item.icon || 'dot')}<span>${esc(item.label)}</span>${item.kbd ? `<kbd>${esc(item.kbd)}</kbd>` : ''}`;
b.addEventListener('click', () => { close(); item.action(); });
menu.appendChild(b);
}
document.body.appendChild(menu);
const w = menu.offsetWidth, h = menu.offsetHeight;
menu.style.left = Math.min(x, innerWidth - w - 8) + 'px'; // flip at the right edge
menu.style.top = Math.min(y, innerHeight - h - 8) + 'px'; // and at the bottom edge
const focusables = () => $$('[role="menuitem"], .menu-colors button', menu);
focusables()[0]?.focus();
menu.addEventListener('keydown', (e) => {
const list = focusables(), i = list.indexOf(document.activeElement);
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
list[(i + (e.key === 'ArrowDown' ? 1 : -1) + list.length) % list.length].focus();
} else if (e.key === 'Escape' || e.key === 'Tab') { e.preventDefault(); close(); }
});
setTimeout(() => document.addEventListener('pointerdown', outside, true));
addEventListener('scroll', close, { once: true, passive: true });
addEventListener('resize', close, { once: true });
}
return { open, close };
})();
function menuFor(note) {
const id = note.dataset.id, title = $('h4', note).textContent, pinned = note.dataset.pinned === 'true';
return [
{ label: 'Rename…', icon: 'edit', kbd: 'F2', action: async () => {
const name = await UI.prompt({ title: 'Rename note', label: 'Title', value: title, validate: (v) => (!v ? 'Title is required.' : v.length > 100 ? 'At most 100 characters.' : '') });
if (name === null) return;
await api('/api/notes/' + id, { method: 'PATCH', body: { title: name } });
log(`<b>PATCH</b> note ${id} title = "${esc(name)}"`); reload(id);
} },
{ render: (close) => {
const wrap = document.createElement('div');
wrap.innerHTML = '<div class="menu-label">Colour</div><div class="menu-colors"></div>';
colors.forEach((c) => {
const b = document.createElement('button');
b.type = 'button'; b.className = 'note ' + c; b.setAttribute('aria-label', c); b.setAttribute('aria-pressed', String(note.dataset.color === c));
b.style.cssText = 'min-height:0;padding:0;box-shadow:none';
b.addEventListener('click', async () => { close(); await api('/api/notes/' + id, { method: 'PATCH', body: { color: c } }); log(`<b>PATCH</b> note ${id} color = ${c}`); reload(id); });
$('.menu-colors', wrap).appendChild(b);
});
return wrap;
} },
'-',
{ label: pinned ? 'Unpin' : 'Pin to top', icon: 'pin', action: async () => { await api('/api/notes/' + id, { method: 'PATCH', body: { pinned: !pinned } }); log(`<b>PATCH</b> note ${id} pinned = ${!pinned}`); reload(id); } },
{ label: 'Duplicate', icon: 'copy', kbd: 'Ctrl D', action: async () => { const n = await api(`/api/notes/${id}/duplicate`, { method: 'POST' }); log(`<b>POST</b> duplicate → new note ${n.id}`); reload(n.id); } },
'-',
{ label: 'Delete…', icon: 'trash', danger: true, kbd: 'Del', action: async () => {
if (!(await UI.confirm({ title: 'Delete this note?', message: `"${title}" will be deleted.` }))) return;
await api('/api/notes/' + id, { method: 'DELETE' }); log(`<b>DELETE</b> note ${id}`); reload();
} }
];
}
box.addEventListener('contextmenu', (e) => {
e.preventDefault();
const note = e.target.closest('.note');
UI.contextMenu.open(e.clientX, e.clientY, note ? menuFor(note) : areaMenu(), note || box);
});
let pressTimer = null;
box.addEventListener('pointerdown', (e) => {
if (e.pointerType !== 'touch') return;
const note = e.target.closest('.note');
pressTimer = setTimeout(() => { if (note) UI.contextMenu.open(e.clientX, e.clientY, menuFor(note), note); }, 550);
});
['pointerup', 'pointermove', 'pointercancel'].forEach((t) => box.addEventListener(t, () => clearTimeout(pressTimer)));
box.addEventListener('keydown', (e) => {
const note = e.target.closest('.note');
if (!note) return;
if ((e.shiftKey && e.key === 'F10') || e.key === 'ContextMenu') {
e.preventDefault();
const r = note.getBoundingClientRect();
UI.contextMenu.open(r.left + 20, r.top + 30, menuFor(note), note);
} else if (e.key === 'F2') { e.preventDefault(); menuFor(note)[0].action(); }
else if (e.key === 'Delete') { e.preventDefault(); menuFor(note)[6].action(); }
else if (e.ctrlKey && e.key.toLowerCase() === 'd') { e.preventDefault(); menuFor(note)[4].action(); }
});