From 08bb2163340e77ab2ffe3a498c032b441a0e0e63 Mon Sep 17 00:00:00 2001 From: Ammar Ahmed Date: Sun, 9 Aug 2026 18:00:53 +0500 Subject: [PATCH] editor: apply text direction to a whole list, not just the cursor's item --- .../__tests__/text-direction.test.ts | 96 +++++++++++++++++++ .../text-direction/text-direction.ts | 38 +++++++- 2 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 packages/editor/src/extensions/text-direction/__tests__/text-direction.test.ts diff --git a/packages/editor/src/extensions/text-direction/__tests__/text-direction.test.ts b/packages/editor/src/extensions/text-direction/__tests__/text-direction.test.ts new file mode 100644 index 000000000..d0de5a1b0 --- /dev/null +++ b/packages/editor/src/extensions/text-direction/__tests__/text-direction.test.ts @@ -0,0 +1,96 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +import { describe, expect, test } from "vitest"; +import { createEditor } from "../../../../test-utils/index.js"; +import { TaskListNode } from "../../task-list/task-list.js"; +import { TaskItemNode } from "../../task-item/task-item.js"; +import { BulletList } from "../../bullet-list/bullet-list.js"; +import { ListItem } from "../../list-item/list-item.js"; +import { Paragraph } from "../../paragraph/paragraph.js"; +import { TextDirection } from "../text-direction.js"; + +function directions(editor: { + state: { doc: { descendants: (fn: (node: any) => void) => void } }; +}) { + const found: Record = {}; + editor.state.doc.descendants((node) => { + const dir = node.attrs.textDirection; + if (dir !== undefined) (found[node.type.name] ??= []).push(dir); + }); + return found; +} + +/** cursor into the first paragraph of the document */ +function cursorInFirstParagraph(editor: any) { + let pos = -1; + editor.state.doc.descendants((node: any, at: number) => { + if (pos === -1 && node.type.name === "paragraph") pos = at + 1; + }); + editor.commands.setTextSelection(pos); +} + +describe("text direction on lists", () => { + const cases = [ + { + name: "task list", + extensions: { + taskList: TaskListNode, + taskListItem: TaskItemNode.configure({ nested: true }), + paragraph: Paragraph + }, + content: `
  • one

  • two

` + }, + { + name: "bullet list", + extensions: { + bulletList: BulletList, + listItem: ListItem, + paragraph: Paragraph + }, + content: `
  • one

  • two

` + } + ]; + + for (const { name, extensions, content } of cases) { + test(`switching a ${name} to ltr clears the direction of every item`, () => { + const { editor } = createEditor({ + initialContent: content, + extensions: { + ...extensions, + textDirection: TextDirection.configure({ + types: ["paragraph", "taskList", "bulletList"] + }) + } + }); + + // every paragraph starts rtl, matching the list + const before = directions(editor); + expect(Object.values(before).flat()).toContain("rtl"); + + cursorInFirstParagraph(editor); + editor.commands.setTextDirection(undefined); + + // ...and nothing is left rtl — not the list, not any item, cursor or + // not, so the checkboxes/markers and the text no longer disagree + const after = directions(editor); + expect(Object.values(after).flat()).not.toContain("rtl"); + }); + } +}); diff --git a/packages/editor/src/extensions/text-direction/text-direction.ts b/packages/editor/src/extensions/text-direction/text-direction.ts index bbc67a1eb..2fda1f74d 100644 --- a/packages/editor/src/extensions/text-direction/text-direction.ts +++ b/packages/editor/src/extensions/text-direction/text-direction.ts @@ -114,10 +114,40 @@ export const TextDirection = Extension.create({ return { setTextDirection: (direction) => - ({ commands }) => { - return this.options.types.every((type) => - commands.updateAttributes(type, { textDirection: direction }) - ); + ({ state, tr, dispatch }) => { + const value = direction || ""; + const { $from, from, to } = state.selection; + + // Expand to the outermost block that carries a direction, so a + // whole task list turns together — every item's paragraph and + // all — instead of only the row the cursor is in. Otherwise the + // list's own direction flips while its items keep theirs, and the + // checkboxes and text end up on opposite sides. + let start = from; + let end = to; + for (let depth = $from.depth; depth > 0; depth--) { + if (this.options.types.includes($from.node(depth).type.name)) { + start = Math.min(start, $from.before(depth)); + end = Math.max(end, $from.after(depth)); + } + } + + let changed = false; + state.doc.nodesBetween(start, end, (node, pos) => { + if ( + !this.options.types.includes(node.type.name) || + node.attrs.textDirection === value + ) + return; + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + textDirection: value + }); + changed = true; + }); + + if (changed) dispatch?.(tr); + return changed; } }; }