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:
@@ -45,19 +45,19 @@ test.describe('Mobile editor (#69)', () => {
|
||||
await expect(page.locator('body')).not.toHaveClass(/reading-mode/);
|
||||
});
|
||||
|
||||
test('editor modal exposes the floating formatting toolbar', async ({ page, viewport }) => {
|
||||
test('editor modal exposes the Obsidian-style editing ribbon', async ({ page, viewport }) => {
|
||||
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
|
||||
await boot(page);
|
||||
|
||||
// Force the editor modal open — the toolbar visibility is pure CSS.
|
||||
// Force the editor modal open — the ribbon visibility is pure CSS.
|
||||
await page.evaluate(() => document.getElementById('editor-modal').classList.add('active'));
|
||||
|
||||
const toolbar = page.locator('#me-toolbar');
|
||||
await expect(toolbar).toBeVisible();
|
||||
for (const action of ['bold', 'italic', 'code', 'list', 'link']) {
|
||||
await expect(page.locator('#me-fmt-' + action)).toBeVisible();
|
||||
for (const cmd of ['bold', 'italic', 'strike', 'highlight', 'wikilink', 'checkbox', 'undo', 'redo']) {
|
||||
await expect(page.locator('#me-cmd-' + cmd)).toBeVisible();
|
||||
}
|
||||
await expect(page.locator('#me-paste')).toBeVisible();
|
||||
await expect(page.locator('#me-ribbon-gear')).toBeVisible();
|
||||
});
|
||||
|
||||
test('font zoom buttons persist the editor font size', async ({ page, viewport }) => {
|
||||
@@ -65,8 +65,8 @@ test.describe('Mobile editor (#69)', () => {
|
||||
await boot(page);
|
||||
await page.evaluate(() => document.getElementById('editor-modal').classList.add('active'));
|
||||
|
||||
await page.locator('#me-font-plus').click();
|
||||
await page.locator('#me-font-plus').click();
|
||||
await page.locator('#me-cmd-font_larger').click();
|
||||
await page.locator('#me-cmd-font_larger').click();
|
||||
const stored = await page.evaluate(() => localStorage.getItem('obsigate-editor-font-size'));
|
||||
expect(Number(stored)).toBeGreaterThan(13.5);
|
||||
});
|
||||
|
||||
@@ -52,11 +52,15 @@ const mod = await import(
|
||||
|
||||
const {
|
||||
FORMAT_ACTIONS,
|
||||
RIBBON_COMMANDS,
|
||||
DEFAULT_RIBBON_COMMANDS,
|
||||
MIN_EDITOR_FONT,
|
||||
MAX_EDITOR_FONT,
|
||||
clampZoom,
|
||||
detectSwipe,
|
||||
formatChange,
|
||||
normalizeRibbonCommands,
|
||||
moveRibbonCommand,
|
||||
insertText,
|
||||
applyFormatToTextarea,
|
||||
applyEditorFontSize,
|
||||
@@ -161,7 +165,7 @@ test("formatChange link builds [selection](url) and selects the url", () => {
|
||||
});
|
||||
|
||||
test("formatChange returns null for an unknown action", () => {
|
||||
assert.equal(formatChange("abc", 0, 0, "strike"), null);
|
||||
assert.equal(formatChange("abc", 0, 0, "nope"), null);
|
||||
});
|
||||
|
||||
test("insertText builds an insertion change at the cursor", () => {
|
||||
@@ -170,6 +174,62 @@ test("insertText builds an insertion change at the cursor", () => {
|
||||
assert.deepEqual(c.selection, { anchor: 8, head: 8 });
|
||||
});
|
||||
|
||||
// ── ROADMAP #83 — ribbon command transforms ────────────────────────────────
|
||||
test("formatChange strike and highlight wrap with the right markers", () => {
|
||||
assert.equal(formatChange("x", 0, 1, "strike").changes.insert, "~~x~~");
|
||||
assert.equal(formatChange("x", 0, 1, "highlight").changes.insert, "==x==");
|
||||
});
|
||||
|
||||
test("formatChange wikilink and attachment wrap with brackets", () => {
|
||||
assert.equal(formatChange("Note", 0, 4, "wikilink").changes.insert, "[[Note]]");
|
||||
assert.equal(formatChange("img.png", 0, 7, "attachment").changes.insert, "![[img.png]]");
|
||||
});
|
||||
|
||||
test("formatChange tag prefixes a hash", () => {
|
||||
const c = formatChange("devops", 0, 6, "tag");
|
||||
assert.equal(c.changes.insert, "#devops");
|
||||
assert.deepEqual(c.selection, { anchor: 7, head: 7 });
|
||||
});
|
||||
|
||||
test("formatChange heading replaces an existing heading level", () => {
|
||||
const c = formatChange("# Title", 0, 7, "h2");
|
||||
assert.equal(c.changes.insert, "## Title");
|
||||
});
|
||||
|
||||
test("formatChange heading toggles off the same level", () => {
|
||||
const c = formatChange("## Title", 0, 8, "h2");
|
||||
assert.equal(c.changes.insert, "Title");
|
||||
});
|
||||
|
||||
test("formatChange quote, bullet, checkbox and ordered list prefix lines", () => {
|
||||
assert.equal(formatChange("a\nb", 0, 3, "quote").changes.insert, "> a\n> b");
|
||||
assert.equal(formatChange("a\nb", 0, 3, "bullet").changes.insert, "- a\n- b");
|
||||
assert.equal(formatChange("a\nb", 0, 3, "checkbox").changes.insert, "- [ ] a\n- [ ] b");
|
||||
assert.equal(formatChange("a\nb", 0, 3, "ordered").changes.insert, "1. a\n2. b");
|
||||
});
|
||||
|
||||
test("formatChange codeblock wraps the selected block in fences", () => {
|
||||
const c = formatChange("code", 0, 4, "codeblock");
|
||||
assert.equal(c.changes.insert, "```\ncode\n```");
|
||||
});
|
||||
|
||||
test("formatChange indent and dedent shift lines", () => {
|
||||
assert.equal(formatChange("a\nb", 0, 3, "indent").changes.insert, " a\n b");
|
||||
assert.equal(formatChange(" a\n\tb", 0, 6, "dedent").changes.insert, "a\nb");
|
||||
});
|
||||
|
||||
test("normalizeRibbonCommands filters unknown ids and falls back to default", () => {
|
||||
assert.deepEqual(normalizeRibbonCommands(["bold", "nope", "bold"]), ["bold"]);
|
||||
assert.deepEqual(normalizeRibbonCommands([]), [...DEFAULT_RIBBON_COMMANDS]);
|
||||
assert.deepEqual(normalizeRibbonCommands(null), [...DEFAULT_RIBBON_COMMANDS]);
|
||||
});
|
||||
|
||||
test("moveRibbonCommand reorders within bounds", () => {
|
||||
assert.deepEqual(moveRibbonCommand(["a", "b", "c"], "b", -1), ["b", "a", "c"]);
|
||||
assert.deepEqual(moveRibbonCommand(["a", "b", "c"], "c", 1), ["a", "b", "c"]);
|
||||
assert.deepEqual(moveRibbonCommand(["a", "b"], "a", -1), ["a", "b"]);
|
||||
});
|
||||
|
||||
// ── Textarea application ───────────────────────────────────────────────────
|
||||
test("applyFormatToTextarea formats the current textarea selection", () => {
|
||||
const ta = w.document.createElement("textarea");
|
||||
@@ -187,17 +247,29 @@ test("applyFormatToTextarea returns false without an element", () => {
|
||||
});
|
||||
|
||||
// ── DOM wiring ─────────────────────────────────────────────────────────────
|
||||
test("initMobileEditor builds the floating toolbar with the five actions", () => {
|
||||
test("initMobileEditor builds the ribbon toolbar with the gear button", () => {
|
||||
initMobileEditor();
|
||||
const bar = w.document.getElementById("me-toolbar");
|
||||
assert.ok(bar, "toolbar exists");
|
||||
for (const action of FORMAT_ACTIONS) {
|
||||
assert.ok(w.document.getElementById("me-fmt-" + action), action + " button exists");
|
||||
assert.ok(bar.classList.contains("me-ribbon"), "uses the ribbon style");
|
||||
assert.ok(w.document.querySelector("#me-toolbar .me-ribbon-scroll"), "scroll container exists");
|
||||
for (const id of DEFAULT_RIBBON_COMMANDS) {
|
||||
assert.ok(w.document.getElementById("me-cmd-" + id), id + " command exists");
|
||||
}
|
||||
assert.ok(w.document.getElementById("me-paste"), "paste button exists");
|
||||
assert.ok(w.document.getElementById("me-ribbon-gear"), "gear button exists");
|
||||
assert.ok(w.document.getElementById("me-resize-handle"), "resize handle exists");
|
||||
});
|
||||
|
||||
test("ribbon customiser opens and lists commands", () => {
|
||||
const gear = w.document.getElementById("me-ribbon-gear");
|
||||
gear.click();
|
||||
const sheet = w.document.getElementById("me-ribbon-customizer");
|
||||
assert.ok(sheet, "customiser sheet exists");
|
||||
assert.ok(sheet.classList.contains("open"), "customiser is open");
|
||||
assert.ok(sheet.querySelectorAll(".me-ribbon-cmd-row").length >= RIBBON_COMMANDS.length,
|
||||
"every command is listed");
|
||||
});
|
||||
|
||||
test("initMobileEditor is idempotent", () => {
|
||||
initMobileEditor();
|
||||
const bars = w.document.querySelectorAll("#me-toolbar");
|
||||
|
||||
Reference in New Issue
Block a user