Web Totals

Học từ Facebook #98: Phím tắt Markdown, Backspace để hoàn tác

Gõ "# " thành tiêu đề, "- " thành danh sách, Backspace để lấy lại chữ đã gõ. Cách editor Draft.js của Facebook làm, hai lỗi khi dán Markdown.

hunghg255hunghg25530 min read
#facebook#editor#markdown#draftjs#ux
Học từ Facebook #98: Phím tắt Markdown, Backspace để hoàn tác

Notion, Slack, Google Docs, ô soạn bài của Facebook Workplace: gõ # ở đầu dòng là dòng đó thành tiêu đề, gõ - là thành danh sách. Rất tiện, cho tới khi bạn thật sự muốn viết "# hashtag" hay "- 5 độ" và editor cứ tự đổi kiểu. Editor tốt cho một lối thoát: bấm Backspace ngay sau đó là chữ # quay lại nguyên vẹn, dòng trở về văn bản thường.

Nghe đơn giản, nhưng có ba câu hỏi: khi nào thì coi là "vừa gõ tiền tố" (không phải dán, không phải sửa giữa dòng)? Backspace nào là "hoàn tác", Backspace nào là "xoá kiểu"? Và khi dán cả đoạn Markdown nhiều dòng thì sao? Bài này đọc RichTextEditorMarkdownUtil của Facebook, một tiện ích trên Draft.js, để trả lời.

Tip

Tóm tắt

  • Một bảng tiền tố (# , ## , > , ```, 4 dấu cách, [ ] , [x] , - , * , + , 1. , 1) ) ánh xạ sang kiểu block. Chỉ đổi khi lần thay đổi gần nhất là gõ ký tự, selection thu gọn, caret đứng đúng ở cuối tiền tố và không quá 4 ký tự từ đầu dòng.
  • Lần đổi kiểu được ghi vào lịch sử với một nhãn riêng (replaced-markdown-text-with-block-style). Backspace ở đầu dòng mà thay đổi gần nhất còn mang nhãn đó thì gọi undo, trả lại chữ đã gõ.
  • Dán Markdown nhiều dòng dùng regex sinh từ cùng bảng tiền tố. Hai lỗi: "2019. Một năm" thành danh sách số và mất chữ 2019; dòng thụt vào sau một đoạn văn vẫn thành danh sách con.
  • Bản viết lại không cần Draft.js: model block thuần, ảnh chụp "trước khi đổi kiểu" thay cho undo stack, và luật dán theo CommonMark.

#Facebook làm thế nào?

Module RichTextEditorMarkdownUtil nằm trong bundle 294.js, dùng EditorState, DraftModifier, ContentBlock của Draft.js (thư viện editor mã nguồn mở của Facebook, nay đã ngừng phát triển và được thay bằng Lexical).

#Bảng tiền tố

const lists = [
  { text: '* ', type: 'unordered-list-item', isListStart: true },
  { text: '- ', type: 'unordered-list-item', isListStart: true },
  { text: '+ ', type: 'unordered-list-item', isListStart: true },
  { text: '1. ', type: 'ordered-list-item', isListStart: true },
  { text: '1) ', type: 'ordered-list-item', isListStart: true },
];
const shortcuts = [
  { text: '# ', type: 'header-one' }, { text: '## ', type: 'header-two' }, { text: '> ', type: 'blockquote' },
  ...codeBlocks,  // '```', '``` ' (isMultilineCodeBlockMarker), '    ' (4 dấu cách)
  { text: '[] ', type: 'checkbox-list-item', data: { isChecked: false } }, { text: '[ ] ', … }, { text: '[x] ', … }, { text: '[X] ', … },
  ...lists,
];
js

Bảng được lọc theo cấu hình của editor: checkbox chỉ bật khi có enableCheckboxes (mặc định tắt), code block bật trừ khi enableCodeBlock: false, và người dùng module có thể thêm markdownReplacementOverrides (khử trùng theo text, bản ghi đè đứng trước). Nhóm code block còn phụ thuộc hai cờ gatekeeper (gkx), tức tính năng đang được thử dần.

#Khi nào đổi kiểu

function textToBlockStyle(editorState, config) {
  return editorState.getLastChangeType() === 'insert-characters' ? tryReplace(editorState, config) : null;
}

function tryReplace(editorState, config) {
  const sel = editorState.getSelection();
  const block = currentBlock(editorState);
  if (!sel.isCollapsed() || sel.getStartOffset() > 4) return null;
  for (const s of candidatesFor(block.getType(), config)) {
    if (sel.getStartOffset() === s.text.length && block.getText().startsWith(s.text)) {
      return EditorState.forceSelection(
        EditorState.push(editorState, replaceWithStyle(editorState, s), 'replaced-markdown-text-with-block-style'),
        selectionAt(editorState, 0),
      );
    }
  }
  return null;
}
js

Bốn điều kiện cùng lúc: vừa gõ (dán thì lastChangeType là insert-fragment), không có vùng chọn, caret ở vị trí ≤ 4, và caret nằm đúng cuối tiền tố. Điều kiện cuối là mấu chốt: gõ # rồi dấu cách ở đầu dòng "hello" (caret ở 2) thì đổi; nhưng sửa chữ ở giữa một dòng đã bắt đầu bằng # thì không, vì caret không đứng ở vị trí 2.

candidatesFor(type) chỉ trả bảng tiền tố khi block đang là unstyled. Block đã là danh sách thì mặc định không có tiền tố nào; chỉ khi editor bật enableNestingListsInline, danh sách mới nhận thêm các tiền tố danh sách và một tiền tố là một dấu cách. Khi đó gõ - trong một mục gạch đầu dòng sẽ thụt vào một mức (depth + 1, tối đa RichComposedTextEditorMaxNestingLevels) thay vì đổi kiểu.

replaceWithStyle làm hai việc trong một ContentState: setBlockType rồi replaceText(…, '') xoá tiền tố, kèm setBlockData cho checkbox (isChecked).

#Backspace để hoàn tác

function blockStyleToText(command, editorState) {
  if (!['backspace', 'backspace-word', 'backspace-to-start-of-line'].includes(command)) return null;
  if (editorState.getLastChangeType() !== 'replaced-markdown-text-with-block-style') return null;
  const sel = editorState.getSelection();
  if (!(sel.isCollapsed() && sel.getStartOffset() === 0)) return null;
  const undo = editorState.getUndoStack();
  if (!(undo.size && sel.getStartKey() === undo.first().getSelectionAfter().getStartKey())) return null;
  return EditorState.undo(editorState);
}
js

Đây là chỗ khéo nhất. Draft.js lưu lịch sử theo "change type": mỗi lần push với một loại thay đổi khác loại trước sẽ tạo một mục undo mới. Đặt cho thao tác đổi kiểu một loại riêng thì:

  1. Mục undo trên cùng chính là trạng thái ngay trước khi đổi kiểu, tức dòng còn chữ # .
  2. getLastChangeType() cho biết thao tác gần nhất có phải là đổi kiểu không. Gõ thêm một chữ là loại thay đổi thành insert-characters, Backspace không còn là hoàn tác nữa mà chỉ là xoá kiểu block (Draft.js mặc định).
  3. Kiểm tra key của block ở đỉnh undo stack để chắc chắn Backspace đang ở đúng dòng vừa đổi.

Không cần biến trạng thái riêng nào. Cái giá là phụ thuộc chặt vào mô hình lịch sử của Draft.js.

#Dán Markdown nhiều dòng

formatWithStyles duyệt từng block sau khi dán. Regex danh sách được sinh từ bảng: escape ký tự đặc biệt, rồi đổi chữ 1 thành \d+:

const listRegex = (type) => '(?:' + shortcuts
  .filter((s) => s.isListStart && s.type === type)
  .map((s) => s.text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/1/g, '\\d+'))
  .join('|') + ')';
// ordered: ^((?:\d+\. |\d+\) ))   lồng: ^((?:\t| {4})+(?:\d+\. |\d+\) ))
js

Mức lồng là số đoạn khi split(/(?:\t| {4})/) phần thụt đầu dòng. Dòng ``` bật/tắt chế độ code nhiều dòng; trong chế độ đó mọi dòng đều là code-block.

Hai lỗi, kiểm bằng cách chạy lại đúng logic trên node:

  • \d+ nhận mọi số. Dán "2019. Một năm tốt" ra một mục danh sách số với chữ "Một năm tốt": con số 2019 bị xoá khỏi văn bản và danh sách tự đánh lại từ 1.
  • Biến lưu "đang trong danh sách" (e) chỉ được đặt lại khi gặp một tiền tố không phải danh sách. Dòng văn bản thường không đặt lại nó. Dán "- a / Đoạn văn /     - b" thì "b" thành mục con mức 1, dù phía trên nó là một đoạn văn chứ không phải danh sách.
sequenceDiagram
  participant U as Người dùng
  participant E as Editor
  participant H as Lịch sử
  U->>E: gõ "#", rồi " "
  E->>E: caret = 2 = độ dài "# ", block bắt đầu bằng "# "
  E->>H: push, nhãn "replaced-markdown…"
  E-->>U: dòng thành H1, chữ trống
  alt Backspace ngay
    U->>E: Backspace ở offset 0
    E->>H: nhãn gần nhất vẫn là "replaced-markdown…"
    H-->>E: undo, trả lại "# "
  else gõ thêm chữ rồi mới Backspace
    U->>E: gõ "Tiêu đề", Home, Backspace
    E-->>U: chỉ bỏ kiểu H1, giữ "Tiêu đề"
  end

#Bên trong trình duyệt

contenteditable thuần không có khái niệm "block có kiểu" hay lịch sử theo loại thay đổi: undo của trình duyệt (execCommand('undo')) gom các thao tác theo heuristic riêng và bị xoá ngay khi JavaScript sửa DOM. Vì vậy mọi editor nghiêm túc (Draft.js, Lexical, ProseMirror, Slate) đều giữ model riêng và tự quản lý undo.

Thông tin "đây là gõ phím hay dán" đến từ InputEvent.inputType: insertText khi gõ, insertFromPaste khi dán, insertCompositionText khi đang gõ bằng bộ gõ (IME, như Telex trên một số hệ điều hành hay gõ tiếng Nhật). Phím tắt Markdown chỉ nên chạy với insertText. Đặc biệt với bộ gõ: trong lúc composition, chữ chưa "chốt", đổi kiểu block lúc đó làm hỏng chuỗi đang gõ.

#Viết lại

Model: mảng block { key, type, text, depth, checked }, caret { key, offset }, loại thay đổi gần nhất và ảnh chụp block trước khi đổi kiểu. Ảnh chụp đó thay cho undo stack của Draft.js.

src/lib/mdShortcuts.ts
/** Tiền tố nào khớp với block tại vị trí caret, hoặc null. */
export function matchShortcut(b: Block, offset: number, shortcuts = SHORTCUTS): Shortcut | null {
  if (offset > MAX_PREFIX) return null;
  for (const s of shortcuts) {
    if (offset !== s.prefix.length || !b.text.startsWith(s.prefix)) continue;
    if (b.type === 'unstyled') return s;
    if (s.list && s.type === b.type) return s; // "- " trong danh sách gạch đầu dòng: thụt vào
  }
  return null;
}

/**
 * Người dùng sửa chữ trong một block. `inserted` = lần sửa này là gõ thêm ký tự
 * (InputEvent.inputType === 'insertText'); dán, xoá, IME thì không đổi kiểu block.
 */
export function editText(ed: Editor, key: string, text: string, offset: number, inserted: boolean): Editor {
  const i = indexOf(ed, key);
  const b = { ...ed.blocks[i], text };
  const s = inserted ? matchShortcut(b, offset) : null;
  if (!s) {
    return { blocks: replaceAt(ed.blocks, i, b), caret: { key, offset }, last: inserted ? 'insert' : 'other', undo: ed.undo };
  }
  const nested = b.type === s.type;
  const styled: Block = {
    ...b,
    type: s.type,
    text: text.slice(s.prefix.length),
    depth: nested ? Math.min(MAX_DEPTH, b.depth + 1) : b.depth,
    ...(s.type === 'checkbox-list-item' ? { checked: !!s.checked } : {}),
  };
  return {
    blocks: replaceAt(ed.blocks, i, styled),
    caret: { key, offset: 0 },
    last: 'markdown',
    undo: { before: b, offset },
  };
}
ts

Backspace ở đầu block: hoàn tác nếu vừa đổi kiểu, bỏ một mức thụt hoặc bỏ kiểu nếu block có kiểu, nối vào block trước nếu là văn bản thường:

src/lib/mdShortcuts.ts
/**
 * Backspace khi caret ở đầu block. Trả null nếu để trình duyệt tự xử lý
 * (caret không ở đầu, hoặc là block đầu tiên không có kiểu).
 */
export function backspaceAtStart(ed: Editor): Editor | null {
  const { key, offset } = ed.caret;
  if (offset !== 0) return null;
  const i = indexOf(ed, key);
  const b = ed.blocks[i];
  // 1. vừa đổi kiểu bằng tiền tố: trả lại đúng chữ đã gõ
  if (ed.last === 'markdown' && ed.undo?.before.key === key) {
    return {
      blocks: replaceAt(ed.blocks, i, ed.undo.before),
      caret: { key, offset: ed.undo.offset },
      last: 'other',
      undo: null,
    };
  }
  // 2. block có kiểu: bỏ một mức thụt, rồi bỏ kiểu
  if (b.type !== 'unstyled') {
    const next: Block =
      isList(b.type) && b.depth > 0 ? { ...b, depth: b.depth - 1 } : { key: b.key, type: 'unstyled', text: b.text, depth: 0 };
    return { blocks: replaceAt(ed.blocks, i, next), caret: { key, offset: 0 }, last: 'other', undo: null };
  }
  // 3. block thường: nối vào block trước
  if (i === 0) return null;
  const prev = ed.blocks[i - 1];
  const merged = { ...prev, text: prev.text + b.text };
  return {
    blocks: [...ed.blocks.slice(0, i - 1), merged, ...ed.blocks.slice(i + 1)],
    caret: { key: prev.key, offset: prev.text.length },
    last: 'other',
    undo: null,
  };
}
ts

Dán Markdown: danh sách số khác 1 chỉ được nhận khi dòng trước đã là danh sách số; mức lồng chỉ có nghĩa khi dòng trước là mục danh sách:

src/lib/mdShortcuts.ts
export function markdownToBlocks(text: string, o: PasteOptions = {}): Block[] {
  const out: Block[] = [];
  let inFence = false;
  let listOpen = false; // dòng trước có phải mục danh sách không
  let prevOrdered = false;

  for (const line of text.replace(/\r\n?/g, '\n').split('\n')) {
    if (line.startsWith('```')) {
      inFence = !inFence;
      continue;
    }
    if (inFence) {
      out.push(block(line, 'code-block'));
      continue;
    }
    const { depth, rest } = leadingIndent(line);
    const canNest = listOpen && depth > 0;
    const body = depth > 0 && canNest ? rest : line;
    const lineDepth = canNest ? Math.min(depth, MAX_DEPTH) : 0;

    const bullet = BULLET.exec(body);
    const ordered = ORDERED.exec(body);
    if (bullet && (depth === 0 || canNest)) {
      out.push(block(body.slice(bullet[0].length), 'unordered-list-item', lineDepth));
      listOpen = true;
      prevOrdered = false;
      continue;
    }
    if (ordered && (depth === 0 || canNest) && (o.anyOrderedStart || ordered[1] === '1' || prevOrdered)) {
      out.push(block(body.slice(ordered[0].length), 'ordered-list-item', lineDepth));
      listOpen = true;
      prevOrdered = true;
      continue;
    }
    const s = depth === 0 ? SHORTCUTS.find((x) => !x.list && !x.fence && line.startsWith(x.prefix)) : undefined;
    if (s) {
      out.push(block(line.slice(s.prefix.length), s.type, 0, s.checked));
      listOpen = false;
    } else if (depth > 0 && !canNest) {
      out.push(block(rest, 'code-block')); // 4 dấu cách đầu dòng ngoài danh sách: code
      listOpen = false;
    } else {
      out.push(block(line));
      listOpen = o.keepNestingAcrossParagraphs ? listOpen : false;
    }
    prevOrdered = false;
  }
  return out.length ? out : [block()];
}
ts
Xem toàn bộ src/lib/mdShortcuts.ts
src/lib/mdShortcuts.ts
/**
 * Phím tắt Markdown trong editor dạng block: gõ "# " thành tiêu đề, "- " thành danh sách...
 * Backspace ngay sau đó để hoàn tác. Kèm bộ chuyển Markdown nhiều dòng khi dán.
 * Ý tưởng học từ RichTextEditorMarkdownUtil (Draft.js) của Facebook, viết lại từ đầu,
 * không phụ thuộc Draft.js.
 */

export type BlockType =
  | 'unstyled'
  | 'header-one'
  | 'header-two'
  | 'blockquote'
  | 'code-block'
  | 'unordered-list-item'
  | 'ordered-list-item'
  | 'checkbox-list-item';

export interface Block {
  key: string;
  type: BlockType;
  text: string;
  depth: number;
  checked?: boolean;
}

export interface Shortcut {
  prefix: string;
  type: BlockType;
  checked?: boolean;
  /** bắt đầu danh sách: gõ lại trong danh sách cùng loại thì thụt vào một mức */
  list?: boolean;
  /** ``` bật/tắt khối code nhiều dòng khi dán */
  fence?: boolean;
}

export const SHORTCUTS: readonly Shortcut[] = [
  { prefix: '# ', type: 'header-one' },
  { prefix: '## ', type: 'header-two' },
  { prefix: '> ', type: 'blockquote' },
  { prefix: '```', type: 'code-block', fence: true },
  { prefix: '    ', type: 'code-block' },
  { prefix: '[] ', type: 'checkbox-list-item', checked: false },
  { prefix: '[ ] ', type: 'checkbox-list-item', checked: false },
  { prefix: '[x] ', type: 'checkbox-list-item', checked: true },
  { prefix: '[X] ', type: 'checkbox-list-item', checked: true },
  { prefix: '* ', type: 'unordered-list-item', list: true },
  { prefix: '- ', type: 'unordered-list-item', list: true },
  { prefix: '+ ', type: 'unordered-list-item', list: true },
  { prefix: '1. ', type: 'ordered-list-item', list: true },
  { prefix: '1) ', type: 'ordered-list-item', list: true },
];

/** tiền tố dài nhất; caret xa hơn thì khỏi kiểm tra */
const MAX_PREFIX = Math.max(...SHORTCUTS.map((s) => s.prefix.length));
export const MAX_DEPTH = 4;
const LISTS: BlockType[] = ['unordered-list-item', 'ordered-list-item', 'checkbox-list-item'];
export const isList = (t: BlockType) => LISTS.includes(t);

let seq = 0;
export const newKey = () => `b${++seq}`;
export const block = (text = '', type: BlockType = 'unstyled', depth = 0, checked?: boolean): Block => ({
  key: newKey(),
  type,
  text,
  depth,
  ...(type === 'checkbox-list-item' ? { checked: !!checked } : {}),
});

// ---------------------------------------------------------------- editor

export type LastChange = 'insert' | 'markdown' | 'other';

export interface Editor {
  blocks: Block[];
  caret: { key: string; offset: number };
  last: LastChange;
  /** trạng thái ngay trước lần đổi kiểu block gần nhất, để Backspace hoàn tác */
  undo: { before: Block; offset: number } | null;
}

export function createEditor(blocks: Block[] = [block()]): Editor {
  return { blocks, caret: { key: blocks[0].key, offset: 0 }, last: 'other', undo: null };
}

const indexOf = (ed: Editor, key: string) => ed.blocks.findIndex((b) => b.key === key);
const replaceAt = (blocks: Block[], i: number, ...items: Block[]) => [
  ...blocks.slice(0, i),
  ...items,
  ...blocks.slice(i + 1),
];

/** Tiền tố nào khớp với block tại vị trí caret, hoặc null. */
export function matchShortcut(b: Block, offset: number, shortcuts = SHORTCUTS): Shortcut | null {
  if (offset > MAX_PREFIX) return null;
  for (const s of shortcuts) {
    if (offset !== s.prefix.length || !b.text.startsWith(s.prefix)) continue;
    if (b.type === 'unstyled') return s;
    if (s.list && s.type === b.type) return s; // "- " trong danh sách gạch đầu dòng: thụt vào
  }
  return null;
}

/**
 * Người dùng sửa chữ trong một block. `inserted` = lần sửa này là gõ thêm ký tự
 * (InputEvent.inputType === 'insertText'); dán, xoá, IME thì không đổi kiểu block.
 */
export function editText(ed: Editor, key: string, text: string, offset: number, inserted: boolean): Editor {
  const i = indexOf(ed, key);
  const b = { ...ed.blocks[i], text };
  const s = inserted ? matchShortcut(b, offset) : null;
  if (!s) {
    return { blocks: replaceAt(ed.blocks, i, b), caret: { key, offset }, last: inserted ? 'insert' : 'other', undo: ed.undo };
  }
  const nested = b.type === s.type;
  const styled: Block = {
    ...b,
    type: s.type,
    text: text.slice(s.prefix.length),
    depth: nested ? Math.min(MAX_DEPTH, b.depth + 1) : b.depth,
    ...(s.type === 'checkbox-list-item' ? { checked: !!s.checked } : {}),
  };
  return {
    blocks: replaceAt(ed.blocks, i, styled),
    caret: { key, offset: 0 },
    last: 'markdown',
    undo: { before: b, offset },
  };
}

/**
 * Backspace khi caret ở đầu block. Trả null nếu để trình duyệt tự xử lý
 * (caret không ở đầu, hoặc là block đầu tiên không có kiểu).
 */
export function backspaceAtStart(ed: Editor): Editor | null {
  const { key, offset } = ed.caret;
  if (offset !== 0) return null;
  const i = indexOf(ed, key);
  const b = ed.blocks[i];
  // 1. vừa đổi kiểu bằng tiền tố: trả lại đúng chữ đã gõ
  if (ed.last === 'markdown' && ed.undo?.before.key === key) {
    return {
      blocks: replaceAt(ed.blocks, i, ed.undo.before),
      caret: { key, offset: ed.undo.offset },
      last: 'other',
      undo: null,
    };
  }
  // 2. block có kiểu: bỏ một mức thụt, rồi bỏ kiểu
  if (b.type !== 'unstyled') {
    const next: Block =
      isList(b.type) && b.depth > 0 ? { ...b, depth: b.depth - 1 } : { key: b.key, type: 'unstyled', text: b.text, depth: 0 };
    return { blocks: replaceAt(ed.blocks, i, next), caret: { key, offset: 0 }, last: 'other', undo: null };
  }
  // 3. block thường: nối vào block trước
  if (i === 0) return null;
  const prev = ed.blocks[i - 1];
  const merged = { ...prev, text: prev.text + b.text };
  return {
    blocks: [...ed.blocks.slice(0, i - 1), merged, ...ed.blocks.slice(i + 1)],
    caret: { key: prev.key, offset: prev.text.length },
    last: 'other',
    undo: null,
  };
}

/** Enter: tách block. Mục danh sách rỗng thì thoát danh sách thay vì tạo mục mới. */
export function splitBlock(ed: Editor): Editor {
  const { key, offset } = ed.caret;
  const i = indexOf(ed, key);
  const b = ed.blocks[i];
  if (b.text === '' && b.type !== 'unstyled') {
    const plain: Block = { key: b.key, type: 'unstyled', text: '', depth: 0 };
    return { blocks: replaceAt(ed.blocks, i, plain), caret: { key, offset: 0 }, last: 'other', undo: null };
  }
  const keepType = isList(b.type) || b.type === 'code-block';
  const tail = block(b.text.slice(offset), keepType ? b.type : 'unstyled', keepType ? b.depth : 0, false);
  return {
    blocks: replaceAt(ed.blocks, i, { ...b, text: b.text.slice(0, offset) }, tail),
    caret: { key: tail.key, offset: 0 },
    last: 'other',
    undo: null,
  };
}

// ---------------------------------------------------------------- dán Markdown

export interface PasteOptions {
  /**
   * Bản gốc: "2019. Một năm" luôn thành danh sách số và mất chữ "2019".
   * Mặc định ở đây theo CommonMark: danh sách số chỉ được ngắt đoạn văn khi bắt đầu bằng 1,
   * và số khác 1 chỉ được nhận nếu dòng trước đã là danh sách số.
   */
  anyOrderedStart?: boolean;
  /** Bản gốc: không đặt lại mức lồng khi gặp dòng thường, dòng thụt sau đoạn văn vẫn thành danh sách con. */
  keepNestingAcrossParagraphs?: boolean;
}

const BULLET = /^([*+-]) /;
const ORDERED = /^(\d{1,9})[.)] /;

/** Đếm số mức thụt đầu dòng: mỗi tab hoặc 4 dấu cách là một mức. */
function leadingIndent(line: string): { depth: number; rest: string } {
  let pos = 0;
  let depth = 0;
  for (;;) {
    if (line[pos] === '\t') pos += 1;
    else if (line.startsWith('    ', pos)) pos += 4;
    else break;
    depth++;
  }
  return { depth, rest: line.slice(pos) };
}

export function markdownToBlocks(text: string, o: PasteOptions = {}): Block[] {
  const out: Block[] = [];
  let inFence = false;
  let listOpen = false; // dòng trước có phải mục danh sách không
  let prevOrdered = false;

  for (const line of text.replace(/\r\n?/g, '\n').split('\n')) {
    if (line.startsWith('```')) {
      inFence = !inFence;
      continue;
    }
    if (inFence) {
      out.push(block(line, 'code-block'));
      continue;
    }
    const { depth, rest } = leadingIndent(line);
    const canNest = listOpen && depth > 0;
    const body = depth > 0 && canNest ? rest : line;
    const lineDepth = canNest ? Math.min(depth, MAX_DEPTH) : 0;

    const bullet = BULLET.exec(body);
    const ordered = ORDERED.exec(body);
    if (bullet && (depth === 0 || canNest)) {
      out.push(block(body.slice(bullet[0].length), 'unordered-list-item', lineDepth));
      listOpen = true;
      prevOrdered = false;
      continue;
    }
    if (ordered && (depth === 0 || canNest) && (o.anyOrderedStart || ordered[1] === '1' || prevOrdered)) {
      out.push(block(body.slice(ordered[0].length), 'ordered-list-item', lineDepth));
      listOpen = true;
      prevOrdered = true;
      continue;
    }
    const s = depth === 0 ? SHORTCUTS.find((x) => !x.list && !x.fence && line.startsWith(x.prefix)) : undefined;
    if (s) {
      out.push(block(line.slice(s.prefix.length), s.type, 0, s.checked));
      listOpen = false;
    } else if (depth > 0 && !canNest) {
      out.push(block(rest, 'code-block')); // 4 dấu cách đầu dòng ngoài danh sách: code
      listOpen = false;
    } else {
      out.push(block(line));
      listOpen = o.keepNestingAcrossParagraphs ? listOpen : false;
    }
    prevOrdered = false;
  }
  return out.length ? out : [block()];
}

/** Bản mô phỏng hành vi gốc khi dán, để so sánh trong demo. */
export const legacyPaste = (text: string) =>
  markdownToBlocks(text, { anyOrderedStart: true, keepNestingAcrossParagraphs: true });
ts

#Đi qua code

  • matchShortcut lặp bảng theo thứ tự, giống bản gốc. ## không bị # bắt nhầm vì điều kiện offset === prefix.length: khi gõ xong ## , caret ở 3, chỉ ## có độ dài 3.
  • editText nhận cờ inserted từ lớp giao diện (inputType === 'insertText'). Dán hay xoá chữ không bao giờ đổi kiểu block.
  • Khi đổi kiểu, undo lưu block trước khi đổi (còn nguyên tiền tố) và vị trí caret. last = 'markdown' đóng vai trò lastChangeType.
  • backspaceAtStart kiểm tra cả last lẫn key của block trong ảnh chụp, tương đương việc so key ở đỉnh undo stack của bản gốc.
  • splitBlock giữ kiểu danh sách và code khi Enter; mục danh sách rỗng mà Enter thì thoát danh sách (giống richTextTryToInsertNewlineAndReset).
  • markdownToBlocks đọc phần thụt bằng leadingIndent (tab hoặc 4 dấu cách mỗi mức), dùng listOpen để biết dòng trước có phải mục danh sách không, và prevOrdered cho luật số bắt đầu.

Khác bản gốc:

  • Không phụ thuộc Draft.js; hoàn tác bằng ảnh chụp một block thay vì undo stack chung.
  • Checkbox và thụt danh sách bằng - luôn bật (bản gốc cần enableCheckboxes và enableNestingListsInline).
  • Backspace ở đầu mục danh sách có thụt sẽ bỏ một mức trước, rồi mới bỏ kiểu.
  • Dán: số khác 1 không mở danh sách mới sau đoạn văn (luật CommonMark), và dòng văn bản thường đặt lại trạng thái danh sách. legacyPaste giữ hành vi cũ để so sánh.

#Tại sao không làm cách khác?

  • Đổi kiểu khi gõ chữ đầu tiên sau tiền tố (# T): người dùng thấy # hiện rồi mới biến mất, giật. Đổi ngay khi gõ dấu cách cho phản hồi tức thì, và Backspace là đường lui rẻ.
  • Hỏi lại người dùng ("Đổi thành tiêu đề?"): quá nặng cho một thao tác gõ.
  • Ctrl+Z thay cho Backspace: vẫn nên hỗ trợ, nhưng người dùng thấy dòng vừa đổi kiểu thì phản xạ tự nhiên là xoá lùi, không phải undo.
  • Dùng parser Markdown đầy đủ khi dán (markdown-it, remark): đúng chuẩn hơn hẳn, nhưng nặng và sinh cả những kiểu block editor không hỗ trợ (bảng, HTML). Bảng tiền tố nhỏ đủ cho ô soạn tin, chỉ cần sửa hai luật dễ gây mất dữ liệu.

#Edge case và cạm bẫy

  • Bộ gõ tiếng Việt: một số bộ gõ gửi chữ qua composition, một số gửi deleteContentBackward rồi insertText để thay dấu. Với chuỗi "# " không có dấu, không ảnh hưởng; nhưng đừng để phím tắt chạy giữa composition.
  • Tiền tố 4 dấu cách: rất dễ kích hoạt nhầm khi người dùng căn lề bằng dấu cách. Nhiều editor bỏ hẳn tiền tố này; bản gốc cũng để nó sau cờ gatekeeper.
  • Số thứ tự: dán "3. Ba" đứng một mình thì nên giữ số 3. Bản viết lại giữ nguyên chữ (không thành danh sách); nếu muốn danh sách bắt đầu từ 3 thì cần lưu thêm thuộc tính start.
  • Undo sau khi dán: hoàn tác bằng ảnh chụp chỉ áp cho thao tác đổi kiểu vừa xong. Undo đa bước (Ctrl+Z) vẫn cần một lịch sử thật.
  • Đọc màn hình: block đổi kiểu thì vai trò của nó đổi (đoạn văn thành tiêu đề, mục danh sách). Ở editor thật, dùng phần tử đúng nghĩa (h1, li) để trình đọc màn hình đọc đúng.

#Dùng trong dự án

import { backspaceAtStart, editText, splitBlock, type Editor } from './mdShortcuts';

function onBeforeInput(e: InputEvent, ed: Editor, caretKey: string, newText: string, offset: number) {
  // gọi sau khi lấy được text mới của block và vị trí caret
  return editText(ed, caretKey, newText, offset, e.inputType === 'insertText');
}

function onKeyDown(e: KeyboardEvent, ed: Editor): Editor | null {
  if (e.key === 'Backspace') return backspaceAtStart(ed); // null: để trình duyệt xoá như thường
  if (e.key === 'Enter' && !e.shiftKey) return (e.preventDefault(), splitBlock(ed));
  return null;
}
tsx

Nếu dùng Lexical, plugin MarkdownShortcutPlugin có sẵn các transformer cho tiêu đề, danh sách, trích dẫn, code; bản viết lại ở đây hữu ích khi cần hiểu cơ chế hoặc làm editor nhỏ không muốn thêm phụ thuộc.

#Kiểm thử

Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it } from 'vitest';
import { backspaceAtStart, block, createEditor, editText, legacyPaste, markdownToBlocks } from './mdShortcuts';

const type = (ed: ReturnType<typeof createEditor>, s: string) => {
  const key = ed.caret.key;
  for (let i = 1; i <= s.length; i++) {
    const cur = ed.blocks.find((b) => b.key === key)!;
    ed = editText(ed, key, cur.text + s[i - 1], cur.text.length + 1, true);
  }
  return ed;
};

describe('phím tắt', () => {
  it('"# " thành H1, Backspace trả lại "# "', () => {
    let ed = type(createEditor(), '# ');
    expect(ed.blocks[0]).toMatchObject({ type: 'header-one', text: '' });
    ed = backspaceAtStart(ed)!;
    expect(ed.blocks[0]).toMatchObject({ type: 'unstyled', text: '# ' });
    expect(ed.caret.offset).toBe(2);
  });

  it('gõ thêm chữ thì Backspace chỉ bỏ kiểu', () => {
    let ed = type(createEditor(), '# A');
    ed = backspaceAtStart({ ...ed, caret: { ...ed.caret, offset: 0 } })!;
    expect(ed.blocks[0]).toMatchObject({ type: 'unstyled', text: 'A' });
  });

  it('dán không đổi kiểu', () => {
    const ed = createEditor();
    const next = editText(ed, ed.caret.key, '# ', 2, false);
    expect(next.blocks[0].type).toBe('unstyled');
  });

  it('"- " trong danh sách thì thụt vào', () => {
    const ed = createEditor([block('- ', 'unordered-list-item')]);
    expect(editText(ed, ed.caret.key, '- ', 2, true).blocks[0].depth).toBe(1);
  });
});

describe('dán Markdown', () => {
  it('giữ "2019." là văn bản', () => {
    expect(markdownToBlocks('2019. Một năm')[0]).toMatchObject({ type: 'unstyled', text: '2019. Một năm' });
    expect(legacyPaste('2019. Một năm')[0]).toMatchObject({ type: 'ordered-list-item', text: 'Một năm' });
  });
  it('dòng thụt sau đoạn văn không thành mục con', () => {
    const out = markdownToBlocks('- a\nĐoạn\n    - b');
    expect(out[2].type).not.toBe('unordered-list-item');
    expect(legacyPaste('- a\nĐoạn\n    - b')[2]).toMatchObject({ type: 'unordered-list-item', depth: 1 });
  });
  it('danh sách lồng và code', () => {
    const out = markdownToBlocks('1. a\n2. b\n    - c\n```\n# x\n```');
    expect(out.map((b) => `${b.type}@${b.depth}`)).toEqual([
      'ordered-list-item@0', 'ordered-list-item@0', 'unordered-list-item@1', 'code-block@0',
    ]);
  });
});
ts

#So với thư viện và chuẩn web

  • Lexical (@lexical/markdown): MarkdownShortcutPlugin với các "transformer" theo regex, kể cả định dạng inline (**đậm**). Hoàn tác qua lịch sử của Lexical (Ctrl+Z), không có luật Backspace riêng như Draft.js của Facebook.
  • ProseMirror (prosemirror-inputrules): textblockTypeInputRule, wrappingInputRule, và lệnh undoInputRule gắn vào Backspace: đúng cơ chế "Backspace ngay sau đó để hoàn tác".
  • Tiptap: input rules dựa trên ProseMirror, có sẵn cho heading, list, blockquote, code block.
  • CommonMark: đặc tả nói danh sách số chỉ được ngắt một đoạn văn khi bắt đầu bằng 1, và số bắt đầu tối đa 9 chữ số; bản viết lại dựa vào luật này để không ăn mất "2019.".
  • Bài liên quan: #21 Smart Compose, #43 Composer dạng plugin.

Bài này thuộc series Học từ mã nguồn Facebook (106 bài, đọc theo thứ tự).

Xem nhanh danh sách bài trong series

Phần 1

Phần 2

Phần 3

Phần 4–6

Phần 7–10

Phần 11–15

Code + demo: examples/facebook-patterns · Mở trên StackBlitz