Path: blob/master/src/packages/frontend/editors/slate/normalize.ts
1691 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45/* Ideas for things to put here that aren't here now:67- merging adjacent lists, since the roundtrip to markdown does that.89WARNING: The following warning used to apply. However, we now normalize10markdown_to_slate always, so it does not apply: "Before very very very11careful before changing anything here!!!12It is absolutely critical that the output of markdown_to_slate be normalized13according to all the rules here. If you change a rule here, that will14likely break this assumption and things will go to hell. Be careful.""1516*/1718import { Editor, Element, Path, Range, Text, Transforms } from "slate";19import { isEqual } from "lodash";2021import { getNodeAt } from "./slate-util";22import { emptyParagraph } from "./padding";23import { isListElement } from "./elements/list";2425interface NormalizeInputs {26editor?: Editor;27node?: Node;28path?: Path;29}3031type NormalizeFunction = (NormalizeInputs) => void;3233const NORMALIZERS: NormalizeFunction[] = [];3435export const withNormalize = (editor) => {36const { normalizeNode } = editor;3738editor.normalizeNode = (entry) => {39const [node, path] = entry;4041for (const f of NORMALIZERS) {42//const before = JSON.stringify(editor.children);43const before = editor.children;44f({ editor, node, path });45if (before !== editor.children) {46// changed so return; normalize will get called again by47// slate until no changes.48return;49}50}5152// No changes above, so fall back to the original `normalizeNode`53// to enforce other constraints. Important to not call any normalize54// if there were any changes, since they can make the entry invalid!55normalizeNode(entry);56};5758return editor;59};6061// This does get called if you somehow blank the document. It62// gets called with path=[], which makes perfect sense. If we63// don't put something in, then things immediately break due to64// selection assumptions. Slate doesn't do this automatically,65// since it doesn't nail down the internal format of a blank document.66NORMALIZERS.push(function ensureDocumentNonempty({ editor }) {67if (editor.children.length == 0) {68Editor.insertNode(editor, emptyParagraph());69}70});7172// Ensure every list_item is contained in a list.73NORMALIZERS.push(function ensureListItemInAList({ editor, node, path }) {74if (Element.isElement(node) && node.type === "list_item") {75const [parent] = Editor.parent(editor, path);76if (!isListElement(parent)) {77// invalid document: every list_item should be in a list.78Transforms.wrapNodes(editor, { type: "bullet_list" } as Element, {79at: path,80});81}82}83});8485// Ensure every immediate child of a list is a list_item. Also, ensure86// that the children of each list_item are block level elements, since this87// makes list manipulation much easier and more consistent.88NORMALIZERS.push(function ensureListContainsListItems({ editor, node, path }) {89if (90Element.isElement(node) &&91(node.type === "bullet_list" || node.type == "ordered_list")92) {93let i = 0;94for (const child of node.children) {95if (!Element.isElement(child) || child.type != "list_item") {96// invalid document: every child of a list should be a list_item97Transforms.wrapNodes(editor, { type: "list_item" } as Element, {98at: path.concat([i]),99mode: "lowest",100});101return;102}103if (!Element.isElement(child.children[0])) {104// if the the children of the list item are leaves, wrap105// them all in a paragraph (for consistency with what our106// convertor from markdown does, and also our doc manipulation,107// e.g., backspace, assumes this).108Transforms.wrapNodes(editor, { type: "paragraph" } as Element, {109mode: "lowest",110match: (node) => !Element.isElement(node),111at: path.concat([i]),112});113}114i += 1;115}116}117});118119/*120Trim *all* whitespace from the beginning of blocks whose first child is Text,121since markdown doesn't allow for it. (You can use of course.)122*/123NORMALIZERS.push(function trimLeadingWhitespace({ editor, node, path }) {124if (Element.isElement(node) && Text.isText(node.children[0])) {125const firstText = node.children[0].text;126if (firstText != null) {127// We actually get rid of spaces and tabs, but not ALL whitespace. For example,128// if you type " bar", then autoformat turns that into *two* whitespace129// characters, with the being ascii 160, which counts if we just searched130// via .search(/\S|$/), but not if we explicitly only look for space or tab as below.131const i = firstText.search(/[^ \t]|$/);132if (i > 0) {133const p = path.concat([0]);134const { selection } = editor;135const text = firstText.slice(0, i);136editor.apply({ type: "remove_text", offset: 0, path: p, text });137if (138selection != null &&139Range.isCollapsed(selection) &&140isEqual(selection.focus.path, p)141) {142const offset = Math.max(0, selection.focus.offset - i);143const focus = { path: p, offset };144setTimeout(() =>145Transforms.setSelection(editor, { focus, anchor: focus })146);147}148}149}150}151});152153/*154If there are two adjacent lists of the same type, merge the second one into155the first.156*/157NORMALIZERS.push(function mergeAdjacentLists({ editor, node, path }) {158if (159Element.isElement(node) &&160(node.type === "bullet_list" || node.type === "ordered_list")161) {162try {163const nextPath = Path.next(path);164const nextNode = getNodeAt(editor, nextPath);165if (Element.isElement(nextNode) && nextNode.type == node.type) {166// We have two adjacent lists of the same type: combine them.167// Note that we do NOT take into account tightness when deciding168// whether to merge, since in markdown you can't have a non-tight169// and tight list of the same type adjacent to each other anyways.170Transforms.mergeNodes(editor, { at: nextPath });171return;172}173} catch (_) {} // because prev or next might not be defined174175try {176const previousPath = Path.previous(path);177const previousNode = getNodeAt(editor, previousPath);178if (Element.isElement(previousNode) && previousNode.type == node.type) {179Transforms.mergeNodes(editor, { at: path });180}181} catch (_) {}182}183});184185// Delete any empty links (with no text content), since you can't see them.186// This is a questionable design choice, e.g,. maybe people want to use empty187// links as a comment hack, as explained here:188// https://stackoverflow.com/questions/4823468/comments-in-markdown189// However, those are the footnote style links. The inline ones don't work190// anyways as soon as there is a space.191NORMALIZERS.push(function removeEmptyLinks({ editor, node, path }) {192if (193Element.isElement(node) &&194node.type === "link" &&195node.children.length == 1 &&196node.children[0]?.["text"] === ""197) {198Transforms.removeNodes(editor, { at: path });199}200});201202203