diff --git a/resources/js/app.js b/resources/js/app.js
index 28dbae7..f2ed386 100644
--- a/resources/js/app.js
+++ b/resources/js/app.js
@@ -1,37 +1,472 @@
// Storage paths
- const DATA_FILE = 'jottings.json';
- const METADATA_FILE = 'jottings.meta.json';
+ const DATA_FILE = 'jottings.json';
+ const METADATA_FILE = 'jottings.meta.json';
+ const PERSONAL_DIC_FILE = 'personal.dic';
// Global state
- let currentDocId = null;
+ let currentDocId = null;
let currentDocName = null;
- let autoSaveTimer = null;
+ let autoSaveTimer = null;
+
+ // Spellcheck state
+ let spell = null;
+ let spellReady = false;
+ let spellDebounce = null;
+ let menuTargetWord = null;
+ let menuTargetRange = null;
+ let ignoreChars = '';
+
+ // DOM references
+ const editor = document.getElementById('editor');
+ const canvas = document.getElementById('spellCanvas');
+ const ctx = canvas.getContext('2d');
+ const spellMenu = document.getElementById('spellMenu');
+ const spellMenuItems = document.getElementById('spellMenuItems');
+ const spellMenuAdd = document.getElementById('spellMenuAdd');
+
+ // Editor text helpers
+ function getEditorText() {
+ return editor.innerText.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
+ }
+
+ function setEditorText(text) {
+ editor.innerText = text;
+ clearTimeout(spellDebounce);
+ runSpellcheck();
+ }
+
+ // Canvas sizing
+ function resizeCanvas() {
+ const editorRect = editor.getBoundingClientRect();
+ const style = window.getComputedStyle(editor);
+ const padLeft = parseFloat(style.paddingLeft) || 0;
+ const padRight = parseFloat(style.paddingRight) || 0;
+ const padTop = parseFloat(style.paddingTop) || 0;
+ const padBottom = parseFloat(style.paddingBottom) || 0;
+
+ const contentWidth = editorRect.width - padLeft - padRight;
+ const contentHeight = editorRect.height - padTop - padBottom;
+ const dpr = window.devicePixelRatio || 1;
+
+ canvas.width = contentWidth * dpr;
+ canvas.height = contentHeight * dpr;
+ canvas.style.width = contentWidth + 'px';
+ canvas.style.height = contentHeight + 'px';
+ // Position canvas at the editor's padding start
+ canvas.style.left = padLeft + 'px';
+ canvas.style.top = padTop + 'px';
+
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.scale(dpr, dpr);
+
+ // Clear on resize to avoid ghosting
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ }
+
+ // ===============
+ // Spellcheck core
+ // ===============
+ async function initSpellcheck() {
+ try {
+ const [affRes, dicRes] = await Promise.all([
+ fetch('dict/en_GB.aff'),
+ fetch('dict/en_GB.dic')
+ ]);
+ if (!affRes.ok) throw new Error(`Could not load en_GB.aff (${affRes.status})`);
+ if (!dicRes.ok) throw new Error(`Could not load en_GB.dic (${dicRes.status})`);
+
+ const aff = await affRes.text();
+ const dic = await dicRes.text();
+ spell = nspell({ aff, dic });
+
+ const ignoreMatch = aff.match(/^IGNORE\s+(\S+)/m);
+ ignoreChars = ignoreMatch ? ignoreMatch[1] : '';
+
+ await loadPersonalDic();
+ spellReady = true;
+ runSpellcheck();
+ } catch (err) {
+ console.warn('Spellcheck unavailable:', err.message);
+ spellReady = false;
+ }
+ }
+
+ async function loadPersonalDic() {
+ try {
+ const path = await getDataPath(PERSONAL_DIC_FILE);
+ const content = await Neutralino.filesystem.readFile(path);
+ const words = content.split('\n').map(w => w.trim()).filter(Boolean);
+ words.forEach(w => spell.add(w));
+ } catch (e) {
+ if (e.code !== 'NE_FS_NOPATHE' && e.code !== 'NE_FS_FILRDER') {
+ console.warn('Could not load personal dictionary:', e.message);
+ }
+ }
+ }
+
+ async function addToPersonalDic(word) {
+ if (!spell || !word) return;
+ spell.add(word);
+ try {
+ const path = await getDataPath(PERSONAL_DIC_FILE);
+ let existing = '';
+ try { existing = await Neutralino.filesystem.readFile(path); } catch(e) {}
+ const words = existing.split('\n').map(w => w.trim()).filter(Boolean);
+ if (!words.includes(word)) {
+ words.push(word);
+ await Neutralino.filesystem.writeFile(path, words.join('\n') + '\n');
+ }
+ } catch (e) {
+ console.warn('Could not save personal dictionary:', e.message);
+ }
+ runSpellcheck();
+ }
+
+ function stripIgnored(word) {
+ if (!ignoreChars) return word;
+ let out = '';
+ for (const ch of word) {
+ if (ignoreChars.indexOf(ch) === -1) out += ch;
+ }
+ return out;
+ }
+
+ function isIgnorableNumericPossessive(word) {
+ const m = word.match(/^(.*)['\u2019]s$/i);
+ if (!m) return false;
+ const base = m[1];
+ return base.length > 0 && stripIgnored(base) === '';
+ }
+
+ function isCorrect(word) {
+ const stripped = stripIgnored(word);
+ if (stripped === '') return true;
+ if (isIgnorableNumericPossessive(word)) return true;
+ return spell.correct(stripped);
+ }
+
+ function getSuggestions(word) {
+ if (isIgnorableNumericPossessive(word)) return [];
+ const stripped = stripIgnored(word);
+ if (stripped === '') return [];
+ return spell.suggest(stripped);
+ }
+
+ function getCheckableSpan(rawToken) {
+ let start = 0, end = rawToken.length;
+ if (end - start > 1 && /^['\u2019]/.test(rawToken.slice(start, end))) start += 1;
+ if (end - start > 1 && /['\u2019]$/.test(rawToken.slice(start, end))) end -= 1;
+ if (start >= end) return { word: rawToken, offset: 0, length: rawToken.length };
+ return { word: rawToken.slice(start, end), offset: start, length: end - start };
+ }
+
+ const TOKEN_RE = /[A-Za-z0-9\u00C0-\u024F'\u2019\-]+/g;
+
+ function scheduleSpellcheck() {
+ clearTimeout(spellDebounce);
+ spellDebounce = setTimeout(runSpellcheck, 600);
+ }
+
+ // Canvas drawing
+ function runSpellcheck() {
+ if (!spellReady || !spell) {
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ return;
+ }
+
+ const text = getEditorText();
+
+ // Find misspelled tokens (and hyphen segments)
+ const misspelled = new Set();
+ let m;
+ TOKEN_RE.lastIndex = 0;
+ while ((m = TOKEN_RE.exec(text)) !== null) {
+ const { word: token } = getCheckableSpan(m[0]);
+ if (isCorrect(token)) continue;
+
+ if (token.includes('-')) {
+ token.split('-').forEach(seg => {
+ if (seg && !isCorrect(seg)) misspelled.add(seg);
+ });
+ } else {
+ misspelled.add(token);
+ }
+ }
+
+ // Clear canvas
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+ // Helper to get client rects for a text range (indices into innerText)
+ function getRectsForTextRange(startIdx, endIdx) {
+ const rects = [];
+
+ const walker = document.createTreeWalker(
+ editor,
+ NodeFilter.SHOW_ALL,
+ null
+ );
+
+ let position = 0;
+ let node;
+
+ while ((node = walker.nextNode())) {
+
+ if (node.nodeType === Node.TEXT_NODE) {
+ const len = node.textContent.length;
+
+ const nodeStart = Math.max(0, startIdx - position);
+ const nodeEnd = Math.min(len, endIdx - position);
+
+ if (nodeStart < nodeEnd) {
+ const range = document.createRange();
+ range.setStart(node, nodeStart);
+ range.setEnd(node, nodeEnd);
+
+ rects.push(...range.getClientRects());
+ }
+
+ position += len;
+ }
+
+ else if (node.nodeName === 'BR') {
+ position += 1;
+ }
+
+ if (position >= endIdx) {
+ break;
+ }
+ }
+
+ return rects;
+ }
+
+
+ // Iterate tokens and draw squiggles for misspelled pieces
+ TOKEN_RE.lastIndex = 0;
+ while ((m = TOKEN_RE.exec(text)) !== null) {
+ const rawToken = m[0];
+ const startIdx = m.index;
+ const endIdx = m.index + rawToken.length;
+ const { word: token, offset, length } = getCheckableSpan(rawToken);
+ const checkStart = startIdx + offset;
+ const checkEnd = checkStart + length;
+
+ if (misspelled.has(token)) {
+ const rects = getRectsForTextRange(checkStart, checkEnd);
+ drawWavyLines(rects);
+ } else if (token.includes('-')) {
+ let segStart = checkStart;
+ const segments = token.split('-');
+ for (let i = 0; i < segments.length; i++) {
+ const seg = segments[i];
+ const segEnd = segStart + seg.length;
+ if (seg && misspelled.has(seg)) {
+ const rects = getRectsForTextRange(segStart, segEnd);
+ drawWavyLines(rects);
+ }
+ segStart = segEnd + 1; // skip hyphen
+ }
+ }
+ }
+ }
+
+ function drawWavyLines(rects) {
+ if (!rects.length) return;
+
+ const canvasRect = canvas.getBoundingClientRect();
+
+ ctx.save();
+ ctx.strokeStyle = 'red';
+ ctx.lineWidth = 1.5;
+ ctx.beginPath();
+
+ for (const rect of rects) {
+ // Convert viewport-relative rect to canvas-local coordinates
+ const x = rect.left - canvasRect.left;
+ const y = rect.top - canvasRect.top;
+ const w = rect.width;
+ const h = rect.height;
+
+ if (w === 0) continue;
+
+ const amplitude = 1.5;
+ const frequency = 0.2;
+ const startX = x;
+ const endX = x + w;
+ const baseY = y + h + 2;
+
+ ctx.moveTo(startX, baseY);
+ for (let px = startX; px <= endX; px += 0.5) {
+ const wave = Math.sin(px * frequency * Math.PI * 2) * amplitude;
+ ctx.lineTo(px, baseY + wave);
+ }
+ }
+
+ ctx.stroke();
+ ctx.restore();
+ }
+
+ // Context menu handlers
+ function handleEditorContextMenu(e) {
+ e.preventDefault();
+
+ if (!spellReady || !spell) return;
+
+ let range;
+ if (document.caretRangeFromPoint) {
+ range = document.caretRangeFromPoint(e.clientX, e.clientY);
+ } else if (document.caretPositionFromPoint) {
+ const pos = document.caretPositionFromPoint(e.clientX, e.clientY);
+ if (!pos) return;
+ range = document.createRange();
+ range.setStart(pos.offsetNode, pos.offset);
+ range.collapse(true);
+ } else {
+ return;
+ }
+
+ if (!range) return;
+
+ const wordInfo = expandRangeToWord(range);
+ if (!wordInfo) return;
+
+ const { word, wordRange } = wordInfo;
- // Helper: write to user's home directory
+ menuTargetWord = word;
+ menuTargetRange = wordRange;
+
+ const suggestions = getSuggestions(word).slice(0, 6);
+
+ spellMenuItems.innerHTML = '';
+ if (suggestions.length === 0) {
+ const noSug = document.createElement('div');
+ noSug.className = 'spell-menu-no-suggestions';
+ noSug.textContent = 'No suggestions';
+ spellMenuItems.appendChild(noSug);
+ } else {
+ suggestions.forEach(sug => {
+ const item = document.createElement('div');
+ item.className = 'spell-menu-item';
+ item.setAttribute('role', 'menuitem');
+ item.setAttribute('tabindex', '-1');
+ item.textContent = sug;
+ item.addEventListener('mousedown', (ev) => {
+ ev.preventDefault();
+ applyCorrection(sug);
+ });
+ spellMenuItems.appendChild(item);
+ });
+ }
+
+ showSpellMenu(e.clientX, e.clientY);
+ }
+
+ function expandRangeToWord(range) {
+ const container = range.startContainer;
+
+ if (container.nodeType !== Node.TEXT_NODE) return null;
+ if (!editor.contains(container)) return null;
+
+ const text = container.textContent;
+ const offset = range.startOffset;
+
+ const isWordChar = (ch) => /[A-Za-z0-9\u00C0-\u024F'\u2019\-]/.test(ch);
+
+ let start = offset;
+ while (start > 0 && isWordChar(text[start - 1])) start--;
+ let end = offset;
+ while (end < text.length && isWordChar(text[end])) end++;
+
+ if (start === end) return null;
+
+ const rawToken = text.slice(start, end);
+ const span = getCheckableSpan(rawToken);
+ start += span.offset;
+ end = start + span.length;
+ const token = span.word;
+
+ if (isCorrect(token)) return null;
+
+ if (!token.includes('-')) {
+ const wordRange = document.createRange();
+ wordRange.setStart(container, start);
+ wordRange.setEnd(container, end);
+ return { word: token, wordRange };
+ }
+
+ let segStart = start;
+ for (const seg of token.split('-')) {
+ const segEnd = segStart + seg.length;
+ if (offset >= segStart && offset <= segEnd) {
+ if (seg && !isCorrect(seg)) {
+ const wordRange = document.createRange();
+ wordRange.setStart(container, segStart);
+ wordRange.setEnd(container, segEnd);
+ return { word: seg, wordRange };
+ }
+ return null;
+ }
+ segStart = segEnd + 1;
+ }
+
+ return null;
+ }
+
+ function applyCorrection(replacement) {
+ const range = menuTargetRange;
+ hideSpellMenu();
+
+ if (!range) return;
+
+ editor.focus();
+
+ const sel = window.getSelection();
+ sel.removeAllRanges();
+ sel.addRange(range);
+
+ document.execCommand('insertText', false, replacement);
+
+ runSpellcheck();
+ }
+
+ function showSpellMenu(x, y) {
+ spellMenu.style.left = '-9999px';
+ spellMenu.style.top = '-9999px';
+ spellMenu.classList.add('visible');
+
+ const menuW = spellMenu.offsetWidth;
+ const menuH = spellMenu.offsetHeight;
+ const vw = window.innerWidth;
+ const vh = window.innerHeight;
+
+ const left = Math.min(x, vw - menuW - 8);
+ const top = Math.min(y, vh - menuH - 8);
+
+ spellMenu.style.left = `${Math.max(0, left)}px`;
+ spellMenu.style.top = `${Math.max(0, top)}px`;
+ }
+
+ function hideSpellMenu() {
+ spellMenu.classList.remove('visible');
+ menuTargetWord = null;
+ menuTargetRange = null;
+ }
+
+ // FILE SYSTEM HELPERS
async function getDataPath(filename) {
const home = await Neutralino.os.getEnv('HOME');
const appDir = `${home}/.local/share/jottings`;
-
- // Create directories one level at a time
+
const localDir = `${home}/.local`;
const shareDir = `${home}/.local/share`;
-
- try {
- await Neutralino.filesystem.createDirectory(localDir);
- } catch(e) {}
-
- try {
- await Neutralino.filesystem.createDirectory(shareDir);
- } catch(e) {}
-
- try {
- await Neutralino.filesystem.createDirectory(appDir);
- } catch(e) {}
-
+
+ try { await Neutralino.filesystem.createDirectory(localDir); } catch(e) {}
+ try { await Neutralino.filesystem.createDirectory(shareDir); } catch(e) {}
+ try { await Neutralino.filesystem.createDirectory(appDir); } catch(e) {}
+
return `${appDir}/${filename}`;
}
- // Read all jottings from JSON file
async function readNotes() {
try {
const path = await getDataPath(DATA_FILE);
@@ -45,13 +480,11 @@ async function readNotes() {
}
Diff truncated. 393 more lines not shown.