feat(mobile): ruban d'edition style Obsidian + correctifs mobile (#83, BUG-017 a BUG-020)
CI / lint (push) Successful in 1m13s
CI / security (push) Successful in 47s
CI / test (push) Successful in 2m41s
CI / build (push) Successful in 43s
CI / e2e (push) Successful in 11m14s

- #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.
This commit is contained in:
2026-09-13 11:49:45 -04:00
parent 162a5b4acc
commit 520059d866
11 changed files with 841 additions and 126 deletions
+420 -56
View File
@@ -22,9 +22,43 @@ 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) ─────────────────────────────────────────────
@@ -45,35 +79,62 @@ export function detectSwipe(dx, dy, threshold = SWIPE_THRESHOLD) {
/**
* 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 FORMAT_ACTIONS.
* @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);
const wrap = (marker) => {
// Toggle off when the selection already carries the markers.
// Wrap/unwrap the selection with a marker pair.
const wrap = (open, close = open) => {
if (
selected.length >= marker.length * 2 &&
selected.startsWith(marker) &&
selected.endsWith(marker)
selected.length >= open.length + close.length &&
selected.startsWith(open) &&
selected.endsWith(close)
) {
const inner = selected.slice(marker.length, -marker.length);
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 = marker + selected + marker;
const insert = open + selected + close;
return {
changes: { from, to, insert },
selection: { anchor: from + marker.length, head: to + marker.length },
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('**');
@@ -81,19 +142,90 @@ export function formatChange(text, from, to, action) {
return wrap('*');
case 'code':
return wrap('`');
case 'list': {
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);
const lines = block.split('\n');
const allList = lines.every((l) => l.startsWith('- '));
const prefixed = lines
.map((l) => (allList ? l.replace(/^- /, '') : l.startsWith('- ') ? l : '- ' + l))
.join('\n');
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: prefixed },
selection: { anchor: lineStart, head: lineStart + prefixed.length },
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': {
@@ -109,6 +241,32 @@ export function formatChange(text, from, to, action) {
}
}
/** 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 {
@@ -228,62 +386,268 @@ async function handlePaste() {
}
}
function makeButton(id, label, title, onClick) {
// ── 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.id = id;
btn.className = 'me-btn';
btn.title = title;
btn.setAttribute('aria-label', title);
btn.textContent = label;
btn.addEventListener('click', onClick);
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';
bar.className = 'me-toolbar me-ribbon';
bar.setAttribute('role', 'toolbar');
bar.setAttribute('aria-label', t('mobile_editor.toolbar'));
const formats = [
{ action: 'bold', label: 'B', title: t('mobile_editor.bold') },
{ action: 'italic', label: 'I', title: t('mobile_editor.italic') },
{ action: 'code', label: '<>', title: t('mobile_editor.code') },
{ action: 'list', label: '•', title: t('mobile_editor.list') },
{ action: 'link', label: '🔗', title: t('mobile_editor.link') },
];
formats.forEach((f) => {
const btn = makeButton('me-fmt-' + f.action, f.label, f.title, () => handleFormat(f.action));
btn.dataset.action = f.action;
btn.classList.add('me-btn-format');
bar.appendChild(btn);
});
const scroll = document.createElement('div');
scroll.className = 'me-ribbon-scroll';
const sep = document.createElement('span');
sep.className = 'me-sep';
bar.appendChild(sep);
bar.appendChild(
makeButton('me-font-minus', 'A−', t('mobile_editor.font_smaller'), () => bumpFontSize(-1)),
);
bar.appendChild(
makeButton('me-font-plus', 'A+', t('mobile_editor.font_larger'), () => bumpFontSize(1)),
);
const sep2 = document.createElement('span');
sep2.className = 'me-sep';
bar.appendChild(sep2);
const pasteBtn = makeButton('me-paste', '📋', t('mobile_editor.paste'), handlePaste);
pasteBtn.classList.add('me-btn-paste');
bar.appendChild(pasteBtn);
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 ──────────────────────────────────────