Path: blob/master/src/packages/frontend/editors/slate/format/commands.ts
1698 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45import { delay } from "awaiting";6import { isEqual } from "lodash";78import { redux } from "@cocalc/frontend/app-framework";9import { commands } from "@cocalc/frontend/editors/editor-button-bar";10import { getLocale } from "@cocalc/frontend/i18n";11import { is_array, startswith } from "@cocalc/util/misc";12import {13BaseRange,14Editor,15Element,16Location,17Node,18Point,19Range,20Text,21Transforms,22} from "slate";23import { getMarks } from "../edit-bar/marks";24import { SlateEditor } from "../editable-markdown";25import { markdown_to_slate } from "../markdown-to-slate";26import { emptyParagraph } from "../padding";27import { ReactEditor } from "../slate-react";28import { removeBlankLines } from "../util";29import { insertAIFormula } from "./insert-ai-formula";30import { insertImage } from "./insert-image";31import { insertLink } from "./insert-link";32import { insertSpecialChar } from "./insert-special-char";3334// currentWord:35//36// Expand collapsed selection to range containing exactly the37// current word, even if selection potentially spans multiple38// text nodes. If cursor is not *inside* a word (being on edge39// is not inside) then returns undefined. Otherwise, returns40// the Range containing the current word.41//42// NOTE: I posted this on the slate Github and there's a discussion43// with various varients based on this:44// https://github.com/ianstormtaylor/slate/issues/416245function currentWord(editor: SlateEditor): Range | undefined {46const selection = getSelection(editor);47if (selection == null || !Range.isCollapsed(selection)) {48return; // nothing to do -- no current word.49}50const { focus } = selection;51const [node, path] = Editor.node(editor, focus);52if (!Text.isText(node)) {53// focus must be in a text node.54return;55}56const { offset } = focus;57const siblings: any[] = Node.parent(editor, path).children as any;5859// We move to the left from the cursor until leaving the current60// word and to the right as well in order to find the61// start and end of the current word.62let start = { i: path[path.length - 1], offset };63let end = { i: path[path.length - 1], offset };64if (offset == siblings[start.i]?.text?.length) {65// special case when starting at the right hand edge of text node.66moveRight(start);67moveRight(end);68}69const start0 = { ...start };70const end0 = { ...end };7172function len(node): number {73// being careful that there could be some non-text nodes in there, which74// we just treat as length 0.75return node?.text?.length ?? 0;76}7778function charAt(pos: { i: number; offset: number }): string {79const c = siblings[pos.i]?.text?.[pos.offset] ?? "";80return c;81}8283function moveLeft(pos: { i: number; offset: number }): boolean {84if (pos.offset == 0) {85if ((pos.i = 0)) return false;86pos.i -= 1;87pos.offset = Math.max(0, len(siblings[pos.i]) - 1);88return true;89} else {90pos.offset -= 1;91return true;92}93return false;94}9596function moveRight(pos: { i: number; offset: number }): boolean {97if (pos.offset + 1 < len(siblings[pos.i])) {98pos.offset += 1;99return true;100} else {101if (pos.i + 1 < siblings.length) {102pos.offset = 0;103pos.i += 1;104return true;105} else {106if (pos.offset < len(siblings[pos.i])) {107pos.offset += 1; // end of the last block.108return true;109}110}111}112return false;113}114115while (charAt(start).match(/\w/) && moveLeft(start)) {}116// move right 1.117moveRight(start);118while (charAt(end).match(/\w/) && moveRight(end)) {}119if (isEqual(start, start0) || isEqual(end, end0)) {120// if at least one endpoint doesn't change, cursor was not inside a word,121// so we do not select.122return;123}124125const path0 = path.slice(0, path.length - 1);126return {127anchor: { path: path0.concat([start.i]), offset: start.offset },128focus: { path: path0.concat([end.i]), offset: end.offset },129};130}131132function isMarkActive(editor: Editor, mark: string): boolean {133try {134return !!Editor.marks(editor)?.[mark];135} catch (err) {136// see comment in getMarks...137console.warn("Editor.marks", err);138return false;139}140}141142function toggleMark(editor: Editor, mark: string): void {143if (isMarkActive(editor, mark)) {144Editor.removeMark(editor, mark);145} else {146Editor.addMark(editor, mark, true);147}148}149150export function formatSelectedText(editor: SlateEditor, mark: string) {151const selection = getSelection(editor);152if (selection == null) return; // nothing to do.153if (Range.isCollapsed(selection)) {154// select current word (which may partly span multiple text nodes!)155const at = currentWord(editor);156if (at != null) {157// editor.saveValue(true); // TODO: make snapshot so can undo to before format158Transforms.setNodes(159editor,160{ [mark]: !isAlreadyMarked(editor, mark) ? true : undefined },161{ at, split: true, match: (node) => Text.isText(node) },162);163return;164}165// No current word.166// Set thing so if you start typing it has the given167// mark (or doesn't).168toggleMark(editor, mark);169return;170}171172// This formats exactly the current selection or node, even if173// selection spans many nodes, etc.174Transforms.setNodes(175editor,176{ [mark]: !isAlreadyMarked(editor, mark) ? true : undefined },177{ at: selection, match: (node) => Text.isText(node), split: true },178);179}180181function unformatSelectedText(182editor: SlateEditor,183options: { prefix?: string },184): void {185let at: BaseRange | undefined = getSelection(editor);186if (at == null) return; // nothing to do.187if (Range.isCollapsed(at)) {188at = currentWord(editor);189}190if (at == null) return;191if (options.prefix) {192// Remove all formatting of the selected text193// that begins with the given prefix.194let i = 0;195while (i < 100) {196i += 1; // paranoid: just in case there is a stupid infinite loop...197const mark = findMarkWithPrefix(editor, options.prefix);198if (!mark) break;199Transforms.setNodes(200editor,201{ [mark]: false },202{ at, match: (node) => Text.isText(node), split: true },203);204}205}206}207208// returns true if current selection *starts* with mark.209function isAlreadyMarked(editor: Editor, mark: string): boolean {210if (!editor.selection) return false;211return isFragmentAlreadyMarked(212Editor.fragment(editor, editor.selection),213mark,214);215}216217// returns true if fragment *starts* with mark.218function isFragmentAlreadyMarked(fragment, mark: string): boolean {219if (is_array(fragment)) {220fragment = fragment[0];221if (fragment == null) return false;222}223if (Text.isText(fragment) && fragment[mark]) return true;224if (fragment.children) {225return isFragmentAlreadyMarked(fragment.children, mark);226}227return false;228}229230// returns mark if current selection *starts* with a mark with the given prefix.231function findMarkWithPrefix(232editor: Editor,233prefix: string,234): string | undefined {235if (!editor.selection) return;236return findMarkedFragmentWithPrefix(237Editor.fragment(editor, editor.selection),238prefix,239);240}241242// returns mark if fragment *starts* with a mark that starts with prefix243function findMarkedFragmentWithPrefix(244fragment,245prefix: string,246): string | undefined {247if (is_array(fragment)) {248fragment = fragment[0];249if (fragment == null) return;250}251if (Text.isText(fragment)) {252for (const mark in fragment) {253if (startswith(mark, prefix) && fragment[mark]) {254return mark;255}256}257}258if (fragment.children) {259return findMarkedFragmentWithPrefix(fragment.children, prefix);260}261return;262}263264// TODO: make this part of a focus/last selection plugin.265// Is definitely a valid focus point, in that Editor.node will266// work on it.267export function getFocus(editor: SlateEditor): Point {268const focus = editor.selection?.focus ?? editor.lastSelection?.focus;269if (focus == null) {270return { path: [0, 0], offset: 0 };271}272try {273Editor.node(editor, focus);274} catch (_err) {275return { path: [0, 0], offset: 0 };276}277return focus;278}279280// Return a definitely valid selection which is most likely281// to be the current selection (or what it would be, say if282// user recently blurred). Valid means that Editor.node will283// work on both ends.284export function getSelection(editor: SlateEditor): Range {285const selection = editor.selection ?? editor.lastSelection;286if (selection == null) {287return {288focus: { path: [0, 0], offset: 0 },289anchor: { path: [0, 0], offset: 0 },290};291}292try {293Editor.node(editor, selection.focus);294if (!Range.isCollapsed(selection)) {295Editor.node(editor, selection.anchor);296}297} catch (_err) {298return {299focus: { path: [0, 0], offset: 0 },300anchor: { path: [0, 0], offset: 0 },301};302}303return selection;304}305306// get range that's the selection collapsed to the focus point.307export function getCollapsedSelection(editor: SlateEditor): Range {308const focus = getSelection(editor)?.focus;309return { focus, anchor: focus };310}311312export function setSelectionAndFocus(editor: ReactEditor, selection): void {313ReactEditor.focus(editor);314Transforms.setSelection(editor, selection);315}316317export function restoreSelectionAndFocus(editor: SlateEditor): void {318const { selection, lastSelection } = editor;319if (selection != null) return;320if (lastSelection == null) return;321setSelectionAndFocus(editor, lastSelection);322}323324export async function formatAction(325editor: SlateEditor,326cmd: string,327args,328project_id?: string,329) {330const isFocused = ReactEditor.isFocused(editor);331const { selection, lastSelection } = editor;332try {333if (334cmd === "bold" ||335cmd === "italic" ||336cmd === "underline" ||337cmd === "strikethrough" ||338cmd === "code" ||339cmd === "sup" ||340cmd === "sub"341) {342formatSelectedText(editor, cmd);343return;344}345346if (cmd === "color") {347// args = #aa00bc (the hex color)348unformatSelectedText(editor, { prefix: "color:" });349if (args) {350formatSelectedText(editor, `color:${args.toLowerCase()}`);351} else {352for (const mark in getMarks(editor)) {353if (mark.startsWith("color:")) {354Editor.removeMark(editor, mark);355}356}357}358return;359}360361if (cmd === "font_family") {362unformatSelectedText(editor, { prefix: "font-family:" });363formatSelectedText(editor, `font-family:${args}`);364return;365}366367if (startswith(cmd, "font_size")) {368unformatSelectedText(editor, { prefix: "font-size:" });369formatSelectedText(editor, `font-size:${args}`);370return;371}372373if (cmd === "equation") {374transformToEquation(editor, false);375return;376}377378if (cmd === "comment") {379transformToComment(editor);380return;381}382383if (cmd === "display_equation") {384transformToEquation(editor, true);385return;386}387388if (cmd === "quote") {389formatQuote(editor);390return;391}392393if (394cmd === "insertunorderedlist" ||395cmd === "insertorderedlist" ||396cmd === "table" ||397cmd === "horizontalRule" ||398cmd === "linebreak"399) {400insertSnippet(editor, cmd);401return;402}403404if (cmd === "link") {405insertLink(editor);406return;407}408409if (cmd === "image") {410insertImage(editor);411return;412}413414if (cmd === "SpecialChar") {415insertSpecialChar(editor);416return;417}418419if (cmd === "format_code") {420insertMarkdown(421editor,422"\n```\n" + selectionToText(editor).trim() + "\n```\n",423);424return;425}426427if (cmd === "ai_formula") {428if (project_id == null) throw new Error("ai_formula requires project_id");429const account_store = redux.getStore("account")430const locale = getLocale(account_store.get("other_settings"))431const formula = await insertAIFormula(project_id, locale);432const value = removeDollars(removeBlankLines(formula.trim()));433const node: Node = {434type: "math_inline",435value,436isVoid: true,437isInline: true,438children: [{ text: "" }],439};440Transforms.insertFragment(editor, [node]);441return;442}443444if (startswith(cmd, "format_heading_")) {445// single digit is fine, since headings only go up to level 6.446const level = parseInt(cmd[cmd.length - 1]);447formatHeading(editor, level);448return;449}450} finally {451if (!isFocused) {452ReactEditor.focus(editor);453setSelectionAndFocus(editor, selection ?? lastSelection);454await delay(1);455ReactEditor.focus(editor);456setSelectionAndFocus(editor, selection ?? lastSelection);457}458}459460console.warn("WARNING -- slate.format_action not implemented", {461cmd,462args,463editor,464});465}466467function insertSnippet(editor: ReactEditor, name: string): boolean {468let markdown = commands.md[name]?.wrap?.left;469if (name == "insertunorderedlist") {470// better for a wysiwyg editor...471markdown = "-";472} else if (name == "insertorderedlist") {473markdown = "1.";474} else if (name == "linebreak") {475markdown = "<br/>";476}477if (markdown == null) return false;478insertMarkdown(editor, markdown.trim());479return true;480}481482function insertMarkdown(editor: ReactEditor, markdown: string) {483const doc = markdown_to_slate(markdown, true);484Transforms.insertNodes(editor, [...doc, emptyParagraph()]);485}486487function transformToEquation(editor: Editor, display: boolean): void {488let value = selectionToText(editor).trim();489if (!value) {490value = "x^2"; // placeholder math491} else {492// eliminate blank lines which break math apart493value = removeBlankLines(value);494}495let node: Node;496if (display) {497node = {498type: "math_block",499value,500isVoid: true,501children: [{ text: "" }],502};503} else {504node = {505type: "math_inline",506value,507isVoid: true,508isInline: true,509children: [{ text: "" }],510};511}512Transforms.insertFragment(editor, [node]);513}514515function transformToComment(editor: Editor): void {516const html = "<!--" + selectionToText(editor).trim() + "-->\n\n";517const fragment: Node[] = [518{519type: "html_block",520html,521isVoid: true,522isInline: false,523children: [{ text: "" }],524},525];526Transforms.insertFragment(editor, fragment);527}528529// TODO: This is very buggy and can't work in general, e.g., because530// of virtualization. we use it here usually for small snippets of531// visible text, so it tends to be OK. Just temper your expectations!532export function selectionToText(editor: Editor): string {533if (!editor.selection) {534// no selection so nothing to do.535return "";536}537// This is just directly using DOM API, not slatejs, so538// could run into a subtle problem e.g., due to windowing.539// However, that's very unlikely given our application.540return window.getSelection()?.toString() ?? "";541}542543// Setting heading at a given point to a certain level.544// level = 0 -- not a heading545// levels = 1 to 6 -- normal headings.546// The code below is complicated, because there are numerous subtle547// cases that can arise and we have to both create and remove548// being a heading.549export function formatHeading(editor, level: number): void {550const at = getCollapsedSelection(editor);551const options = {552match: (node) => Element.isElement(node) && Editor.isBlock(editor, node),553mode: "highest" as "highest",554at,555};556const fragment = Editor.fragment(editor, at);557const type = fragment[0]?.["type"];558if (type != "heading" && type != "paragraph") {559// Markdown doesn't let most things be in headers.560// Technically markdown allows for headers as entries in other561// things like lists, but we're not supporting this here, since562// that just seems really annoying.563return;564}565try {566if (type == "heading") {567// mutate the type to what's request568if (level == 0) {569if (Editor.isBlock(editor, fragment[0]["children"]?.[0])) {570// descendant of heading is a block, so we can just unwrap,571// which we *can't* do if it were an inline node (e.g., text).572Transforms.unwrapNodes(editor, {573match: (node) => node["type"] == "heading",574mode: "highest",575at,576});577return;578}579// change header to paragraph580Transforms.setNodes(581editor,582{ type: "paragraph", level: undefined } as Partial<Element>,583options,584);585} else {586// change header level587Transforms.setNodes(editor, { level } as Partial<Element>, options);588}589return;590}591if (level == 0) return; // paragraph mode -- no heading.592Transforms.setNodes(593editor,594{ type: "heading", level } as Partial<Element>,595options,596);597} finally {598setSelectionAndFocus(editor, at);599}600}601602function matchingNodes(editor, options): Element[] {603const v: Element[] = [];604for (const x of Editor.nodes(editor, options)) {605const elt = x[0];606if (Element.isElement(elt)) {607// **this specifically excludes including the entire editor608// as a matching node**609v.push(elt);610}611}612return v;613}614615function containingBlocks(editor: Editor, at: Location): Element[] {616return matchingNodes(editor, {617at,618mode: "lowest",619match: (node) => Element.isElement(node) && Editor.isBlock(editor, node),620});621}622623function isExactlyInBlocksOfType(624editor: Editor,625at: Location,626type: string,627): boolean {628// Get the blocks of the given type containing at:629const blocksOfType = matchingNodes(editor, {630at,631mode: "lowest",632match: (node) => node["type"] == type,633});634if (blocksOfType.length == 0) {635return false;636}637// The content in at *might* be exactly contained638// in blocks of the given type. To decide, first639// get the blocks containing at:640let blocks: Element[] = containingBlocks(editor, at);641642// This is complicated, of course mainly due643// to multiple blocks.644for (const blockOfType of blocksOfType) {645const { children } = blockOfType;646if (!isEqual(children, blocks.slice(0, children.length))) {647return false;648} else {649blocks = blocks.slice(children.length);650}651}652return true;653}654655// Toggle whether or not the selection is quoted.656function formatQuote(editor): void {657const at = getSelection(editor);658659// The selected text *might* be exactly contained660// in a blockquote (or multiple of them). If so, we remove it.661// If not we wrap everything in a new block quote.662if (isExactlyInBlocksOfType(editor, at, "blockquote")) {663// Unquote the selected text (just removes ones level of quoting).664Transforms.unwrapNodes(editor, {665match: (node) => node["type"] == "blockquote",666mode: "lowest",667at,668});669} else {670// Quote the blocks containing the selection.671Transforms.wrapNodes(editor, { type: "blockquote" } as Element, {672at,673match: (node) => Element.isElement(node) && Editor.isBlock(editor, node),674mode: "lowest",675});676}677}678679// Get rid of starting and ending $..$ or $$..$$ dollar signs680function removeDollars(formula: string): string {681if (formula.startsWith("$") && formula.endsWith("$")) {682return formula.substring(1, formula.length - 1);683}684685if (formula.startsWith("$$") && formula.endsWith("$$")) {686return formula.substring(2, formula.length - 2);687}688689return formula;690}691692693