Path: blob/master/src/packages/frontend/editors/slate/control.ts
1691 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45import { Editor, Element, Range, Transforms, Point } from "slate";6import { ReactEditor } from "./slate-react";7import { isEqual } from "lodash";8import { rangeAll } from "./slate-util";9import { emptyParagraph } from "./padding";10import { delay } from "awaiting";1112// Scroll to the n-th heading in the document13export async function scrollToHeading(14editor: ReactEditor,15n: number16): Promise<void> {17let i = 0;18for (const x of Editor.nodes(editor, {19at: { path: [], offset: 0 },20match: (node) => node["type"] == "heading",21})) {22if (i == n) {23if (!ReactEditor.isUsingWindowing(editor)) {24// easy case25ReactEditor.toDOMNode(editor, x[0]).scrollIntoView(true);26return;27}28// Do if a few times, in case rendering/measuring changes sizes...29// TODO: This sucks, of course!30// Note that if clicking on table of contents opened the slate editor,31// then it's going to be getting it's scroll reset to where it was last32// used at the exact same time that we're trying to move the scroll here.33// Yes, this is dumb and the two fight each other.34for (const d of [1, 50, 1000]) {35try {36ReactEditor.scrollIntoDOM(editor, x[1]);37// wait for scroll to actually happen resulting in something in the DOM.38await new Promise(requestAnimationFrame);39ReactEditor.toDOMNode(editor, x[0]).scrollIntoView(true);40} catch (_err) {41console.log("WARNING: still not in DOM", _err);42// There is no guarantee that something else didn't happen to remove the element43// from the DOM while we're waiting for it, hence this is OK.44return;45}46await delay(d);47}48return;49}50i += 1;51}52// didn't find it.53}5455export function setCursor(editor: ReactEditor, point: Point) {56Transforms.setSelection(editor, { anchor: point, focus: point });57}5859function move(editor: Editor, options?): void {60try {61Transforms.move(editor, options);62} catch (err) {63// I saw this once when moving the cursor, and don't think it is worth crashing64// the editor completely.65console.warn(`Slate: issue moving cursor -- ${err}`);66resetSelection(editor);67}68}6970export function resetSelection(editor: Editor) {71// set to beginning of document -- better than crashing.72const focus = { path: [0, 0], offset: 0 };73Transforms.setSelection(editor, {74focus,75anchor: focus,76});77}7879export function moveCursorDown(editor: Editor, force: boolean = false): void {80const focus = editor.selection?.focus;81if (focus == null) return;82move(editor, { distance: 1, unit: "line" });83if (!force) return;84const newFocus = editor.selection?.focus;85if (newFocus == null) return;86if (isEqual(focus, newFocus)) {87// didn't move down; at end of doc, so put a blank paragraph there88// and move to that.89editor.apply({90type: "insert_node",91path: [editor.children.length],92node: emptyParagraph(),93});94move(editor, { distance: 1, unit: "line" });95return;96}97ensureCursorNotBlocked(editor);98}99100export function moveCursorUp(editor: Editor, force: boolean = false): void {101const focus = editor.selection?.focus;102if (focus == null) return;103move(editor, { distance: 1, unit: "line", reverse: true });104if (!force) return;105const newFocus = editor.selection?.focus;106if (newFocus == null) return;107if (isEqual(focus, newFocus)) {108// didn't move -- put a blank paragraph there109// and move to that.110editor.apply({111type: "insert_node",112path: [0],113node: emptyParagraph(),114});115move(editor, { distance: 1, unit: "line", reverse: true });116}117ensureCursorNotBlocked(editor, true);118}119120export function blocksCursor(editor, up: boolean = false): boolean {121if (editor.selection == null || !Range.isCollapsed(editor.selection)) {122return false;123}124125let elt;126try {127elt = editor.getFragment()[0];128} catch (_) {129return false;130}131if (elt == null) {132// fragment above was empty.133// I hit not checking for this randomly once in production and it caused a crash.134return false;135}136if (Editor.isVoid(editor, elt)) {137return true;138}139140// Several non-void elements also block the cursor,141// in the sense that you can't move the cursor immediately142// before/after them.143// TODO: instead of listing here, should be part of registration144// system in ../elements.145if (146editor.selection != null &&147((up && isAtBeginningOfBlock(editor, { mode: "highest" })) ||148(!up && isAtEndOfBlock(editor, { mode: "highest" }))) &&149(elt?.type == "blockquote" ||150elt?.type == "ordered_list" ||151elt?.type == "bullet_list")152) {153return true;154}155156return false;157}158159export function ensureCursorNotBlocked(editor: Editor, up: boolean = false) {160if (!blocksCursor(editor, !up)) return;161// cursor in a void element, so insert a blank paragraph at162// cursor and put cursor in that blank paragraph.163const { selection } = editor;164if (selection == null) return;165const path = [selection.focus.path[0] + (up ? +1 : 0)];166editor.apply({167type: "insert_node",168path,169node: { type: "paragraph", children: [{ text: "" }] },170});171const focus = { path: path.concat([0]), offset: 0 };172Transforms.setSelection(editor, {173focus,174anchor: focus,175});176}177178// Find path to a given element.179export function findElement(180editor: Editor,181element: Element182): number[] | undefined {183// Usually when called, the element we are searching for is right184// near the selection, so this first search finds it.185for (const [, path] of Editor.nodes(editor, {186match: (node) => node === element,187})) {188return path;189}190// Searching at the selection failed, so we try searching the191// entire document instead.192// This has to work unless element isn't in the document (which193// is of course possible).194for (const [, path] of Editor.nodes(editor, {195match: (node) => node === element,196at: rangeAll(editor),197})) {198return path;199}200}201202export function moveCursorToElement(editor: Editor, element: Element): void {203const path = findElement(editor, element);204if (path == null) return;205const point = { path, offset: 0 };206Transforms.setSelection(editor, { anchor: point, focus: point });207}208209// Move cursor to the end of a top-level non-inline element.210export function moveCursorToEndOfElement(211editor: Editor,212element: Element // non-line element213): void {214// Find the element215const path = findElement(editor, element);216if (path == null) return;217// Create location at start of the element218const at = { path, offset: 0 };219// Move to block "after" where the element is. This is220// sort of random in that it might be at the end of the221// element, or it might be in the next block. E.g.,222// for "# fo|o**bar**" it is in the next block, but for223// "# foo**b|ar**" it is at the end of the current block!?224// We work around this bug by moving back 1 character225// in case we moved to the next top-level block.226let end = Editor.after(editor, at, { unit: "block" });227if (end == null) return;228if (end.path[0] != path[0]) {229end = Editor.before(editor, end);230}231Transforms.setSelection(editor, { anchor: end, focus: end });232}233234export function moveCursorToBeginningOfBlock(235editor: Editor,236path?: number[]237): void {238if (path == null) {239const selection = editor.selection;240if (selection == null || !Range.isCollapsed(selection)) {241return;242}243path = selection.focus.path;244}245if (path.length > 1) {246path = [...path]; // make mutable copy247path[path.length - 1] = 0;248}249const focus = { path, offset: 0 };250Transforms.setSelection(editor, { focus, anchor: focus });251}252253// True if point is at the beginning of the containing block254// that it is in (or top level block if mode='highest').255export function isAtBeginningOfBlock(256editor: Editor,257options: { at?: Point; mode?: "lowest" | "highest" }258): boolean {259let { at, mode } = options;260if (mode == null) mode = "lowest";261if (at == null) {262at = editor.selection?.focus;263if (at == null) return false;264}265if (at.offset != 0) return false;266if (mode == "lowest") {267// easy special case.268return at.path[at.path.length - 1] == 0;269}270const before = Editor.before(editor, at);271if (before == null) {272// at beginning of the entire document, so definitely at the beginning of the block273return true;274}275return before.path[0] < at.path[0];276}277278// True if point is at the end of the containing block279// that it is in (or top level block if mode='highest').280// Also, return false if "at" is not valid.281export function isAtEndOfBlock(282editor: Editor,283options: { at?: Point; mode?: "lowest" | "highest" }284): boolean {285let { at, mode } = options;286if (mode == null) mode = "lowest";287if (at == null) {288at = editor.selection?.focus;289if (at == null) return false;290}291let after;292try {293after = Editor.after(editor, at);294} catch (_) {295// if "at" is no longer valid for some reason.296return false;297}298if (after == null) {299// at end of the entire document, so definitely at the end of the block300return true;301}302if (isEqual(after.path, at.path)) {303// next point is in the same node, so can't be at the end (not304// even the end of this node).305return false;306}307if (mode == "highest") {308// next path needs to start with a new number.309return after.path[0] > at.path[0];310} else {311const n = Math.min(after.path.length, at.path.length);312if (isEqual(at.path.slice(0, n - 1), after.path.slice(0, n - 1))) {313return false;314}315return true;316}317}318319export function moveCursorToEndOfLine(editor: Editor) {320/*321Call the function Transforms.move(editor, {unit:'line'})322until the integer editor.selection.anchor[0] increases,323then call Transforms.setSelection(editor, ...) with second324argument the previous value of editor.selection. Instead325of setting the selection back to the initial value, set it326to the value right before we exited the while loop, i.e.,327the last value before anchor[0] changed. Also, if the328entire selection doesn't change exist the while loop329to avoid an infinite loop.330*/331332let lastSelection = editor.selection;333if (lastSelection == null) return;334while (335editor.selection &&336editor.selection.anchor.path[0] === lastSelection.anchor.path[0]337) {338if (lastSelection == null) return;339lastSelection = editor.selection;340Transforms.move(editor, { unit: "line" });341// Ensure we don't get stuck in an infinite loop342if (JSON.stringify(lastSelection) === JSON.stringify(editor.selection)) {343return;344}345}346347if (lastSelection) {348Transforms.setSelection(editor, lastSelection);349}350}351352export function moveCursorToBeginningOfLine(editor: Editor) {353let lastSelection = editor.selection;354if (lastSelection == null) return;355while (356editor.selection &&357editor.selection.anchor.path[0] === lastSelection.anchor.path[0]358) {359if (lastSelection == null) return;360lastSelection = editor.selection;361Transforms.move(editor, { unit: "line", reverse: true });362// Ensure we don't get stuck in an infinite loop363if (JSON.stringify(lastSelection) === JSON.stringify(editor.selection)) {364return;365}366}367368if (lastSelection) {369Transforms.setSelection(editor, lastSelection);370}371}372373374