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
+4
View File
@@ -129,6 +129,8 @@
defaultKeymap,
history,
historyKeymap,
undo,
redo,
} from "https://esm.sh/@codemirror/[email protected]?external=@codemirror/state";
import {
searchKeymap,
@@ -200,6 +202,8 @@
rust,
oneDark,
keymap,
undo,
redo,
Decoration,
ViewPlugin,
WidgetType,
+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 ──────────────────────────────────────
+35 -2
View File
@@ -1143,8 +1143,8 @@
"help.mobile_editor_reading": ": full screen without header or sidebars; swipe horizontally to go to the next or previous file.",
"help.mobile_editor_reading_label": "Reading mode",
"help.mobile_editor_title": "📱 Mobile editing",
"help.mobile_editor_toolbar": ": bold, italic, code, bullet list and link. The A−/A+ buttons adjust the font size.",
"help.mobile_editor_toolbar_label": "Floating toolbar",
"help.mobile_editor_toolbar": ": horizontally scrollable ribbon (headings, bold, italic, strikethrough, highlight, code, quote, lists, checkboxes, links, tags, attachments). The ⚙ icon lets you add, remove or reorder commands; A−/A+ adjust the font size.",
"help.mobile_editor_toolbar_label": "Editing ribbon",
"help.mobile_editor_zoom": ": pinch with two fingers to zoom, drag the handle to resize the editing area.",
"help.mobile_editor_zoom_label": "Zoom & height",
"help.modal_title": "ObsiGate User Guide",
@@ -1307,10 +1307,40 @@
"mermaid.zoom_in": "Zoom in",
"mermaid.zoom_out": "Zoom out",
"mobile.all_vaults": "📂 All vaults",
"mobile_editor.add_command": "Add a command",
"mobile_editor.backlinks": "Backlinks",
"mobile_editor.bold": "Bold",
"mobile_editor.close": "Close",
"mobile_editor.cmd_attachment": "Attachment",
"mobile_editor.cmd_bold": "Bold",
"mobile_editor.cmd_bullet": "Bullet list",
"mobile_editor.cmd_checkbox": "Checkbox",
"mobile_editor.cmd_code": "Inline code",
"mobile_editor.cmd_codeblock": "Code block",
"mobile_editor.cmd_dedent": "Outdent",
"mobile_editor.cmd_files": "Template / files",
"mobile_editor.cmd_h1": "Heading 1",
"mobile_editor.cmd_h2": "Heading 2",
"mobile_editor.cmd_h3": "Heading 3",
"mobile_editor.cmd_h4": "Heading 4",
"mobile_editor.cmd_h5": "Heading 5",
"mobile_editor.cmd_h6": "Heading 6",
"mobile_editor.cmd_highlight": "Highlight",
"mobile_editor.cmd_indent": "Indent",
"mobile_editor.cmd_italic": "Italic",
"mobile_editor.cmd_link": "External link",
"mobile_editor.cmd_ordered": "Numbered list",
"mobile_editor.cmd_quote": "Quote",
"mobile_editor.cmd_redo": "Redo",
"mobile_editor.cmd_strike": "Strikethrough",
"mobile_editor.cmd_tag": "Tag",
"mobile_editor.cmd_undo": "Undo",
"mobile_editor.cmd_wikilink": "Internal link",
"mobile_editor.code": "Code",
"mobile_editor.customize": "Customize toolbar",
"mobile_editor.customize_reset": "Reset",
"mobile_editor.customize_title": "Customize toolbar",
"mobile_editor.done": "Done",
"mobile_editor.error": "Loading error",
"mobile_editor.font_larger": "Increase font size",
"mobile_editor.font_smaller": "Decrease font size",
@@ -1318,6 +1348,8 @@
"mobile_editor.link": "Link",
"mobile_editor.list": "Bullet list",
"mobile_editor.loading": "Loading…",
"mobile_editor.move_down": "Move down",
"mobile_editor.move_up": "Move up",
"mobile_editor.no_backlinks": "No backlinks",
"mobile_editor.no_headings": "No headings in this document",
"mobile_editor.no_next": "Last file in folder",
@@ -1326,6 +1358,7 @@
"mobile_editor.paste_hint": "Long-press the field then choose “Paste” (clipboard access denied).",
"mobile_editor.reading_enter": "Reading mode",
"mobile_editor.reading_exit": "Exit reading mode",
"mobile_editor.remove_command": "Remove",
"mobile_editor.resize": "Adjust editor height",
"mobile_editor.toc": "Table of contents",
"mobile_editor.toolbar": "Mobile editing toolbar",
+35 -2
View File
@@ -1143,8 +1143,8 @@
"help.mobile_editor_reading": " : plein écran sans en-tête ni barres latérales ; balayage horizontal pour passer au fichier suivant ou précédent.",
"help.mobile_editor_reading_label": "Mode lecture",
"help.mobile_editor_title": "📱 Édition mobile",
"help.mobile_editor_toolbar": " : gras, italique, code, liste à puces et lien. Les boutons A−/A+ ajustent la taille de la police.",
"help.mobile_editor_toolbar_label": "Barre d'outils flottante",
"help.mobile_editor_toolbar": " : ruban horizontal défilable (titres, gras, italique, barré, surligné, code, citation, listes, cases à cocher, liens, tags, pièces jointes). L'icône ⚙ permet d'ajouter, retirer ou réordonner les commandes ; A−/A+ ajustent la taille de la police.",
"help.mobile_editor_toolbar_label": "Ruban d'édition",
"help.mobile_editor_zoom": " : pincement à deux doigts pour zoomer, poignée pour redimensionner la zone d'édition.",
"help.mobile_editor_zoom_label": "Zoom & hauteur",
"help.modal_title": "Guide d'utilisation ObsiGate",
@@ -1307,10 +1307,40 @@
"mermaid.zoom_in": "Zoom avant",
"mermaid.zoom_out": "Zoom arrière",
"mobile.all_vaults": "📂 Toutes les vaults",
"mobile_editor.add_command": "Ajouter une commande",
"mobile_editor.backlinks": "Liens entrants",
"mobile_editor.bold": "Gras",
"mobile_editor.close": "Fermer",
"mobile_editor.cmd_attachment": "Pièce jointe",
"mobile_editor.cmd_bold": "Gras",
"mobile_editor.cmd_bullet": "Liste à puces",
"mobile_editor.cmd_checkbox": "Case à cocher",
"mobile_editor.cmd_code": "Code en ligne",
"mobile_editor.cmd_codeblock": "Bloc de code",
"mobile_editor.cmd_dedent": "Désindenter",
"mobile_editor.cmd_files": "Modèle / fichiers",
"mobile_editor.cmd_h1": "Titre 1",
"mobile_editor.cmd_h2": "Titre 2",
"mobile_editor.cmd_h3": "Titre 3",
"mobile_editor.cmd_h4": "Titre 4",
"mobile_editor.cmd_h5": "Titre 5",
"mobile_editor.cmd_h6": "Titre 6",
"mobile_editor.cmd_highlight": "Surligné",
"mobile_editor.cmd_indent": "Indenter",
"mobile_editor.cmd_italic": "Italique",
"mobile_editor.cmd_link": "Lien externe",
"mobile_editor.cmd_ordered": "Liste numérotée",
"mobile_editor.cmd_quote": "Citation",
"mobile_editor.cmd_redo": "Refaire",
"mobile_editor.cmd_strike": "Barré",
"mobile_editor.cmd_tag": "Tag",
"mobile_editor.cmd_undo": "Annuler",
"mobile_editor.cmd_wikilink": "Lien interne",
"mobile_editor.code": "Code",
"mobile_editor.customize": "Personnaliser la barre",
"mobile_editor.customize_reset": "Réinitialiser",
"mobile_editor.customize_title": "Personnaliser la barre d'outils",
"mobile_editor.done": "Terminé",
"mobile_editor.error": "Erreur de chargement",
"mobile_editor.font_larger": "Agrandir la police",
"mobile_editor.font_smaller": "Réduire la police",
@@ -1318,6 +1348,8 @@
"mobile_editor.link": "Lien",
"mobile_editor.list": "Liste à puces",
"mobile_editor.loading": "Chargement…",
"mobile_editor.move_down": "Descendre",
"mobile_editor.move_up": "Monter",
"mobile_editor.no_backlinks": "Aucun lien entrant",
"mobile_editor.no_headings": "Aucun titre dans ce document",
"mobile_editor.no_next": "Dernier fichier du dossier",
@@ -1326,6 +1358,7 @@
"mobile_editor.paste_hint": "Appuyez longuement dans le champ puis choisissez « Coller » (accès au presse-papiers refusé).",
"mobile_editor.reading_enter": "Mode lecture",
"mobile_editor.reading_exit": "Quitter le mode lecture",
"mobile_editor.remove_command": "Supprimer",
"mobile_editor.resize": "Ajuster la hauteur de l'éditeur",
"mobile_editor.toc": "Table des matières",
"mobile_editor.toolbar": "Barre d'outils d'édition mobile",
+199 -42
View File
@@ -3440,23 +3440,62 @@ select {
height: 36px;
min-width: 36px;
}
/* BUG-018: keep the Save / Cancel / Delete buttons reachable on small
screens by shrinking the inputs and hiding secondary header items. */
.editor-brand,
.editor-sep,
.editor-spacer,
.collab-presence {
display: none !important;
}
.editor-title-input {
flex: 1 1 60px;
min-width: 0;
font-size: 13px;
}
.editor-filename-input {
width: auto;
flex: 0 1 84px;
min-width: 0;
font-size: 11px;
}
.editor-save-dot {
margin-right: 2px;
padding: 0;
}
#editor-save-label {
display: none;
}
.editor-header .editor-btn {
flex: 0 0 auto;
}
.editor-body {
flex: 1;
min-height: 0;
overflow: auto;
/* BUG-020: only the CodeMirror scroller scrolls — no double scrollbar. */
overflow: hidden;
display: flex;
flex-direction: column;
}
#ai-toolbar-container {
flex: 0 0 auto;
}
.cm-editor {
flex: 1 1 auto;
height: auto;
min-height: 100%;
min-height: 0;
}
.cm-scroller {
overflow: auto !important;
height: auto;
min-height: 100%;
overflow-y: auto !important;
overflow-x: hidden !important;
/* BUG-019: keep the caret clear of the fixed ribbon. */
padding-bottom: 72px;
}
.fallback-editor {
min-height: 100%;
height: auto;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding-bottom: 72px;
}
.search-results-header {
align-items: flex-start;
@@ -6102,6 +6141,10 @@ body.popup-mode .content-area {
gap: 4px;
animation: fadeSlideIn 0.3s ease forwards;
opacity: 0;
/* BUG-017: allow the card to shrink inside the grid instead of overflowing
the viewport because of long nowrap titles/paths. */
min-width: 0;
overflow: hidden;
}
.dashboard-card:hover {
@@ -6160,6 +6203,7 @@ body.popup-mode .content-area {
align-items: flex-start;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.dashboard-card-icon {
@@ -6253,6 +6297,7 @@ body.popup-mode .content-area {
margin-top: 4px;
padding-top: 6px;
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
min-width: 0;
}
.dashboard-card-time {
@@ -9482,64 +9527,176 @@ body.bookslm-resizing { cursor: ew-resize; user-select: none; }
shortcuts and distraction-free reading mode.
══════════════════════════════════════════ */
/* ── Floating formatting toolbar (inside editor modal) ── */
/* ── Obsidian-style editing ribbon (ROADMAP #83) ── */
.me-toolbar {
display: none;
}
.me-btn {
.me-ribbon {
--kb-offset: 0px;
}
.me-ribbon-scroll {
display: flex;
align-items: center;
gap: 4px;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding: 2px 2px;
min-width: 0;
flex: 1;
}
.me-ribbon-scroll::-webkit-scrollbar {
display: none;
}
.me-ribbon-btn {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 40px;
padding: 0 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-secondary);
min-width: 42px;
height: 42px;
padding: 0 8px;
border: 1px solid transparent;
border-radius: 10px;
background: transparent;
color: var(--text-primary);
font-size: 15px;
font-size: 14px;
font-weight: 600;
font-family: var(--font-mono);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background 120ms ease, transform 120ms ease;
}
.me-btn:active {
transform: scale(0.94);
background: var(--bg-hover, rgba(128, 128, 128, 0.18));
.me-ribbon-btn:active {
transform: scale(0.92);
background: rgba(128, 128, 128, 0.22);
}
.me-btn-format {
font-family: var(--font-mono);
.me-ribbon-gear {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border: none;
border-left: 1px solid var(--border);
border-radius: 0;
background: transparent;
color: var(--text-muted);
font-size: 18px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.me-btn-paste {
margin-left: auto;
background: var(--accent);
color: #fff;
border-color: var(--accent);
.me-ribbon-gear:active {
color: var(--accent);
}
.me-sep {
width: 1px;
height: 26px;
background: var(--border);
flex-shrink: 0;
/* Ribbon customiser rows */
.me-ribbon-customizer-list {
max-height: 50vh;
overflow-y: auto;
}
.me-ribbon-cmd-row {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-bottom: 1px solid var(--border);
}
.me-ribbon-cmd-row.disabled {
opacity: 0.75;
}
.me-ribbon-cmd-label {
flex: 1;
font-size: 0.85rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.me-ribbon-cmd-move,
.me-ribbon-cmd-remove,
.me-ribbon-cmd-add {
flex: 0 0 auto;
width: 34px;
height: 34px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
cursor: pointer;
}
.me-ribbon-cmd-move:disabled {
opacity: 0.35;
}
.me-ribbon-cmd-remove {
color: var(--danger);
}
.me-ribbon-cmd-divider {
padding: 10px;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.me-ribbon-customizer-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px;
border-top: 1px solid var(--border);
}
/* BUG-018: the in-editor AI toolbar was too dense on small screens. */
@media (max-width: 768px) {
.ai-toolbar {
gap: 4px !important;
padding: 6px 8px !important;
}
.ai-toolbar-btn {
min-height: 34px;
padding: 6px 10px !important;
font-size: 0.78rem !important;
}
}
@media (max-width: 768px) {
.editor-modal.active .me-toolbar {
.editor-modal.active .me-ribbon {
display: flex;
position: fixed;
left: 0;
right: 0;
bottom: 0;
left: 8px;
right: 8px;
/* BUG-019: sit just above the on-screen keyboard. */
bottom: calc(var(--kb-offset, 0px) + 8px + env(safe-area-inset-bottom, 0));
z-index: 30;
gap: 6px;
align-items: center;
padding: 8px 10px;
padding-bottom: calc(8px + env(safe-area-inset-bottom, 0));
background: var(--bg-primary);
border-top: 1px solid var(--border);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
gap: 4px;
padding: 4px;
background: #1c1c1e;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
}
.me-ribbon-btn {
color: #e8e8ea;
}
.me-ribbon-gear {
color: #b8b8bd;
}
[data-theme="light"] .me-ribbon {
background: #f1f1f4;
border-color: rgba(0, 0, 0, 0.08);
}
[data-theme="light"] .me-ribbon-btn {
color: #1c1c1e;
}
[data-theme="light"] .me-ribbon-gear {
color: #5a5a60;
}
}