Path: blob/master/src/packages/frontend/editors/slate/sync.ts
1691 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45import * as CodeMirror from "codemirror";6import { Descendant, Editor, Point } from "slate";7import { ReactEditor } from "./slate-react";8import { slate_to_markdown } from "./slate-to-markdown";9import { markdown_to_slate } from "./markdown-to-slate";10import { isWhitespaceParagraph } from "./padding";11const SENTINEL = "\uFE30";12import { SlateEditor } from "./editable-markdown";1314export function slatePointToMarkdownPosition(15editor: SlateEditor,16point: Point | undefined17): CodeMirror.Position | undefined {18if (point == null) return undefined; // easy special case not handled below.19const { index, markdown } = slatePointToMarkdown(editor, point);20if (index == -1) return;21return indexToPosition({ index, markdown });22}2324// Given a location in a slatejs document, return the25// corresponding index into the corresponding markdown document,26// along with the version of the markdown file that was used for27// this determination.28// Returns index of -1 if it fails to work for some reason, e.g.,29// the point doesn't exist in the document.30// TODO/BUG: This can still be slightly wrong because we don't use caching on the top-level31// block that contains the cursor. Thus, e.g., in a big nested list with various markdown32// that isn't canonical this could make things be slightly off.33export function slatePointToMarkdown(34editor: SlateEditor,35point: Point36): { index: number; markdown: string } {37let node;38try {39[node] = Editor.node(editor, point);40} catch (err) {41// console.warn(`slate -- invalid point ${JSON.stringify(point)} -- ${err}`);42// There is no guarantee that point is valid when this is called.43return { index: -1, markdown: "" };44}4546let markdown = slate_to_markdown(editor.children, {47cache: editor.syncCache,48noCache: new Set([point.path[0]]),49hook: (elt) => {50if (elt !== node) return;51return (s) => s.slice(0, point.offset) + SENTINEL + s.slice(point.offset);52},53});54const index = markdown.indexOf(SENTINEL);55if (index != -1) {56markdown = markdown.slice(0, index) + markdown.slice(index + 1);57}58return { markdown, index };59}6061export function indexToPosition({62index,63markdown,64}: {65index: number;66markdown: string;67}): CodeMirror.Position | undefined {68let n = 0;69const lines = markdown.split("\n");70for (let line = 0; line < lines.length; line++) {71const len = lines[line].length + 1; // +1 for the newlines.72const next = n + len;73if (index >= n && index < next) {74// in this line75return { line, ch: index - n };76}77n = next;78}79// not found...?80return undefined; // just being explicit here.81}8283function insertSentinel(pos: CodeMirror.Position, markdown: string): string {84const v = markdown.split("\n");85const s = v[pos.line];86if (s == null) {87return markdown + SENTINEL;88}89v[pos.line] = s.slice(0, pos.ch) + SENTINEL + s.slice(pos.ch);90return v.join("\n");91}9293function findSentinel(doc: any[]): Point | undefined {94let j = 0;95for (const node of doc) {96if (node.text != null) {97const offset = node.text.indexOf(SENTINEL);98if (offset != -1) {99return { path: [j], offset };100}101}102if (node.children != null) {103const x = findSentinel(node.children);104if (x != null) {105return { path: [j].concat(x.path), offset: x.offset };106}107}108j += 1;109}110}111112// Convert a markdown string and point in it (in codemirror {line,ch})113// to corresponding slate editor coordinates.114// TODO/Huge CAVEAT -- right now we add in some blank paragraphs to the115// slate document to make it possible to do something things with the cursor,116// get before the first bullet point or code block in a document. These paragraphs117// are unknown to this conversion function... so if there are any then things are118// off as a result. Obviously, we need to get rid of the code (in control.ts) that119// adds these and come up with a better approach to make cursors and source<-->editable sync120// work perfectly.121export function markdownPositionToSlatePoint({122markdown,123pos,124editor,125}: {126markdown: string;127pos: CodeMirror.Position | undefined;128editor: SlateEditor;129}): Point | undefined {130if (pos == null) return undefined;131const m = insertSentinel(pos, markdown);132if (m == null) {133return undefined;134}135const doc: Descendant[] = markdown_to_slate(m, false);136let point = findSentinel(doc);137if (point != null) return normalizePoint(editor, doc, point);138if (pos.ch == 0) return undefined;139140// try again at beginning of line, e.g., putting a sentinel141// in middle of an html fragment not likely to work, but beginning142// of line highly likely to work.143return markdownPositionToSlatePoint({144markdown,145pos: { line: pos.line, ch: 0 },146editor,147});148}149150export async function scrollIntoView(151editor: ReactEditor,152point: Point153): Promise<void> {154const scrollIntoView = () => {155try {156const [node] = Editor.node(editor, point);157const elt = ReactEditor.toDOMNode(editor, node);158elt.scrollIntoView({ block: "center" });159} catch (_err) {160// There is no guarantee the point is valid, or that161// the DOM node exists.162}163};164if (!ReactEditor.isUsingWindowing(editor)) {165scrollIntoView();166} else {167// TODO: this below makes it so the top of the top-level block containing168// the point is displayed. However, that block could be big, and we169// really need to somehow move down to it via some scroll offset.170// There is an offset option to scrollToIndex (see use in preserveScrollPosition),171// and that might be very helpful.172const index = point.path[0];173editor.windowedListRef.current?.virtuosoRef.current?.scrollToIndex({174index,175align: "center",176});177setTimeout(scrollIntoView, 0);178requestAnimationFrame(() => {179scrollIntoView();180setTimeout(scrollIntoView, 0);181});182}183}184185function normalizePoint(186editor: Editor,187doc: Descendant[],188point: Point189): Point | undefined {190// On the slate side at the top level we create blank paragraph to make it possible to191// move the cursor before/after various block elements. In practice this seems to nicely192// workaround a lot of maybe fundamental bugs/issues with Slate, like those193// hinted at here: https://github.com/ianstormtaylor/slate/issues/3469194// But it means we also have to account for this when mapping from markdown195// coordinates to slate coordinates, or other user cursors and forward search196// will be completely broken. These disappear when generating markdown from197// slate, so cause no trouble in the other direction.198if (doc.length < editor.children.length) {199// only an issue when lengths are different; in the common special case they200// are the same (e.g., maybe slate only used to view, not edit), then this201// can't be an issue.202let i = 0,203j = 0;204while (i <= point.path[0]) {205if (206isWhitespaceParagraph(editor.children[j]) &&207!isWhitespaceParagraph(doc[i])208) {209point.path[0] += 1;210j += 1;211continue;212}213i += 1;214j += 1;215}216}217218// If position is at the very end of a line with marking our process to find it219// creates a new text node, so cursor gets lost, so we move back 1 position220// and try that. This is a heuristic to make one common edge case work.221try {222Editor.node(editor, point);223} catch (_err) {224point.path[point.path.length - 1] -= 1;225if (point.path[point.path.length - 1] >= 0) {226try {227// this goes to the end of it or raises an exception if228// there's no point here.229return Editor.after(editor, point, { unit: "line" });230} catch (_err) {231return undefined;232}233}234}235return point;236}237238239