quickSnote
This commit is contained in:
49
AGENTS.md
49
AGENTS.md
@@ -82,3 +82,52 @@ snotes/
|
|||||||
app needs internet access on first editor load (or vendor these locally later).
|
app needs internet access on first editor load (or vendor these locally later).
|
||||||
- `notes.py` opens a new `aiosqlite` connection per operation (same pattern as
|
- `notes.py` opens a new `aiosqlite` connection per operation (same pattern as
|
||||||
`tasky`); fine for a small personal corpus, not for hot paths.
|
`tasky`); fine for a small personal corpus, not for hot paths.
|
||||||
|
|
||||||
|
## Browser extension (browser-extension/)
|
||||||
|
|
||||||
|
A Firefox MV3 "quick capture" extension — mirrors the pve-max extension pattern.
|
||||||
|
No backend changes: it reuses `GET /api/users` and `POST/PUT /api/notes`.
|
||||||
|
|
||||||
|
```
|
||||||
|
browser-extension/
|
||||||
|
manifest.json # MV3, gecko id snotes-capture@schmeeve.com
|
||||||
|
background.js # Relays fetch() to snotes API (credentials:'include')
|
||||||
|
content.js # postMessage ping/pong + settings handshake; get_context msg
|
||||||
|
popup.html/js/css # Capture form: textarea, user select, Save, Page button
|
||||||
|
options.html/js # Server URL config (auto-filled via ping/pong)
|
||||||
|
release-firefox.sh # Package + --sign; copies .xpi to static/
|
||||||
|
icons/ # 48px + 96px PNGs (from assets/snotes-favicon.png)
|
||||||
|
```
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
1. **Content script** runs on all pages. It answers `snotes-ping` → `snotes-pong`
|
||||||
|
and `snotes-settings` → writes `serverUrl` to `browser.storage.sync`, plus a
|
||||||
|
`get_context` message returning the page's selected text, title, and URL.
|
||||||
|
2. **Popup** prefills the note from the active tab's selection, lists users via
|
||||||
|
`list_users`, and Save creates (`POST`) then updates (`PUT`) via `save_note`.
|
||||||
|
A **Page** button inserts `[title](url)`; the note **autosaves 8s** after the
|
||||||
|
last keystroke.
|
||||||
|
3. **Helper page** — the SPA's "Capture" view (`showTools()` in `static/app.js`)
|
||||||
|
pings the extension, offers one-click install from
|
||||||
|
`/static/snotes-capture-firefox.xpi`, and sends `snotes-settings` with
|
||||||
|
`location.origin` to auto-configure.
|
||||||
|
|
||||||
|
### Install / release
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Manual dev install: about:debugging → Load Temporary Add-on → manifest.json
|
||||||
|
bash browser-extension/release-firefox.sh # package .zip only
|
||||||
|
bash browser-extension/release-firefox.sh --sign # sign via AMO (auto-bumps version)
|
||||||
|
```
|
||||||
|
|
||||||
|
The signed `.xpi` is copied to `static/snotes-capture-firefox.xpi` (git-tracked)
|
||||||
|
so the helper page serves it. Signing needs `AMO_JWT_ISSUER` / `AMO_JWT_SECRET`
|
||||||
|
and `npm install -g web-ext`.
|
||||||
|
|
||||||
|
### Gotchas
|
||||||
|
|
||||||
|
- The extension's `fetch` uses `credentials:'include'`, so on remote hosts it
|
||||||
|
only works if the user has already logged into snotes in that browser (the
|
||||||
|
`snotes_auth` cookie is reused). On LAN no auth is needed.
|
||||||
|
- `dist/` is gitignored; committed artifact lives in `static/` instead.
|
||||||
|
|||||||
57
browser-extension/background.js
Normal file
57
browser-extension/background.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
let serverUrl = '';
|
||||||
|
|
||||||
|
browser.storage.sync.get('serverUrl').then(r => {
|
||||||
|
if (r.serverUrl) serverUrl = r.serverUrl.replace(/\/+$/, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
browser.storage.onChanged.addListener(changes => {
|
||||||
|
if (changes.serverUrl) {
|
||||||
|
serverUrl = changes.serverUrl.newValue.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
browser.runtime.onMessage.addListener((msg, sender) => {
|
||||||
|
switch (msg.kind) {
|
||||||
|
case 'list_users':
|
||||||
|
return listUsers();
|
||||||
|
case 'save_note':
|
||||||
|
return saveNote(msg);
|
||||||
|
default:
|
||||||
|
return Promise.reject(new Error('Unknown message kind'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function request(path, opts) {
|
||||||
|
if (!serverUrl) throw new Error('Server URL not configured');
|
||||||
|
opts = opts || {};
|
||||||
|
opts.credentials = 'include';
|
||||||
|
const res = await fetch(serverUrl + path, opts);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.detail || 'HTTP ' + res.status);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listUsers() {
|
||||||
|
const users = await request('/api/users', { headers: { 'Accept': 'application/json' } });
|
||||||
|
return { users: users };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveNote(msg) {
|
||||||
|
const body = { body: msg.body };
|
||||||
|
if (msg.username) body.username = msg.username;
|
||||||
|
|
||||||
|
if (msg.id) {
|
||||||
|
return request('/api/notes/' + msg.id, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return request('/api/notes', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
37
browser-extension/content.js
Normal file
37
browser-extension/content.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ── Page→Extension messaging (used by the snotes helper page) ─────────────
|
||||||
|
window.addEventListener('message', function (event) {
|
||||||
|
if (event.source !== window) return;
|
||||||
|
var msg = event.data;
|
||||||
|
if (!msg || typeof msg.kind !== 'string') return;
|
||||||
|
|
||||||
|
switch (msg.kind) {
|
||||||
|
case 'snotes-ping':
|
||||||
|
window.postMessage({ kind: 'snotes-pong', version: '1.0.0' }, '*');
|
||||||
|
break;
|
||||||
|
case 'snotes-settings': {
|
||||||
|
var data = {};
|
||||||
|
if (msg.serverUrl !== undefined) data.serverUrl = msg.serverUrl;
|
||||||
|
if (Object.keys(data).length > 0) browser.storage.sync.set(data);
|
||||||
|
window.postMessage({ kind: 'snotes-settings-ack', ok: true }, '*');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Extension→Page messaging (capture context for the popup) ──────────────
|
||||||
|
browser.runtime.onMessage.addListener(function (msg) {
|
||||||
|
if (msg && msg.kind === 'get_context') {
|
||||||
|
var selection = '';
|
||||||
|
try { selection = window.getSelection().toString().trim(); } catch (e) {}
|
||||||
|
return Promise.resolve({
|
||||||
|
selection: selection,
|
||||||
|
title: document.title || '',
|
||||||
|
url: window.location.href || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
});
|
||||||
|
})();
|
||||||
BIN
browser-extension/icons/icon-48.png
Normal file
BIN
browser-extension/icons/icon-48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
BIN
browser-extension/icons/icon-96.png
Normal file
BIN
browser-extension/icons/icon-96.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
55
browser-extension/manifest.json
Normal file
55
browser-extension/manifest.json
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "quickSnote",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Quick-capture a note to snotes from any page, with the selected text and page link ready to use",
|
||||||
|
"permissions": [
|
||||||
|
"storage"
|
||||||
|
],
|
||||||
|
"content_security_policy": {
|
||||||
|
"extension_pages": "default-src 'self'; script-src 'self'; style-src 'self'; connect-src http://* https://*"
|
||||||
|
},
|
||||||
|
"host_permissions": [
|
||||||
|
"http://*/*",
|
||||||
|
"https://*/*"
|
||||||
|
],
|
||||||
|
"background": {
|
||||||
|
"scripts": [
|
||||||
|
"background.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options_ui": {
|
||||||
|
"page": "options.html",
|
||||||
|
"open_in_tab": true
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
"*://*/*"
|
||||||
|
],
|
||||||
|
"js": [
|
||||||
|
"content.js"
|
||||||
|
],
|
||||||
|
"all_frames": true,
|
||||||
|
"run_at": "document_idle"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"browser_specific_settings": {
|
||||||
|
"gecko": {
|
||||||
|
"id": "snotes-capture@schmeeve.com",
|
||||||
|
"strict_min_version": "109.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"default_title": "quickSnote",
|
||||||
|
"default_popup": "popup.html",
|
||||||
|
"default_icon": {
|
||||||
|
"48": "icons/icon-48.png",
|
||||||
|
"96": "icons/icon-96.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"icons": {
|
||||||
|
"48": "icons/icon-48.png",
|
||||||
|
"96": "icons/icon-96.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
47
browser-extension/options.html
Normal file
47
browser-extension/options.html
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>quickSnote Options</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: #1e1e2e;
|
||||||
|
color: #cdd6f4;
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 3rem auto;
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.5rem; margin-bottom: 1.5rem; color: #cba6f7; }
|
||||||
|
label { display: block; font-size: 0.875rem; font-weight: 600; margin-bottom: 0.35rem; color: #a6adc8; }
|
||||||
|
input[type="text"] {
|
||||||
|
width: 100%; padding: 0.6rem 0.75rem; border: 1px solid #45475a; border-radius: 6px;
|
||||||
|
background: #313244; color: #cdd6f4; font-size: 0.9rem; box-sizing: border-box;
|
||||||
|
}
|
||||||
|
input[type="text"]:focus { outline: 2px solid #cba6f7; outline-offset: -1px; }
|
||||||
|
.field { margin-bottom: 1.25rem; }
|
||||||
|
.hint { font-size: 0.78rem; color: #6c7086; margin-top: 0.25rem; }
|
||||||
|
#status { margin-top: 1rem; font-size: 0.875rem; color: #a6e3a1; min-height: 1.5rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>quickSnote</h1>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="serverUrl">snotes server URL</label>
|
||||||
|
<input type="text" id="serverUrl" placeholder="http://snotes.lan:8000" />
|
||||||
|
<div class="hint">The address of your snotes app, including port. Auto-filled from the helper page.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<button id="save-close" style="padding:0.6rem 1.2rem;background:#cba6f7;color:#1e1e2e;border:none;border-radius:6px;font-size:0.9rem;font-weight:600;cursor:pointer;margin-top:0.5rem">
|
||||||
|
Save & Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status"></div>
|
||||||
|
|
||||||
|
<script src="options.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
32
browser-extension/options.js
Normal file
32
browser-extension/options.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
const urlInput = document.getElementById('serverUrl');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
|
||||||
|
function showStatus(msg, ok) {
|
||||||
|
statusEl.textContent = msg;
|
||||||
|
statusEl.style.color = ok ? '#a6e3a1' : '#f38ba8';
|
||||||
|
setTimeout(() => { statusEl.textContent = ''; }, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
browser.storage.sync.get('serverUrl').then(r => {
|
||||||
|
if (r.serverUrl) urlInput.value = r.serverUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
let saveTimer = null;
|
||||||
|
|
||||||
|
function scheduleSave() {
|
||||||
|
if (saveTimer) clearTimeout(saveTimer);
|
||||||
|
saveTimer = setTimeout(save, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const url = urlInput.value.trim().replace(/\/+$/, '');
|
||||||
|
await browser.storage.sync.set({ serverUrl: url });
|
||||||
|
showStatus('Saved.', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('save-close').addEventListener('click', async () => {
|
||||||
|
await save();
|
||||||
|
window.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
urlInput.addEventListener('input', scheduleSave);
|
||||||
115
browser-extension/popup.css
Normal file
115
browser-extension/popup.css
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: #1e1e2e;
|
||||||
|
color: #cdd6f4;
|
||||||
|
width: 340px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
border: 1px solid #45475a;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #313244;
|
||||||
|
color: #cdd6f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
height: 150px;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border: 1px solid #45475a;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #181825;
|
||||||
|
color: #cdd6f4;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
resize: vertical;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus {
|
||||||
|
outline: 2px solid #cba6f7;
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid #45475a;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #313244;
|
||||||
|
color: #cdd6f4;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
background: #45475a;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: #cba6f7;
|
||||||
|
color: #1e1e2e;
|
||||||
|
border-color: #cba6f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
min-height: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.ok { color: #a6e3a1; }
|
||||||
|
.status.err { color: #f38ba8; }
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid #313244;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: #6c7086;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a:hover {
|
||||||
|
color: #a6adc8;
|
||||||
|
}
|
||||||
24
browser-extension/popup.html
Normal file
24
browser-extension/popup.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<link rel="stylesheet" href="popup.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<img src="icons/icon-48.png" alt="" class="logo">
|
||||||
|
<select id="user" title="User / space"></select>
|
||||||
|
</div>
|
||||||
|
<textarea id="note" placeholder="Type a note…" spellcheck="false"></textarea>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="capture-page" title="Insert page title + link" type="button">⤴ Page</button>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button id="save" class="primary" type="button">Save</button>
|
||||||
|
</div>
|
||||||
|
<div class="status" id="status"></div>
|
||||||
|
<div class="footer">
|
||||||
|
<a href="#" id="open-options"><span id="server-indicator"></span></a>
|
||||||
|
</div>
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
125
browser-extension/popup.js
Normal file
125
browser-extension/popup.js
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var serverUrl = '';
|
||||||
|
var noteId = null;
|
||||||
|
var lastBody = '';
|
||||||
|
var autosaveTimer = null;
|
||||||
|
|
||||||
|
var noteEl = document.getElementById('note');
|
||||||
|
var userEl = document.getElementById('user');
|
||||||
|
var saveBtn = document.getElementById('save');
|
||||||
|
var captureBtn = document.getElementById('capture-page');
|
||||||
|
var statusEl = document.getElementById('status');
|
||||||
|
var userOptsBtn = document.getElementById('open-options');
|
||||||
|
|
||||||
|
function showStatus(msg, ok) {
|
||||||
|
statusEl.textContent = msg;
|
||||||
|
statusEl.className = 'status ' + (ok ? 'ok' : 'err');
|
||||||
|
if (ok) setTimeout(function () { if (statusEl.textContent === msg) statusEl.textContent = ''; }, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentBody() { return noteEl.value; }
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
try {
|
||||||
|
const r = await browser.runtime.sendMessage({ kind: 'list_users' });
|
||||||
|
const users = (r && r.users) || [];
|
||||||
|
userEl.innerHTML = '';
|
||||||
|
for (const u of users) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = u.name;
|
||||||
|
opt.textContent = u.name;
|
||||||
|
userEl.appendChild(opt);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showStatus('Cannot load users: ' + e.message, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureContext() {
|
||||||
|
try {
|
||||||
|
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
|
||||||
|
if (!tabs.length || tabs[0].id == null) return;
|
||||||
|
const ctx = await browser.tabs.sendMessage(tabs[0].id, { kind: 'get_context' });
|
||||||
|
if (ctx && ctx.selection && !noteEl.value) {
|
||||||
|
noteEl.value = ctx.selection;
|
||||||
|
}
|
||||||
|
if (ctx) {
|
||||||
|
noteEl.dataset.pageTitle = ctx.title || '';
|
||||||
|
noteEl.dataset.pageUrl = ctx.url || '';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// no selection / content script unavailable on this page — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function capturePage() {
|
||||||
|
var title = noteEl.dataset.pageTitle || '';
|
||||||
|
var url = noteEl.dataset.pageUrl || '';
|
||||||
|
if (!title && !url) { showStatus('No page to capture', false); return; }
|
||||||
|
var link = '[' + (title || url) + '](' + (url || '') + ')';
|
||||||
|
var v = noteEl.value;
|
||||||
|
if (v && !/\n$/.test(v)) v += '\n';
|
||||||
|
noteEl.value = v + link + '\n';
|
||||||
|
noteEl.focus();
|
||||||
|
scheduleAutosave();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
var body = currentBody();
|
||||||
|
if (!body.trim()) { showStatus('Nothing to save', false); return; }
|
||||||
|
if (body === lastBody && noteId) return;
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const note = await browser.runtime.sendMessage({
|
||||||
|
kind: 'save_note',
|
||||||
|
id: noteId,
|
||||||
|
body: body,
|
||||||
|
username: userEl.value,
|
||||||
|
});
|
||||||
|
noteId = note.id;
|
||||||
|
lastBody = body;
|
||||||
|
var tags = (note.tags || []).map(function (t) { return '#' + t; }).join(' ');
|
||||||
|
showStatus('Saved' + (tags ? ' · ' + tags : ''), true);
|
||||||
|
} catch (e) {
|
||||||
|
showStatus('Error: ' + e.message, false);
|
||||||
|
} finally {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleAutosave() {
|
||||||
|
if (autosaveTimer) clearTimeout(autosaveTimer);
|
||||||
|
autosaveTimer = setTimeout(function () {
|
||||||
|
autosaveTimer = null;
|
||||||
|
save();
|
||||||
|
}, 8000);
|
||||||
|
}
|
||||||
|
|
||||||
|
noteEl.addEventListener('input', scheduleAutosave);
|
||||||
|
|
||||||
|
saveBtn.addEventListener('click', function () {
|
||||||
|
if (autosaveTimer) { clearTimeout(autosaveTimer); autosaveTimer = null; }
|
||||||
|
save();
|
||||||
|
});
|
||||||
|
|
||||||
|
captureBtn.addEventListener('click', function () {
|
||||||
|
if (autosaveTimer) { clearTimeout(autosaveTimer); autosaveTimer = null; }
|
||||||
|
capturePage();
|
||||||
|
});
|
||||||
|
|
||||||
|
userOptsBtn.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
browser.runtime.openOptionsPage();
|
||||||
|
});
|
||||||
|
|
||||||
|
browser.storage.sync.get('serverUrl').then(function (r) {
|
||||||
|
serverUrl = (r.serverUrl || '').replace(/\/+$/, '');
|
||||||
|
if (!serverUrl) showStatus('Set the server URL in Options', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
loadUsers();
|
||||||
|
captureContext();
|
||||||
|
noteEl.focus();
|
||||||
|
})();
|
||||||
112
browser-extension/release-firefox.sh
Executable file
112
browser-extension/release-firefox.sh
Executable file
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# release-firefox.sh — Package + optionally sign the quickSnote extension.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./release-firefox.sh # Package .zip only
|
||||||
|
# ./release-firefox.sh --sign # Package + sign via web-ext (auto-bumps patch version)
|
||||||
|
# ./release-firefox.sh --sign --no-bump # Sign without bumping version
|
||||||
|
# ./release-firefox.sh --bump # Bump version only, no signing
|
||||||
|
# ./release-firefox.sh --channel=beta # Sign on beta channel
|
||||||
|
#
|
||||||
|
# Prerequisites for --sign:
|
||||||
|
# - npm install -g web-ext
|
||||||
|
# - AMO API keys set in environment:
|
||||||
|
# export AMO_JWT_ISSUER="user:12345678"
|
||||||
|
# export AMO_JWT_SECRET="..."
|
||||||
|
#
|
||||||
|
# The signed .xpi is copied to static/snotes-capture-firefox.xpi so the snotes
|
||||||
|
# helper page can serve it for one-click install.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SRC="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
REPO="$(cd "$SRC"/.. && pwd)"
|
||||||
|
DIST="$REPO/dist"
|
||||||
|
STATIC="$REPO/static"
|
||||||
|
OUT_ZIP="$DIST/snotes-capture-firefox.zip"
|
||||||
|
OUT_XPI="$STATIC/snotes-capture-firefox.xpi"
|
||||||
|
MANIFEST="$SRC/manifest.json"
|
||||||
|
SIGN=false
|
||||||
|
CHANNEL="unlisted"
|
||||||
|
BUMP=""
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--sign) SIGN=true ;;
|
||||||
|
--channel=*) CHANNEL="${arg#*=}" ;;
|
||||||
|
--bump) BUMP=true ;;
|
||||||
|
--no-bump) BUMP=false ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Auto-bump patch version when signing (AMO rejects duplicate versions)
|
||||||
|
if [ "$SIGN" = true ] && [ "$BUMP" != false ]; then
|
||||||
|
BUMP=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$BUMP" = true ]; then
|
||||||
|
CURRENT_VERSION="$(python3 -c "import json,sys; print(json.load(open('$MANIFEST'))['version'])")"
|
||||||
|
NEW_VERSION="$(python3 -c "
|
||||||
|
v = '$CURRENT_VERSION'.split('.')
|
||||||
|
v[-1] = str(int(v[-1]) + 1)
|
||||||
|
print('.'.join(v))
|
||||||
|
")"
|
||||||
|
python3 -c "
|
||||||
|
import json
|
||||||
|
with open('$MANIFEST') as f:
|
||||||
|
m = json.load(f)
|
||||||
|
m['version'] = '$NEW_VERSION'
|
||||||
|
with open('$MANIFEST', 'w') as f:
|
||||||
|
json.dump(m, f, indent=2)
|
||||||
|
f.write('\n')
|
||||||
|
"
|
||||||
|
echo "==> Bumped version: $CURRENT_VERSION -> $NEW_VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$OUT_ZIP"
|
||||||
|
mkdir -p "$DIST"
|
||||||
|
|
||||||
|
echo "==> Packaging Firefox extension..."
|
||||||
|
cd "$SRC"
|
||||||
|
zip -q -r "$OUT_ZIP" \
|
||||||
|
manifest.json \
|
||||||
|
background.js \
|
||||||
|
content.js \
|
||||||
|
popup.html \
|
||||||
|
popup.js \
|
||||||
|
popup.css \
|
||||||
|
options.html \
|
||||||
|
options.js \
|
||||||
|
icons/icon-48.png \
|
||||||
|
icons/icon-96.png
|
||||||
|
|
||||||
|
echo " Created: $OUT_ZIP"
|
||||||
|
echo " Size: $(du -h "$OUT_ZIP" | cut -f1)"
|
||||||
|
|
||||||
|
if [ "$SIGN" = true ]; then
|
||||||
|
if ! command -v web-ext &>/dev/null; then
|
||||||
|
echo "!! web-ext not found. Install with: npm install -g web-ext"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "${AMO_JWT_ISSUER:-}" ] || [ -z "${AMO_JWT_SECRET:-}" ]; then
|
||||||
|
echo "!! AMO_JWT_ISSUER and AMO_JWT_SECRET must be set for signing."
|
||||||
|
echo " Get keys at: https://addons.mozilla.org/en-US/developers/addon/api/key/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "==> Signing via addons.mozilla.org (channel: $CHANNEL)..."
|
||||||
|
web-ext sign \
|
||||||
|
--source-dir "$SRC" \
|
||||||
|
--artifacts-dir "$DIST" \
|
||||||
|
--api-key "$AMO_JWT_ISSUER" \
|
||||||
|
--api-secret "$AMO_JWT_SECRET" \
|
||||||
|
--channel "$CHANNEL" \
|
||||||
|
--ignore-files "*.sh"
|
||||||
|
echo " Signed .xpi in: $DIST/"
|
||||||
|
SIGNED_XPI="$(ls -t "$DIST"/*.xpi 2>/dev/null | head -1)"
|
||||||
|
if [ -n "$SIGNED_XPI" ]; then
|
||||||
|
cp "$SIGNED_XPI" "$OUT_XPI"
|
||||||
|
echo " Copied to: $OUT_XPI"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Done."
|
||||||
@@ -310,6 +310,22 @@ body {
|
|||||||
.editor a { color: var(--secondary); text-decoration: underline; cursor: pointer; }
|
.editor a { color: var(--secondary); text-decoration: underline; cursor: pointer; }
|
||||||
|
|
||||||
.status { margin-top: 10px; color: var(--muted); font-size: 13px; min-height: 20px; }
|
.status { margin-top: 10px; color: var(--muted); font-size: 13px; min-height: 20px; }
|
||||||
|
.editor-hint { margin-top: 12px; padding: 10px 14px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); font-size: 13px; color: var(--muted); line-height: 1.6; }
|
||||||
|
.editor-hint p { margin: 0.25em 0; }
|
||||||
|
.editor-hint code { background: color-mix(in srgb, var(--border) 55%, transparent); padding: 1px 4px; border-radius: 4px; font-size: 0.9em; }
|
||||||
|
|
||||||
|
.help-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px 24px; line-height: 1.6; }
|
||||||
|
.help-card h1 { margin-top: 0; }
|
||||||
|
.help-card h2 { margin-top: 1.5em; margin-bottom: 0.4em; font-size: 18px; color: var(--secondary); }
|
||||||
|
.help-card p { margin: 0.4em 0; color: var(--text); }
|
||||||
|
.help-card code { background: color-mix(in srgb, var(--border) 55%, transparent); padding: 1px 4px; border-radius: 4px; font-size: 0.9em; }
|
||||||
|
.help-table { border-collapse: collapse; width: 100%; max-width: 480px; margin-top: 8px; }
|
||||||
|
.help-table th, .help-table td { text-align: left; padding: 6px 12px; border: 1px solid var(--border); font-size: 14px; }
|
||||||
|
.help-table th { background: var(--bg); color: var(--muted); font-weight: 600; }
|
||||||
|
.help-table code { white-space: nowrap; }
|
||||||
|
|
||||||
|
.tools-row { display: flex; align-items: center; gap: 12px; margin-top: 8px; flex-wrap: wrap; }
|
||||||
|
.tools-row .muted { min-height: 1.2em; }
|
||||||
|
|
||||||
.login-card {
|
.login-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
176
static/app.js
176
static/app.js
@@ -194,11 +194,150 @@ function header() {
|
|||||||
el("div", { class: "topbar-right" }, [
|
el("div", { class: "topbar-right" }, [
|
||||||
el("select", { id: "user-select", class: "user-select", onchange: onUserChange }),
|
el("select", { id: "user-select", class: "user-select", onchange: onUserChange }),
|
||||||
el("button", { class: "btn primary", text: "+ New note", onclick: () => openNote(null) }),
|
el("button", { class: "btn primary", text: "+ New note", onclick: () => openNote(null) }),
|
||||||
|
el("button", { class: "btn ghost", text: "Help", onclick: showHelp }),
|
||||||
|
el("button", { class: "btn ghost", text: "Capture", onclick: showTools }),
|
||||||
themeSelector(),
|
themeSelector(),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showHelp() {
|
||||||
|
state.view = "help";
|
||||||
|
root.innerHTML = "";
|
||||||
|
state.editor?.destroy(); state.editor = null;
|
||||||
|
|
||||||
|
const back = el("button", { class: "btn ghost", text: "← Back", onclick: showHome });
|
||||||
|
const wrap = el("div", { class: "editor-wrap" }, [
|
||||||
|
el("div", { class: "editor-topbar" }, [back]),
|
||||||
|
el("div", { class: "help-card" }, [
|
||||||
|
el("h1", { text: "Help" }),
|
||||||
|
|
||||||
|
el("h2", { text: "Keyboard shortcuts" }),
|
||||||
|
el("table", { class: "help-table" }, [
|
||||||
|
el("tr", {}, [el("th", { text: "Shortcut" }), el("th", { text: "Action" })]),
|
||||||
|
helpRow("n", "New note (from the home page)"),
|
||||||
|
helpRow("Ctrl/Cmd + S", "Save the current note"),
|
||||||
|
helpRow("Ctrl/Cmd + Shift + S", "Save and open a new note"),
|
||||||
|
]),
|
||||||
|
|
||||||
|
el("h2", { text: "Tags" }),
|
||||||
|
el("p", { html: 'Type <code>#tag</code> anywhere in your note to tag it. Tags are normalized to lowercase and shown in the sidebar, search, and note list.' }),
|
||||||
|
|
||||||
|
el("h2", { text: "Lists" }),
|
||||||
|
el("p", { html: 'Type <code>- </code> at the start of a line for a bullet list, or <code>1. </code> for a numbered list. You can also use the <b>• List</b> and <b>1. List</b> toolbar buttons.' }),
|
||||||
|
|
||||||
|
el("h2", { text: "Editing" }),
|
||||||
|
el("p", { html: 'Use the toolbar to format text with <b>bold</b>, <i>italic</i>, headings, quotes, code, and horizontal rules. The editor also accepts standard Markdown shortcuts as you type.' }),
|
||||||
|
|
||||||
|
el("h2", { text: "Notes & sharing" }),
|
||||||
|
el("p", { html: 'Notes are plain Markdown files. A note lives in a <i>space</i> (a user), chosen with the dropdown at the top of the editor. Changing the space moves the note to that user.' }),
|
||||||
|
el("p", { html: 'Use <b>Attach</b> to upload a file and insert a link to it. <b>History</b> shows every change, and <b>Delete</b> soft-deletes a note.' }),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
root.appendChild(wrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
function helpRow(keys, action) {
|
||||||
|
return el("tr", {}, [el("td", { html: "<code>" + keys + "</code>" }), el("td", { text: action })]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTools() {
|
||||||
|
state.view = "tools";
|
||||||
|
root.innerHTML = "";
|
||||||
|
state.editor?.destroy(); state.editor = null;
|
||||||
|
|
||||||
|
const back = el("button", { class: "btn ghost", text: "← Back", onclick: showHome });
|
||||||
|
|
||||||
|
const extStatus = el("div", { class: "muted", text: "" });
|
||||||
|
const installBtn = el("button", { class: "btn primary", text: "Install Browser Extension" });
|
||||||
|
const qsStatus = el("span", { class: "muted", text: "" });
|
||||||
|
const qsApply = el("button", { class: "btn", text: "Apply to Extension" });
|
||||||
|
|
||||||
|
const card = el("div", { class: "help-card" }, [
|
||||||
|
el("h1", { text: "Quick capture" }),
|
||||||
|
el("p", { html: 'The <b>quickSnote</b> browser extension lets you jot a note from any page. It prefills the note with the text you\'ve selected, and a <b>Page</b> button inserts the current page\'s title and link.' }),
|
||||||
|
|
||||||
|
el("h2", { text: "Firefox" }),
|
||||||
|
el("div", { class: "tools-row" }, [installBtn, extStatus]),
|
||||||
|
|
||||||
|
el("h2", { text: "Quick settings" }),
|
||||||
|
el("p", { text: "Point the extension at this snotes server (" + window.location.origin + ")." }),
|
||||||
|
el("div", { class: "tools-row" }, [qsApply, qsStatus]),
|
||||||
|
|
||||||
|
el("h2", { text: "Manual install" }),
|
||||||
|
el("p", { text: "Open about:debugging#/runtime/this-firefox → Load Temporary Add-on… → select browser-extension/manifest.json." }),
|
||||||
|
|
||||||
|
el("h2", { text: "Using it" }),
|
||||||
|
el("p", { html: 'Open the toolbar icon, pick a <b>user</b>, type, and <b>Save</b> — or just stop typing for 8s and it autosaves.' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const wrap = el("div", { class: "editor-wrap" }, [
|
||||||
|
el("div", { class: "editor-topbar" }, [back]),
|
||||||
|
card,
|
||||||
|
]);
|
||||||
|
root.appendChild(wrap);
|
||||||
|
|
||||||
|
// ── Extension detection (ping/pong) ──────────────────────────────────────
|
||||||
|
const xpiUrl = "/static/snotes-capture-firefox.xpi";
|
||||||
|
let detected = false;
|
||||||
|
let pingCount = 0;
|
||||||
|
let pingInterval = null;
|
||||||
|
let slowInterval = null;
|
||||||
|
|
||||||
|
function stopPing() {
|
||||||
|
if (pingInterval) clearInterval(pingInterval), pingInterval = null;
|
||||||
|
if (slowInterval) clearInterval(slowInterval), slowInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDetected() {
|
||||||
|
detected = true;
|
||||||
|
stopPing();
|
||||||
|
extStatus.textContent = "✓ Extension detected";
|
||||||
|
installBtn.textContent = "Extension installed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function doPing() {
|
||||||
|
window.postMessage({ kind: "snotes-ping" }, "*");
|
||||||
|
pingCount++;
|
||||||
|
if (pingCount >= 8) {
|
||||||
|
if (pingInterval) clearInterval(pingInterval), pingInterval = null;
|
||||||
|
if (!detected && !slowInterval) {
|
||||||
|
extStatus.textContent = "Extension not detected";
|
||||||
|
slowInterval = setInterval(() => window.postMessage({ kind: "snotes-ping" }, "*"), 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
installBtn.addEventListener("click", () => {
|
||||||
|
if (detected) return;
|
||||||
|
if (window.InstallTrigger) InstallTrigger.install({ "quickSnote": xpiUrl });
|
||||||
|
else window.location.href = xpiUrl;
|
||||||
|
extStatus.textContent = "Installing… check toolbar for prompt";
|
||||||
|
stopPing();
|
||||||
|
pingCount = 0;
|
||||||
|
pingInterval = setInterval(doPing, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
qsApply.addEventListener("click", () => {
|
||||||
|
window.postMessage({ kind: "snotes-settings", serverUrl: window.location.origin }, "*");
|
||||||
|
qsStatus.textContent = "Applying…";
|
||||||
|
setTimeout(() => { if (qsStatus.textContent === "Applying…") qsStatus.textContent = "No response — is the extension installed?"; }, 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("message", (event) => {
|
||||||
|
if (event.source !== window) return;
|
||||||
|
const msg = event.data;
|
||||||
|
if (msg && msg.kind === "snotes-pong") onDetected();
|
||||||
|
if (msg && msg.kind === "snotes-settings-ack") {
|
||||||
|
qsStatus.textContent = "✓ Applied";
|
||||||
|
setTimeout(() => { if (qsStatus.textContent === "✓ Applied") qsStatus.textContent = ""; }, 3000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pingInterval = setInterval(doPing, 500);
|
||||||
|
doPing();
|
||||||
|
}
|
||||||
|
|
||||||
async function showHome() {
|
async function showHome() {
|
||||||
state.view = "home";
|
state.view = "home";
|
||||||
root.innerHTML = "";
|
root.innerHTML = "";
|
||||||
@@ -341,6 +480,11 @@ function renderEditor(body, owner) {
|
|||||||
]);
|
]);
|
||||||
const editorArea = el("div", { class: "editor" });
|
const editorArea = el("div", { class: "editor" });
|
||||||
const status = el("div", { class: "status", text: "" });
|
const status = el("div", { class: "status", text: "" });
|
||||||
|
const hint = el("div", { class: "editor-hint" }, [
|
||||||
|
el("p", { html: 'Tags: type <code>#tag</code> anywhere in your note to add a tag.' }),
|
||||||
|
el("p", { html: 'Lists: type <code>- </code> for a bullet list or <code>1. </code> for a numbered list, or use the toolbar buttons.' }),
|
||||||
|
el("p", { html: 'Shortcuts: <code>Ctrl/Cmd+S</code> save · <code>Ctrl/Cmd+Shift+S</code> save & new note.' }),
|
||||||
|
]);
|
||||||
|
|
||||||
const fileInput = el("input", { type: "file", style: "display:none" });
|
const fileInput = el("input", { type: "file", style: "display:none" });
|
||||||
fileInput.addEventListener("change", () => uploadAttachment(fileInput));
|
fileInput.addEventListener("change", () => uploadAttachment(fileInput));
|
||||||
@@ -350,6 +494,7 @@ function renderEditor(body, owner) {
|
|||||||
el("div", { class: "editor-topbar" }, [back, spaceSel, el("div", { class: "spacer" }), attach, hist, del, save]),
|
el("div", { class: "editor-topbar" }, [back, spaceSel, el("div", { class: "spacer" }), attach, hist, del, save]),
|
||||||
toolbar,
|
toolbar,
|
||||||
editorArea,
|
editorArea,
|
||||||
|
hint,
|
||||||
status,
|
status,
|
||||||
]);
|
]);
|
||||||
wrap.appendChild(fileInput);
|
wrap.appendChild(fileInput);
|
||||||
@@ -387,7 +532,7 @@ function runToolbar(action) {
|
|||||||
cmds[action]?.();
|
cmds[action]?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveNote() {
|
async function persistNote() {
|
||||||
const html = state.editor.getHTML();
|
const html = state.editor.getHTML();
|
||||||
const body = turndownService.turndown(html);
|
const body = turndownService.turndown(html);
|
||||||
const status = document.querySelector(".status");
|
const status = document.querySelector(".status");
|
||||||
@@ -404,6 +549,17 @@ async function saveNote() {
|
|||||||
} catch (e) { status.textContent = "Error: " + e.message; }
|
} catch (e) { status.textContent = "Error: " + e.message; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveNote() {
|
||||||
|
const isNew = !state.noteId;
|
||||||
|
await persistNote();
|
||||||
|
if (isNew) showHome();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAndNewNote() {
|
||||||
|
await persistNote();
|
||||||
|
openNote(null);
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteNote() {
|
async function deleteNote() {
|
||||||
if (!confirm("Delete this note?")) return;
|
if (!confirm("Delete this note?")) return;
|
||||||
await api("DELETE", "/api/notes/" + state.noteId);
|
await api("DELETE", "/api/notes/" + state.noteId);
|
||||||
@@ -451,4 +607,22 @@ async function showHistory() {
|
|||||||
root.appendChild(overlay);
|
root.appendChild(overlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
const mod = e.ctrlKey || e.metaKey;
|
||||||
|
if (state.view === "home") {
|
||||||
|
if (e.key === "n" && !mod) {
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA" || active.isContentEditable)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
openNote(null);
|
||||||
|
}
|
||||||
|
} else if (state.view === "editor") {
|
||||||
|
if (mod && e.key.toLowerCase() === "s") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.shiftKey) saveAndNewNote();
|
||||||
|
else saveNote();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
boot();
|
boot();
|
||||||
|
|||||||
Reference in New Issue
Block a user