Skip to content
Source
Sign inSign up

composer-text-direction.ts

90 lines3.2 KB

1import { Extension } from "@tiptap/core";
2import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
3import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
4import { Decoration, DecorationSet } from "@tiptap/pm/view";
5​
6import { textDirection, type TextDirection } from "~/textDirection";
7​
8interface ComposerTextDirections {
9 /** One entry per top-level block. */
10 readonly blocks: ReadonlyArray<TextDirection | null>;
11 readonly decorations: DecorationSet;
13​
14// Nodes are immutable and blocks are shared between documents, so a keystroke
15// only recounts the block it changed, and every reader of one document shares
16// one result.
17const blockDirections = new WeakMap<ProseMirrorNode, TextDirection | null>();
18const docDirections = new WeakMap<ProseMirrorNode, ComposerTextDirections>();
19​
20function proseText(block: ProseMirrorNode): string {
21 let text = "";
22 block.descendants((node) => {
23 if (node.isText) {
24 text += node.marks.some((mark) => mark.type.spec.code) ? " " : node.text;
25 } else if (node.isBlock || node.isAtom) {
26 text += " ";
27 }
28 });
29 return text;
31​
32function blockDirection(block: ProseMirrorNode): TextDirection | null {
33 let direction = blockDirections.get(block);
34 if (direction === undefined) {
35 direction = textDirection(proseText(block)) ?? null;
36 blockDirections.set(block, direction);
37 }
38 return direction;
40​
41/**
42 * Lines inside a list or quote follow the block around them, which keeps the
43 * bullets and the quote rule on the same side as the text. A line without
44 * letters yet continues the one above, so a new line after Arabic starts on
45 * the right. Code blocks stay left to right.
46 */
47export function composerTextDirections(doc: ProseMirrorNode): ComposerTextDirections {
48 const cached = docDirections.get(doc);
49 if (cached) return cached;
50 const blocks: Array<TextDirection | null> = [];
51 const decorations: Decoration[] = [];
52 let previous: TextDirection | null = null;
53 doc.forEach((block, offset) => {
54 if (block.type.spec.code) {
55 blocks.push("ltr");
56 return;
57 }
58 const dir = blockDirection(block) ?? previous;
59 blocks.push(dir);
60 if (dir) decorations.push(Decoration.node(offset, offset + block.nodeSize, { dir }));
61 previous = dir;
62 });
63 const directions = { blocks, decorations: DecorationSet.create(doc, decorations) };
64 docDirections.set(doc, directions);
65 return directions;
67​
68export function caretLineDirection(state: EditorState): TextDirection | null {
69 return composerTextDirections(state.doc).blocks[state.selection.$head.index(0)] ?? null;
71​
72export const ComposerTextDirectionExtension = Extension.create({
73 name: "composer-text-direction",
74 addProseMirrorPlugins() {
75 return [
76 new Plugin({
77 key: new PluginKey("composer-text-direction"),
78 props: {
79 decorations: (state) => composerTextDirections(state.doc).decorations,
80 // On Chrome, ProseMirror reads the arrow keys' direction from the
81 // editor root, so the root follows the line holding the caret.
82 attributes: (state): Record<string, string> => {
83 const dir = caretLineDirection(state);
84 return dir ? { dir } : {};
85 },
86 },
87 }),
88 ];
89 },
90});