- #83: ruban horizontal defilable ancre au-dessus du clavier, commandes etendues (titres, barre, surligne, code bloc, citation, listes, cases, wikilinks, tags, pieces jointes, undo/redo, colle, police) et personnalisation persistee via l'icone engrenage - BUG-017: cartes du tableau de bord compressibles (min-width:0) - BUG-018: en-tete editeur mobile compact, boutons Annuler/Sauvegarder accessibles, barre IA plus tactile - BUG-019: ancrage du ruban via visualViewport (--kb-offset) - BUG-020: suppression de la double barre de defilement Tests: mobile-editor 35/35, ai 50/50, 9 suites JSDOM, validate-imports, pytest 961 passed / 6 skipped, ruff + mypy 0.
1110 lines
37 KiB
JavaScript
1110 lines
37 KiB
JavaScript
/* ObsiGate — Native mobile editor UX (ROADMAP #69)
|
||
*
|
||
* Tactile-optimised editing experience, split in three areas:
|
||
* 1. Editor — floating Markdown toolbar (bold/italic/code/list/link),
|
||
* persistent "Paste" button (iOS clipboard workaround),
|
||
* pinch-zoom font size and adjustable editor height.
|
||
* 2. Viewer — swipe shortcuts (left → backlinks, right → table of contents)
|
||
* and a distraction-free reading mode with horizontal page
|
||
* navigation.
|
||
*
|
||
* The pure helpers at the top are exported for unit testing (JSDOM).
|
||
* All UI wiring happens in initMobileEditor(), called once from app.js.
|
||
*/
|
||
import { state } from './state.js';
|
||
import { t } from './i18n.js';
|
||
|
||
// ── Constants ──────────────────────────────────────────────────────────────
|
||
export const FORMAT_ACTIONS = ['bold', 'italic', 'code', 'list', 'link'];
|
||
export const SWIPE_THRESHOLD = 60;
|
||
export const MIN_EDITOR_FONT = 10;
|
||
export const MAX_EDITOR_FONT = 28;
|
||
export const DEFAULT_EDITOR_FONT = 13.5;
|
||
export const MIN_EDITOR_HEIGHT = 140;
|
||
|
||
// ROADMAP #83 — every command the mobile ribbon can expose.
|
||
export const RIBBON_COMMANDS = [
|
||
'undo', 'redo',
|
||
'wikilink', 'files', 'tag', 'attachment',
|
||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||
'bold', 'italic', 'strike', 'highlight', 'code', 'codeblock', 'quote',
|
||
'link', 'bullet', 'ordered', 'checkbox', 'indent', 'dedent',
|
||
'paste', 'font_smaller', 'font_larger',
|
||
];
|
||
// Default ribbon layout (Obsidian Android-like).
|
||
export const DEFAULT_RIBBON_COMMANDS = [
|
||
'undo', 'redo', 'wikilink', 'files', 'tag', 'attachment',
|
||
'h1', 'h2', 'h3', 'bold', 'italic', 'strike', 'highlight',
|
||
'code', 'codeblock', 'quote', 'link', 'bullet', 'ordered',
|
||
'checkbox', 'indent', 'dedent', 'paste', 'font_smaller', 'font_larger',
|
||
];
|
||
|
||
const FONT_KEY = 'obsigate-editor-font-size';
|
||
const HEIGHT_KEY = 'obsigate-editor-height';
|
||
const READING_KEY = 'obsigate-reading-mode';
|
||
const RIBBON_KEY = 'obsigate-mobile-ribbon';
|
||
|
||
// Command glyphs (kept as plain text so they render with the mono font).
|
||
const COMMAND_ICONS = {
|
||
undo: '↶', redo: '↷',
|
||
wikilink: '[[]]', files: '📁', tag: '#', attachment: '![[ ]]',
|
||
h1: 'H1', h2: 'H2', h3: 'H3', h4: 'H4', h5: 'H5', h6: 'H6',
|
||
bold: 'B', italic: 'I', strike: 'S̶', highlight: '==', code: '<>', codeblock: '{}', quote: '❝',
|
||
link: '🔗', bullet: '•', ordered: '1.', checkbox: '☑', indent: '→', dedent: '←',
|
||
paste: '📋', font_smaller: 'A−', font_larger: 'A+',
|
||
};
|
||
// Commands reusing an existing i18n key (the rest use mobile_editor.cmd_<id>).
|
||
const COMMAND_LABEL_KEYS = {
|
||
paste: 'mobile_editor.paste',
|
||
font_smaller: 'mobile_editor.font_smaller',
|
||
font_larger: 'mobile_editor.font_larger',
|
||
};
|
||
|
||
// ── Pure helpers (unit tested) ─────────────────────────────────────────────
|
||
|
||
/** Clamp a font size to the supported range; non-numeric falls back to 13.5. */
|
||
export function clampZoom(value, min = MIN_EDITOR_FONT, max = MAX_EDITOR_FONT) {
|
||
const n = Number(value);
|
||
if (!Number.isFinite(n)) return DEFAULT_EDITOR_FONT;
|
||
return Math.min(max, Math.max(min, n));
|
||
}
|
||
|
||
/** Detect a horizontal swipe from a delta. Returns 'left' | 'right' | null. */
|
||
export function detectSwipe(dx, dy, threshold = SWIPE_THRESHOLD) {
|
||
if (Math.abs(dx) < threshold) return null;
|
||
if (Math.abs(dy) > Math.abs(dx)) return null;
|
||
return dx < 0 ? 'left' : 'right';
|
||
}
|
||
|
||
/**
|
||
* Compute the text change produced by a Markdown formatting action.
|
||
*
|
||
* Supports the legacy actions (bold, italic, code, list, link) plus the
|
||
* ROADMAP #83 ribbon commands (headings, strike, highlight, code block,
|
||
* quote, ordered list, checkbox, indent/dedent, wikilink, tag, attachment).
|
||
*
|
||
* @param {string} text Full document text.
|
||
* @param {number} from Selection start.
|
||
* @param {number} to Selection end.
|
||
* @param {string} action One of RIBBON_COMMANDS / FORMAT_ACTIONS.
|
||
* @returns {{changes:{from:number,to:number,insert:string},selection:{anchor:number,head:number}}|null}
|
||
*/
|
||
export function formatChange(text, from, to, action) {
|
||
const selected = text.slice(from, to);
|
||
|
||
// Wrap/unwrap the selection with a marker pair.
|
||
const wrap = (open, close = open) => {
|
||
if (
|
||
selected.length >= open.length + close.length &&
|
||
selected.startsWith(open) &&
|
||
selected.endsWith(close)
|
||
) {
|
||
const inner = selected.slice(open.length, selected.length - close.length);
|
||
return {
|
||
changes: { from, to, insert: inner },
|
||
selection: { anchor: from, head: from + inner.length },
|
||
};
|
||
}
|
||
const insert = open + selected + close;
|
||
return {
|
||
changes: { from, to, insert },
|
||
selection: { anchor: from + open.length, head: from + open.length + selected.length },
|
||
};
|
||
};
|
||
|
||
// Apply a per-line prefix, toggling it off when every line already has it.
|
||
// ``pattern`` strips any competing prefix before adding the new one.
|
||
const prefixLines = (prefix, pattern) => {
|
||
const lineStart = text.lastIndexOf('\n', from - 1) + 1;
|
||
let lineEnd = text.indexOf('\n', to);
|
||
if (lineEnd === -1) lineEnd = text.length;
|
||
const lines = text.slice(lineStart, lineEnd).split('\n');
|
||
const allHave = lines.every((l) => l.startsWith(prefix));
|
||
const out = lines
|
||
.map((l) => {
|
||
if (allHave) return l.slice(prefix.length);
|
||
const base = pattern ? l.replace(pattern, '') : l;
|
||
return prefix + base;
|
||
})
|
||
.join('\n');
|
||
return {
|
||
changes: { from: lineStart, to: lineEnd, insert: out },
|
||
selection: { anchor: lineStart, head: lineStart + out.length },
|
||
};
|
||
};
|
||
|
||
const listPattern = /^([-*+]\s+|\d+\.\s+|- \[[ xX]\]\s+)/;
|
||
|
||
switch (action) {
|
||
case 'bold':
|
||
return wrap('**');
|
||
case 'italic':
|
||
return wrap('*');
|
||
case 'code':
|
||
return wrap('`');
|
||
case 'strike':
|
||
return wrap('~~');
|
||
case 'highlight':
|
||
return wrap('==');
|
||
case 'wikilink':
|
||
return wrap('[[', ']]');
|
||
case 'attachment':
|
||
return wrap('![[', ']]');
|
||
case 'tag': {
|
||
const insert = '#' + selected;
|
||
const anchor = from + insert.length;
|
||
return { changes: { from, to, insert }, selection: { anchor, head: anchor } };
|
||
}
|
||
case 'h1':
|
||
case 'h2':
|
||
case 'h3':
|
||
case 'h4':
|
||
case 'h5':
|
||
case 'h6': {
|
||
const level = Number(action.slice(1));
|
||
return prefixLines('#'.repeat(level) + ' ', /^#{1,6}\s+/);
|
||
}
|
||
case 'quote':
|
||
return prefixLines('> ', /^>\s?/);
|
||
case 'bullet':
|
||
case 'list':
|
||
return prefixLines('- ', listPattern);
|
||
case 'checkbox':
|
||
return prefixLines('- [ ] ', listPattern);
|
||
case 'ordered': {
|
||
const lineStart = text.lastIndexOf('\n', from - 1) + 1;
|
||
let lineEnd = text.indexOf('\n', to);
|
||
if (lineEnd === -1) lineEnd = text.length;
|
||
const lines = text.slice(lineStart, lineEnd).split('\n');
|
||
const allOrdered = lines.every((l) => /^\d+\.\s+/.test(l));
|
||
const out = lines
|
||
.map((l, i) => {
|
||
if (allOrdered) return l.replace(/^\d+\.\s+/, '');
|
||
return `${i + 1}. ` + l.replace(listPattern, '');
|
||
})
|
||
.join('\n');
|
||
return {
|
||
changes: { from: lineStart, to: lineEnd, insert: out },
|
||
selection: { anchor: lineStart, head: lineStart + out.length },
|
||
};
|
||
}
|
||
case 'codeblock': {
|
||
const lineStart = text.lastIndexOf('\n', from - 1) + 1;
|
||
let lineEnd = text.indexOf('\n', to);
|
||
if (lineEnd === -1) lineEnd = text.length;
|
||
const block = text.slice(lineStart, lineEnd);
|
||
if (block.startsWith('```') && block.endsWith('```') && block.length >= 6) {
|
||
const inner = block.slice(3, -3).replace(/^\n/, '').replace(/\n$/, '');
|
||
return {
|
||
changes: { from: lineStart, to: lineEnd, insert: inner },
|
||
selection: { anchor: lineStart, head: lineStart + inner.length },
|
||
};
|
||
}
|
||
const insert = '```\n' + block + '\n```';
|
||
return {
|
||
changes: { from: lineStart, to: lineEnd, insert },
|
||
selection: { anchor: lineStart + 4, head: lineStart + 4 + block.length },
|
||
};
|
||
}
|
||
case 'indent': {
|
||
const lineStart = text.lastIndexOf('\n', from - 1) + 1;
|
||
let lineEnd = text.indexOf('\n', to);
|
||
if (lineEnd === -1) lineEnd = text.length;
|
||
const lines = text.slice(lineStart, lineEnd).split('\n');
|
||
const out = lines.map((l) => ' ' + l).join('\n');
|
||
return {
|
||
changes: { from: lineStart, to: lineEnd, insert: out },
|
||
selection: { anchor: lineStart, head: lineStart + out.length },
|
||
};
|
||
}
|
||
case 'dedent': {
|
||
const lineStart = text.lastIndexOf('\n', from - 1) + 1;
|
||
let lineEnd = text.indexOf('\n', to);
|
||
if (lineEnd === -1) lineEnd = text.length;
|
||
const lines = text.slice(lineStart, lineEnd).split('\n');
|
||
const out = lines.map((l) => l.replace(/^(\t| {1,2})/, '')).join('\n');
|
||
return {
|
||
changes: { from: lineStart, to: lineEnd, insert: out },
|
||
selection: { anchor: lineStart, head: lineStart + out.length },
|
||
};
|
||
}
|
||
case 'link': {
|
||
const insert = `[${selected}](url)`;
|
||
const urlStart = from + 1 + selected.length + 2;
|
||
return {
|
||
changes: { from, to, insert },
|
||
selection: { anchor: urlStart, head: urlStart + 3 },
|
||
};
|
||
}
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Keep only known ribbon command ids, de-duplicated, in order. */
|
||
export function normalizeRibbonCommands(list) {
|
||
const valid = new Set(RIBBON_COMMANDS);
|
||
const seen = new Set();
|
||
const out = [];
|
||
(Array.isArray(list) ? list : []).forEach((id) => {
|
||
if (valid.has(id) && !seen.has(id)) {
|
||
seen.add(id);
|
||
out.push(id);
|
||
}
|
||
});
|
||
return out.length ? out : [...DEFAULT_RIBBON_COMMANDS];
|
||
}
|
||
|
||
/** Move a command by ``delta`` positions within the list (clamped). */
|
||
export function moveRibbonCommand(list, id, delta) {
|
||
const arr = [...list];
|
||
const i = arr.indexOf(id);
|
||
if (i === -1) return arr;
|
||
const j = Math.max(0, Math.min(arr.length - 1, i + delta));
|
||
if (i === j) return arr;
|
||
arr.splice(i, 1);
|
||
arr.splice(j, 0, id);
|
||
return arr;
|
||
}
|
||
|
||
/** Build a plain insertion change (used by the paste helper). */
|
||
export function insertText(from, to, insert) {
|
||
return {
|
||
changes: { from, to, insert },
|
||
selection: { anchor: from + insert.length, head: from + insert.length },
|
||
};
|
||
}
|
||
|
||
/** Apply a formatting action to a CodeMirror EditorView. */
|
||
export function applyFormatToView(view, action) {
|
||
if (!view || !view.state) return false;
|
||
const { from, to } = view.state.selection.main;
|
||
const change = formatChange(view.state.doc.toString(), from, to, action);
|
||
if (!change) return false;
|
||
view.dispatch({ changes: change.changes, selection: change.selection });
|
||
if (typeof view.focus === 'function') view.focus();
|
||
return true;
|
||
}
|
||
|
||
/** Apply a formatting action to a plain textarea fallback editor. */
|
||
export function applyFormatToTextarea(ta, action) {
|
||
if (!ta) return false;
|
||
const change = formatChange(ta.value, ta.selectionStart, ta.selectionEnd, action);
|
||
if (!change) return false;
|
||
const { from, to, insert } = change.changes;
|
||
ta.value = ta.value.slice(0, from) + insert + ta.value.slice(to);
|
||
if (typeof ta.focus === 'function') ta.focus();
|
||
ta.selectionStart = change.selection.anchor;
|
||
ta.selectionEnd = change.selection.head;
|
||
return true;
|
||
}
|
||
|
||
// ── Runtime helpers ────────────────────────────────────────────────────────
|
||
|
||
let _initialized = false;
|
||
|
||
function isMobileViewport() {
|
||
if (typeof window === 'undefined') return false;
|
||
if (window.matchMedia) return window.matchMedia('(max-width: 768px)').matches;
|
||
return window.innerWidth <= 768;
|
||
}
|
||
|
||
async function showToastSafe(message, type) {
|
||
try {
|
||
const ui = await import('./ui.js');
|
||
if (ui && typeof ui.showToast === 'function') ui.showToast(message, type);
|
||
} catch (_) {
|
||
/* UI module unavailable (tests) — silent */
|
||
}
|
||
}
|
||
|
||
function currentEditorFontSize() {
|
||
try {
|
||
const saved = parseFloat(localStorage.getItem(FONT_KEY));
|
||
if (Number.isFinite(saved)) return clampZoom(saved);
|
||
} catch (_) {
|
||
/* ignore */
|
||
}
|
||
return DEFAULT_EDITOR_FONT;
|
||
}
|
||
|
||
/** Apply a font size to every CodeMirror scroller + fallback textarea. */
|
||
export function applyEditorFontSize(size) {
|
||
const s = clampZoom(size);
|
||
try {
|
||
localStorage.setItem(FONT_KEY, String(s));
|
||
} catch (_) {
|
||
/* ignore */
|
||
}
|
||
const px = s + 'px';
|
||
document.querySelectorAll('.cm-editor .cm-scroller').forEach((el) => {
|
||
if (el.style.fontSize !== px) el.style.fontSize = px;
|
||
});
|
||
if (state.fallbackEditorEl) state.fallbackEditorEl.style.fontSize = px;
|
||
return s;
|
||
}
|
||
|
||
function bumpFontSize(delta) {
|
||
return applyEditorFontSize(currentEditorFontSize() + delta);
|
||
}
|
||
|
||
// ── Formatting toolbar + clipboard ─────────────────────────────────────────
|
||
|
||
function handleFormat(action) {
|
||
if (state.editorView) {
|
||
applyFormatToView(state.editorView, action);
|
||
} else if (state.fallbackEditorEl) {
|
||
applyFormatToTextarea(state.fallbackEditorEl, action);
|
||
}
|
||
}
|
||
|
||
async function handlePaste() {
|
||
let text = '';
|
||
try {
|
||
if (navigator.clipboard && navigator.clipboard.readText) {
|
||
text = await navigator.clipboard.readText();
|
||
}
|
||
} catch (_) {
|
||
// iOS Safari / permission denied — fall through to the hint.
|
||
}
|
||
if (!text) {
|
||
showToastSafe(t('mobile_editor.paste_hint'), 'info');
|
||
return;
|
||
}
|
||
if (state.editorView) {
|
||
const { from, to } = state.editorView.state.selection.main;
|
||
const change = insertText(from, to, text);
|
||
state.editorView.dispatch({ changes: change.changes, selection: change.selection });
|
||
if (typeof state.editorView.focus === 'function') state.editorView.focus();
|
||
} else if (state.fallbackEditorEl) {
|
||
const ta = state.fallbackEditorEl;
|
||
const from = ta.selectionStart;
|
||
const to = ta.selectionEnd;
|
||
ta.value = ta.value.slice(0, from) + text + ta.value.slice(to);
|
||
ta.focus();
|
||
ta.selectionStart = ta.selectionEnd = from + text.length;
|
||
}
|
||
}
|
||
|
||
// ── Formatting ribbon (ROADMAP #83) ────────────────────────────────────────
|
||
|
||
let _ribbonCommands = null;
|
||
|
||
function loadRibbonCommands() {
|
||
if (_ribbonCommands) return _ribbonCommands;
|
||
let stored = null;
|
||
try {
|
||
stored = JSON.parse(localStorage.getItem(RIBBON_KEY) || 'null');
|
||
} catch (_) {
|
||
stored = null;
|
||
}
|
||
_ribbonCommands = normalizeRibbonCommands(stored);
|
||
return _ribbonCommands;
|
||
}
|
||
|
||
function persistRibbonCommands(list) {
|
||
_ribbonCommands = normalizeRibbonCommands(list);
|
||
try {
|
||
localStorage.setItem(RIBBON_KEY, JSON.stringify(_ribbonCommands));
|
||
} catch (_) {
|
||
/* ignore quota / private mode */
|
||
}
|
||
}
|
||
|
||
function commandLabel(id) {
|
||
return t(COMMAND_LABEL_KEYS[id] || 'mobile_editor.cmd_' + id);
|
||
}
|
||
|
||
function runRibbonCommand(id) {
|
||
if (id === 'undo' || id === 'redo') {
|
||
const fn = window.CodeMirror && window.CodeMirror[id];
|
||
if (typeof fn === 'function' && state.editorView) {
|
||
fn(state.editorView);
|
||
if (typeof state.editorView.focus === 'function') state.editorView.focus();
|
||
}
|
||
return;
|
||
}
|
||
if (id === 'files') {
|
||
import('./palette.js').then((m) => m.open('')).catch(() => {});
|
||
return;
|
||
}
|
||
if (id === 'paste') {
|
||
handlePaste();
|
||
return;
|
||
}
|
||
if (id === 'font_smaller') {
|
||
bumpFontSize(-1);
|
||
return;
|
||
}
|
||
if (id === 'font_larger') {
|
||
bumpFontSize(1);
|
||
return;
|
||
}
|
||
handleFormat(id);
|
||
}
|
||
|
||
function buildRibbonButton(id) {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'me-ribbon-btn';
|
||
btn.id = 'me-cmd-' + id;
|
||
btn.dataset.cmd = id;
|
||
btn.textContent = COMMAND_ICONS[id] || id;
|
||
const label = commandLabel(id);
|
||
btn.title = label;
|
||
btn.setAttribute('aria-label', label);
|
||
btn.addEventListener('click', () => runRibbonCommand(id));
|
||
return btn;
|
||
}
|
||
|
||
function renderRibbon() {
|
||
const scroll = document.querySelector('#me-toolbar .me-ribbon-scroll');
|
||
if (!scroll) return;
|
||
scroll.innerHTML = '';
|
||
loadRibbonCommands().forEach((id) => scroll.appendChild(buildRibbonButton(id)));
|
||
}
|
||
|
||
// ── Ribbon customiser (gear button) ────────────────────────────────────────
|
||
|
||
function buildCustomizerSheet() {
|
||
let sheet = document.getElementById('me-ribbon-customizer');
|
||
if (sheet) return sheet;
|
||
|
||
// Reuse the shared bottom-sheet overlay created by ensureSheet().
|
||
ensureSheet();
|
||
|
||
sheet = document.createElement('div');
|
||
sheet.id = 'me-ribbon-customizer';
|
||
sheet.className = 'me-sheet me-ribbon-customizer';
|
||
sheet.setAttribute('role', 'dialog');
|
||
sheet.setAttribute('aria-modal', 'true');
|
||
|
||
const handle = document.createElement('div');
|
||
handle.className = 'me-sheet-handle';
|
||
const header = document.createElement('div');
|
||
header.className = 'me-sheet-header';
|
||
const title = document.createElement('span');
|
||
title.className = 'me-sheet-title';
|
||
title.textContent = t('mobile_editor.customize_title');
|
||
const close = document.createElement('button');
|
||
close.type = 'button';
|
||
close.className = 'me-sheet-close';
|
||
close.textContent = '✕';
|
||
close.setAttribute('aria-label', t('mobile_editor.close'));
|
||
header.appendChild(title);
|
||
header.appendChild(close);
|
||
|
||
const list = document.createElement('div');
|
||
list.className = 'me-sheet-list me-ribbon-customizer-list';
|
||
|
||
const footer = document.createElement('div');
|
||
footer.className = 'me-ribbon-customizer-actions';
|
||
const reset = document.createElement('button');
|
||
reset.type = 'button';
|
||
reset.className = 'config-btn-secondary';
|
||
reset.textContent = t('mobile_editor.customize_reset');
|
||
const done = document.createElement('button');
|
||
done.type = 'button';
|
||
done.className = 'config-btn-primary';
|
||
done.textContent = t('mobile_editor.done');
|
||
footer.appendChild(reset);
|
||
footer.appendChild(done);
|
||
|
||
sheet.appendChild(handle);
|
||
sheet.appendChild(header);
|
||
sheet.appendChild(list);
|
||
sheet.appendChild(footer);
|
||
document.body.appendChild(sheet);
|
||
|
||
close.addEventListener('click', () => closeSheet(sheet));
|
||
done.addEventListener('click', () => closeSheet(sheet));
|
||
reset.addEventListener('click', () => {
|
||
persistRibbonCommands(DEFAULT_RIBBON_COMMANDS);
|
||
renderRibbon();
|
||
renderCustomizerList();
|
||
});
|
||
return sheet;
|
||
}
|
||
|
||
function _customizerRow(labelText, buttons, extraClass) {
|
||
const row = document.createElement('div');
|
||
row.className = 'me-ribbon-cmd-row' + (extraClass ? ' ' + extraClass : '');
|
||
const label = document.createElement('span');
|
||
label.className = 'me-ribbon-cmd-label';
|
||
label.textContent = labelText;
|
||
row.appendChild(label);
|
||
buttons.forEach((b) => row.appendChild(b));
|
||
return row;
|
||
}
|
||
|
||
function _smallButton(text, cls, aria, onClick, disabled) {
|
||
const b = document.createElement('button');
|
||
b.type = 'button';
|
||
b.className = cls;
|
||
b.textContent = text;
|
||
if (aria) b.setAttribute('aria-label', aria);
|
||
if (disabled) b.disabled = true;
|
||
b.addEventListener('click', onClick);
|
||
return b;
|
||
}
|
||
|
||
function renderCustomizerList() {
|
||
const sheet = document.getElementById('me-ribbon-customizer');
|
||
if (!sheet) return;
|
||
const list = sheet.querySelector('.me-ribbon-customizer-list');
|
||
if (!list) return;
|
||
|
||
const enabled = loadRibbonCommands();
|
||
const disabled = RIBBON_COMMANDS.filter((id) => !enabled.includes(id));
|
||
list.innerHTML = '';
|
||
|
||
enabled.forEach((id, index) => {
|
||
const up = _smallButton('↑', 'me-ribbon-cmd-move', t('mobile_editor.move_up'), () => {
|
||
persistRibbonCommands(moveRibbonCommand(enabled, id, -1));
|
||
renderRibbon();
|
||
renderCustomizerList();
|
||
}, index === 0);
|
||
const down = _smallButton('↓', 'me-ribbon-cmd-move', t('mobile_editor.move_down'), () => {
|
||
persistRibbonCommands(moveRibbonCommand(enabled, id, 1));
|
||
renderRibbon();
|
||
renderCustomizerList();
|
||
}, index === enabled.length - 1);
|
||
const remove = _smallButton('✕', 'me-ribbon-cmd-remove', t('mobile_editor.remove_command'), () => {
|
||
if (enabled.length <= 1) return;
|
||
persistRibbonCommands(enabled.filter((c) => c !== id));
|
||
renderRibbon();
|
||
renderCustomizerList();
|
||
}, false);
|
||
list.appendChild(
|
||
_customizerRow(`${COMMAND_ICONS[id] || id} ${commandLabel(id)}`, [up, down, remove]),
|
||
);
|
||
});
|
||
|
||
if (disabled.length) {
|
||
const divider = document.createElement('div');
|
||
divider.className = 'me-ribbon-cmd-divider';
|
||
divider.textContent = t('mobile_editor.add_command');
|
||
list.appendChild(divider);
|
||
disabled.forEach((id) => {
|
||
const add = _smallButton('+', 'me-ribbon-cmd-add', t('mobile_editor.add_command'), () => {
|
||
persistRibbonCommands([...enabled, id]);
|
||
renderRibbon();
|
||
renderCustomizerList();
|
||
}, false);
|
||
list.appendChild(_customizerRow(`${COMMAND_ICONS[id] || id} ${commandLabel(id)}`, [add], 'disabled'));
|
||
});
|
||
}
|
||
}
|
||
|
||
function openRibbonCustomizer() {
|
||
const sheet = buildCustomizerSheet();
|
||
renderCustomizerList();
|
||
openSheet(sheet);
|
||
}
|
||
|
||
// ── Keyboard anchoring (BUG-019) ───────────────────────────────────────────
|
||
|
||
function initKeyboardAnchor(bar) {
|
||
if (!window.visualViewport) return;
|
||
const vv = window.visualViewport;
|
||
const update = () => {
|
||
const offset = Math.max(0, Math.round((window.innerHeight || 0) - vv.height - vv.offsetTop));
|
||
bar.style.setProperty('--kb-offset', offset + 'px');
|
||
bar.classList.toggle('kb-open', offset > 40);
|
||
};
|
||
vv.addEventListener('resize', update);
|
||
vv.addEventListener('scroll', update);
|
||
window.addEventListener('resize', update);
|
||
document.addEventListener('focusin', () => setTimeout(update, 60));
|
||
document.addEventListener('focusout', () => setTimeout(update, 60));
|
||
update();
|
||
}
|
||
|
||
function buildFormattingToolbar() {
|
||
const modal = document.getElementById('editor-modal');
|
||
if (!modal || document.getElementById('me-toolbar')) return;
|
||
|
||
const bar = document.createElement('div');
|
||
bar.id = 'me-toolbar';
|
||
bar.className = 'me-toolbar me-ribbon';
|
||
bar.setAttribute('role', 'toolbar');
|
||
bar.setAttribute('aria-label', t('mobile_editor.toolbar'));
|
||
|
||
const scroll = document.createElement('div');
|
||
scroll.className = 'me-ribbon-scroll';
|
||
|
||
const gear = document.createElement('button');
|
||
gear.type = 'button';
|
||
gear.id = 'me-ribbon-gear';
|
||
gear.className = 'me-ribbon-gear';
|
||
gear.textContent = '⚙';
|
||
gear.title = t('mobile_editor.customize');
|
||
gear.setAttribute('aria-label', t('mobile_editor.customize'));
|
||
gear.addEventListener('click', openRibbonCustomizer);
|
||
|
||
bar.appendChild(scroll);
|
||
bar.appendChild(gear);
|
||
modal.appendChild(bar);
|
||
|
||
renderRibbon();
|
||
initKeyboardAnchor(bar);
|
||
}
|
||
|
||
// ── Editor height resize + pinch zoom ──────────────────────────────────────
|
||
|
||
function applySavedHeight(bodyEl) {
|
||
if (!isMobileViewport()) return;
|
||
try {
|
||
const saved = parseInt(localStorage.getItem(HEIGHT_KEY), 10);
|
||
if (Number.isFinite(saved) && saved >= MIN_EDITOR_HEIGHT) {
|
||
bodyEl.style.flex = '0 0 ' + saved + 'px';
|
||
bodyEl.style.height = saved + 'px';
|
||
}
|
||
} catch (_) {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
function initHeightResize(bodyEl) {
|
||
if (!bodyEl || document.getElementById('me-resize-handle')) return;
|
||
const handle = document.createElement('div');
|
||
handle.id = 'me-resize-handle';
|
||
handle.className = 'me-resize-handle';
|
||
handle.setAttribute('role', 'separator');
|
||
handle.setAttribute('aria-orientation', 'horizontal');
|
||
handle.title = t('mobile_editor.resize');
|
||
(bodyEl.parentElement || bodyEl).insertBefore(handle, bodyEl.nextSibling);
|
||
|
||
let dragging = false;
|
||
let startY = 0;
|
||
let startH = 0;
|
||
|
||
handle.addEventListener('pointerdown', (e) => {
|
||
dragging = true;
|
||
startY = e.clientY;
|
||
startH = bodyEl.getBoundingClientRect().height;
|
||
if (handle.setPointerCapture) {
|
||
try {
|
||
handle.setPointerCapture(e.pointerId);
|
||
} catch (_) {
|
||
/* ignore */
|
||
}
|
||
}
|
||
e.preventDefault();
|
||
});
|
||
handle.addEventListener('pointermove', (e) => {
|
||
if (!dragging) return;
|
||
const maxH = Math.max(MIN_EDITOR_HEIGHT, (window.innerHeight || 800) - 140);
|
||
const h = Math.min(maxH, Math.max(MIN_EDITOR_HEIGHT, startH + (e.clientY - startY)));
|
||
bodyEl.style.flex = '0 0 ' + h + 'px';
|
||
bodyEl.style.height = h + 'px';
|
||
});
|
||
const stop = () => {
|
||
if (!dragging) return;
|
||
dragging = false;
|
||
try {
|
||
localStorage.setItem(HEIGHT_KEY, String(Math.round(bodyEl.getBoundingClientRect().height)));
|
||
} catch (_) {
|
||
/* ignore */
|
||
}
|
||
};
|
||
handle.addEventListener('pointerup', stop);
|
||
handle.addEventListener('pointercancel', stop);
|
||
}
|
||
|
||
function initPinchZoom(bodyEl) {
|
||
if (!bodyEl) return;
|
||
let startDist = 0;
|
||
let startSize = 0;
|
||
const distance = (touches) =>
|
||
Math.hypot(
|
||
touches[0].clientX - touches[1].clientX,
|
||
touches[0].clientY - touches[1].clientY,
|
||
);
|
||
|
||
bodyEl.addEventListener(
|
||
'touchstart',
|
||
(e) => {
|
||
if (e.touches.length === 2) {
|
||
startDist = distance(e.touches);
|
||
startSize = currentEditorFontSize();
|
||
}
|
||
},
|
||
{ passive: true },
|
||
);
|
||
bodyEl.addEventListener(
|
||
'touchmove',
|
||
(e) => {
|
||
if (e.touches.length === 2 && startDist > 0) {
|
||
applyEditorFontSize(startSize * (distance(e.touches) / startDist));
|
||
if (e.cancelable) e.preventDefault();
|
||
}
|
||
},
|
||
{ passive: false },
|
||
);
|
||
bodyEl.addEventListener('touchend', () => {
|
||
startDist = 0;
|
||
});
|
||
}
|
||
|
||
// ── Bottom sheet (backlinks / TOC) ─────────────────────────────────────────
|
||
|
||
function ensureSheet() {
|
||
let sheet = document.getElementById('me-sheet');
|
||
if (sheet) return sheet;
|
||
|
||
sheet = document.createElement('div');
|
||
sheet.id = 'me-sheet';
|
||
sheet.className = 'me-sheet';
|
||
sheet.setAttribute('role', 'dialog');
|
||
sheet.setAttribute('aria-modal', 'true');
|
||
|
||
const handle = document.createElement('div');
|
||
handle.className = 'me-sheet-handle';
|
||
|
||
const header = document.createElement('div');
|
||
header.className = 'me-sheet-header';
|
||
const title = document.createElement('span');
|
||
title.className = 'me-sheet-title';
|
||
const close = document.createElement('button');
|
||
close.type = 'button';
|
||
close.className = 'me-sheet-close';
|
||
close.textContent = '✕';
|
||
close.setAttribute('aria-label', t('mobile_editor.close'));
|
||
header.appendChild(title);
|
||
header.appendChild(close);
|
||
|
||
const list = document.createElement('div');
|
||
list.className = 'me-sheet-list';
|
||
|
||
sheet.appendChild(handle);
|
||
sheet.appendChild(header);
|
||
sheet.appendChild(list);
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'me-sheet-overlay';
|
||
overlay.className = 'me-sheet-overlay';
|
||
|
||
document.body.appendChild(overlay);
|
||
document.body.appendChild(sheet);
|
||
|
||
close.addEventListener('click', () => closeSheet(sheet));
|
||
overlay.addEventListener('click', () => closeSheet(sheet));
|
||
return sheet;
|
||
}
|
||
|
||
function openSheet(sheet) {
|
||
sheet.classList.add('open');
|
||
const overlay = document.getElementById('me-sheet-overlay');
|
||
if (overlay) overlay.classList.add('visible');
|
||
}
|
||
|
||
function closeSheet(sheet) {
|
||
if (sheet) sheet.classList.remove('open');
|
||
const overlay = document.getElementById('me-sheet-overlay');
|
||
if (overlay) overlay.classList.remove('visible');
|
||
}
|
||
|
||
function renderSheetItems(sheet, items, emptyLabel) {
|
||
const list = sheet.querySelector('.me-sheet-list');
|
||
if (!list) return;
|
||
list.innerHTML = '';
|
||
if (!items.length) {
|
||
const empty = document.createElement('div');
|
||
empty.className = 'me-sheet-empty';
|
||
empty.textContent = emptyLabel;
|
||
list.appendChild(empty);
|
||
return;
|
||
}
|
||
items.forEach((item) => {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'me-sheet-item';
|
||
btn.textContent = item.label;
|
||
btn.addEventListener('click', item.onClick);
|
||
list.appendChild(btn);
|
||
});
|
||
}
|
||
|
||
async function openBacklinksSheet() {
|
||
const vault = state.currentVault;
|
||
const path = state.currentPath;
|
||
if (!vault || !path) return;
|
||
const sheet = ensureSheet();
|
||
sheet.querySelector('.me-sheet-title').textContent = t('mobile_editor.backlinks');
|
||
openSheet(sheet);
|
||
renderSheetItems(sheet, [], t('mobile_editor.loading'));
|
||
try {
|
||
const { api } = await import('./auth.js');
|
||
const data = await api(
|
||
`/api/file/${encodeURIComponent(vault)}/backlinks?path=${encodeURIComponent(path)}`,
|
||
);
|
||
const backlinks = data.backlinks || [];
|
||
renderSheetItems(
|
||
sheet,
|
||
backlinks.map((bl) => ({
|
||
label: bl.title || bl.path.split('/').pop().replace(/\.md$/i, ''),
|
||
onClick: async () => {
|
||
closeSheet(sheet);
|
||
if (window.TabManager && typeof window.TabManager.openPreview === 'function') {
|
||
window.TabManager.openPreview(bl.vault, bl.path);
|
||
} else {
|
||
const viewer = await import('./viewer.js');
|
||
viewer.openFile(bl.vault, bl.path);
|
||
}
|
||
},
|
||
})),
|
||
t('mobile_editor.no_backlinks'),
|
||
);
|
||
} catch (_) {
|
||
renderSheetItems(sheet, [], t('mobile_editor.error'));
|
||
}
|
||
}
|
||
|
||
function openTocSheet() {
|
||
const sheet = ensureSheet();
|
||
sheet.querySelector('.me-sheet-title').textContent = t('mobile_editor.toc');
|
||
const headings = state.headingsCache || [];
|
||
renderSheetItems(
|
||
sheet,
|
||
headings.map((h) => ({
|
||
label: h.text,
|
||
onClick: () => {
|
||
closeSheet(sheet);
|
||
const target = document.getElementById(h.id);
|
||
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
},
|
||
})),
|
||
t('mobile_editor.no_headings'),
|
||
);
|
||
openSheet(sheet);
|
||
}
|
||
|
||
// ── Reading mode + page navigation ─────────────────────────────────────────
|
||
|
||
export function isReadingMode() {
|
||
return document.body.classList.contains('reading-mode');
|
||
}
|
||
|
||
export function toggleReadingMode(force) {
|
||
const on = typeof force === 'boolean' ? force : !isReadingMode();
|
||
document.body.classList.toggle('reading-mode', on);
|
||
const btn = document.getElementById('me-reading-btn');
|
||
if (btn) btn.setAttribute('aria-pressed', String(on));
|
||
try {
|
||
localStorage.setItem(READING_KEY, on ? '1' : '0');
|
||
} catch (_) {
|
||
/* ignore */
|
||
}
|
||
try {
|
||
if (on) {
|
||
if (document.documentElement.requestFullscreen) {
|
||
const p = document.documentElement.requestFullscreen();
|
||
if (p && typeof p.catch === 'function') p.catch(() => {});
|
||
}
|
||
} else if (document.fullscreenElement && document.exitFullscreen) {
|
||
const p = document.exitFullscreen();
|
||
if (p && typeof p.catch === 'function') p.catch(() => {});
|
||
}
|
||
} catch (_) {
|
||
/* Fullscreen unsupported (iOS) — CSS reading mode still applies. */
|
||
}
|
||
return on;
|
||
}
|
||
|
||
async function navigateAdjacentFile(direction) {
|
||
const vault = state.currentVault;
|
||
const path = state.currentPath;
|
||
if (!vault || !path) return;
|
||
const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '';
|
||
try {
|
||
const { api } = await import('./auth.js');
|
||
const data = await api(
|
||
`/api/vault/${encodeURIComponent(vault)}/files?dir=${encodeURIComponent(dir)}&recursive=false&limit=500`,
|
||
);
|
||
const files = (data.files || [])
|
||
.filter((f) => /\.md$/i.test(f.path))
|
||
.sort((a, b) => a.path.localeCompare(b.path));
|
||
const idx = files.findIndex((f) => f.path === path);
|
||
if (idx === -1) return;
|
||
const next = files[idx + (direction > 0 ? 1 : -1)];
|
||
if (!next) {
|
||
showToastSafe(
|
||
direction > 0 ? t('mobile_editor.no_next') : t('mobile_editor.no_prev'),
|
||
'info',
|
||
);
|
||
return;
|
||
}
|
||
const viewer = await import('./viewer.js');
|
||
viewer.openFile(vault, next.path);
|
||
} catch (_) {
|
||
/* network / offline — silent */
|
||
}
|
||
}
|
||
|
||
function buildReadingControls() {
|
||
if (document.getElementById('me-reading-btn')) return;
|
||
const enter = document.createElement('button');
|
||
enter.id = 'me-reading-btn';
|
||
enter.type = 'button';
|
||
enter.className = 'me-reading-btn';
|
||
enter.title = t('mobile_editor.reading_enter');
|
||
enter.setAttribute('aria-label', t('mobile_editor.reading_enter'));
|
||
enter.setAttribute('aria-pressed', String(isReadingMode()));
|
||
enter.textContent = '📖';
|
||
enter.addEventListener('click', () => toggleReadingMode(true));
|
||
|
||
const exit = document.createElement('button');
|
||
exit.id = 'me-reading-exit';
|
||
exit.type = 'button';
|
||
exit.className = 'me-reading-exit';
|
||
exit.title = t('mobile_editor.reading_exit');
|
||
exit.setAttribute('aria-label', t('mobile_editor.reading_exit'));
|
||
exit.textContent = '✕';
|
||
exit.addEventListener('click', () => toggleReadingMode(false));
|
||
|
||
document.body.appendChild(enter);
|
||
document.body.appendChild(exit);
|
||
}
|
||
|
||
/** True when the BooksLM assistant panel is expanded on screen. */
|
||
function isAssistantOpen() {
|
||
const panel = document.querySelector('.bookslm-panel');
|
||
return !!(panel && panel.classList.contains('open') && !panel.classList.contains('hidden'));
|
||
}
|
||
|
||
/**
|
||
* Show the floating reading-mode button only while a file is open and the
|
||
* assistant panel is closed. Without this guard the button floats above the
|
||
* full-screen mobile assistant and covers its send button.
|
||
*
|
||
* Called on init, whenever the content area changes and when the assistant
|
||
* opens/closes.
|
||
*/
|
||
export function updateReadingButtonVisibility() {
|
||
const btn = document.getElementById('me-reading-btn');
|
||
if (!btn) return;
|
||
const fileOpen = !!state.currentPath;
|
||
btn.hidden = !fileOpen || isAssistantOpen();
|
||
}
|
||
|
||
// ── Swipe gestures ─────────────────────────────────────────────────────────
|
||
|
||
function initSwipeGestures() {
|
||
const area = document.getElementById('content-area');
|
||
if (!area || area.dataset.meSwipe === '1') return;
|
||
area.dataset.meSwipe = '1';
|
||
|
||
let startX = 0;
|
||
let startY = 0;
|
||
let tracking = false;
|
||
|
||
area.addEventListener(
|
||
'touchstart',
|
||
(e) => {
|
||
if (e.touches.length !== 1) {
|
||
tracking = false;
|
||
return;
|
||
}
|
||
tracking = true;
|
||
startX = e.touches[0].clientX;
|
||
startY = e.touches[0].clientY;
|
||
},
|
||
{ passive: true },
|
||
);
|
||
|
||
area.addEventListener(
|
||
'touchend',
|
||
(e) => {
|
||
if (!tracking) return;
|
||
tracking = false;
|
||
const touch = e.changedTouches[0];
|
||
if (!touch) return;
|
||
const direction = detectSwipe(touch.clientX - startX, touch.clientY - startY);
|
||
if (!direction) return;
|
||
if (isReadingMode()) {
|
||
navigateAdjacentFile(direction === 'left' ? 1 : -1);
|
||
} else if (direction === 'left') {
|
||
openBacklinksSheet();
|
||
} else {
|
||
openTocSheet();
|
||
}
|
||
},
|
||
{ passive: true },
|
||
);
|
||
}
|
||
|
||
// ── Init ───────────────────────────────────────────────────────────────────
|
||
|
||
export function initMobileEditor() {
|
||
if (_initialized) return;
|
||
_initialized = true;
|
||
|
||
buildFormattingToolbar();
|
||
buildReadingControls();
|
||
initSwipeGestures();
|
||
|
||
// Keep the reading button in sync with the current view: visible only when
|
||
// a file is open and the assistant is closed.
|
||
updateReadingButtonVisibility();
|
||
document.addEventListener('bookslm:opened', updateReadingButtonVisibility);
|
||
document.addEventListener('bookslm:closed', updateReadingButtonVisibility);
|
||
const readingArea = document.getElementById('content-area');
|
||
if (readingArea && typeof MutationObserver !== 'undefined') {
|
||
new MutationObserver(updateReadingButtonVisibility).observe(readingArea, {
|
||
childList: true,
|
||
subtree: true,
|
||
});
|
||
}
|
||
|
||
const bodyEl = document.getElementById('editor-body');
|
||
if (bodyEl) {
|
||
initPinchZoom(bodyEl);
|
||
initHeightResize(bodyEl);
|
||
|
||
const modal = document.getElementById('editor-modal');
|
||
if (modal && typeof MutationObserver !== 'undefined') {
|
||
const observer = new MutationObserver(() => {
|
||
if (modal.classList.contains('active')) {
|
||
applyEditorFontSize(currentEditorFontSize());
|
||
applySavedHeight(bodyEl);
|
||
}
|
||
});
|
||
observer.observe(modal, { attributes: true, attributeFilter: ['class'] });
|
||
|
||
// CodeMirror is recreated on every openEditor()/file switch — re-apply the
|
||
// persisted zoom whenever a new scroller is inserted. Observe direct
|
||
// children only so per-keystroke DOM mutations don't retrigger this.
|
||
const bodyObserver = new MutationObserver(() => {
|
||
if (bodyEl.querySelector('.cm-editor .cm-scroller')) {
|
||
applyEditorFontSize(currentEditorFontSize());
|
||
}
|
||
});
|
||
bodyObserver.observe(bodyEl, { childList: true });
|
||
}
|
||
}
|
||
|
||
// Keep the reading-mode class in sync when the user exits fullscreen (ESC).
|
||
document.addEventListener('fullscreenchange', () => {
|
||
if (!document.fullscreenElement && isReadingMode()) {
|
||
document.body.classList.remove('reading-mode');
|
||
try {
|
||
localStorage.setItem(READING_KEY, '0');
|
||
} catch (_) {
|
||
/* ignore */
|
||
}
|
||
}
|
||
});
|
||
|
||
// Restore a previous reading-mode session on reload (mobile only).
|
||
try {
|
||
if (isMobileViewport() && localStorage.getItem(READING_KEY) === '1') {
|
||
document.body.classList.add('reading-mode');
|
||
}
|
||
} catch (_) {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
export { isMobileViewport };
|