feat(mobile): ruban d'edition style Obsidian + correctifs mobile (#83, BUG-017 a BUG-020)
- #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:
@@ -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
@@ -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 ──────────────────────────────────────
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user