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:
+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 ──────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user