From 0481fcf96b5639072183a35e2313c925508b7d1d Mon Sep 17 00:00:00 2001 From: jocs Date: Sun, 3 Dec 2023 20:13:03 +0800 Subject: [PATCH] refactor: format class --- .../base/{content/index.ts => content.ts} | 8 +- lib/block/base/format.ts | 1612 +++++++++++++++++ lib/block/base/format/backspace.ts | 89 - lib/block/base/format/clickHandler.ts | 72 - lib/block/base/format/converter.ts | 598 ------ lib/block/base/format/delete.ts | 33 - lib/block/base/format/enterHandler.ts | 38 - lib/block/base/format/format.ts | 317 ---- lib/block/base/format/index.ts | 280 --- lib/block/base/format/inputHandler.ts | 96 - lib/block/base/format/keyupHandler.ts | 60 - lib/block/base/parent.ts | 2 +- lib/block/base/treeNode.ts | 2 +- lib/block/mixins/containerQueryBlock.ts | 2 +- lib/config/index.ts | 12 +- lib/editor/index.ts | 6 +- lib/inlineRenderer/renderer/highlight.ts | 2 +- lib/inlineRenderer/renderer/index.ts | 62 +- lib/selection/index.ts | 320 ++-- lib/state/types.ts | 16 +- lib/ui/imageResizeBar/index.ts | 4 +- lib/ui/imageToolbar/index.ts | 10 +- lib/ui/inlineFormatToolbar/index.ts | 4 +- lib/utils/image.ts | 9 +- 24 files changed, 1849 insertions(+), 1805 deletions(-) rename lib/block/base/{content/index.ts => content.ts} (98%) create mode 100644 lib/block/base/format.ts delete mode 100644 lib/block/base/format/backspace.ts delete mode 100644 lib/block/base/format/clickHandler.ts delete mode 100644 lib/block/base/format/converter.ts delete mode 100644 lib/block/base/format/delete.ts delete mode 100644 lib/block/base/format/enterHandler.ts delete mode 100644 lib/block/base/format/format.ts delete mode 100644 lib/block/base/format/index.ts delete mode 100644 lib/block/base/format/inputHandler.ts delete mode 100644 lib/block/base/format/keyupHandler.ts diff --git a/lib/block/base/content/index.ts b/lib/block/base/content.ts similarity index 98% rename from lib/block/base/content/index.ts rename to lib/block/base/content.ts index 4267b391..1a91cb52 100644 --- a/lib/block/base/content/index.ts +++ b/lib/block/base/content.ts @@ -201,7 +201,7 @@ class Content extends TreeNode { muya, newNodeState ); - this.scrollPage.append(newNode, "user"); + this.scrollPage?.append(newNode, "user"); cursorBlock = newNode.children.head; } offset = adjustOffset(0, cursorBlock, event); @@ -223,7 +223,7 @@ class Content extends TreeNode { */ getCursor() { const selection = this.selection.getSelection(); - if (!selection) { + if (selection == null) { return null; } @@ -519,11 +519,11 @@ class Content extends TreeNode { }; blurHandler() { - this.scrollPage.handleBlurFromContent(this); + this.scrollPage?.handleBlurFromContent(this); } focusHandler() { - this.scrollPage.handleFocusFromContent(this); + this.scrollPage?.handleFocusFromContent(this); } getAncestors() { diff --git a/lib/block/base/format.ts b/lib/block/base/format.ts new file mode 100644 index 00000000..1bae788f --- /dev/null +++ b/lib/block/base/format.ts @@ -0,0 +1,1612 @@ +/* eslint-disable no-fallthrough */ +import ScrollPage from "@muya/block"; +import Content from "@muya/block/base/content"; +import { + CLASS_NAMES, + FORMAT_MARKER_MAP, + FORMAT_TAG_MAP, + FORMAT_TYPES, + PARAGRAPH_STATE, + THEMATIC_BREAK_STATE, +} from "@muya/config"; +import { generator, tokenizer } from "@muya/inlineRenderer/lexer"; +import type { + CodeEmojiMathToken, + HTMLTagToken, + StrongEmToken, + TextToken, + Token, +} from "@muya/inlineRenderer/types"; +import Selection from '@muya/selection'; +import { getTextContent } from "@muya/selection/dom"; +import { Cursor } from "@muya/selection/types"; +import { IBulletListState, IOrderListState } from "@muya/state/types"; +import { Nullable } from "@muya/types"; +import { conflict, getCursorReference, isMouseEvent } from "@muya/utils"; +import { IImageInfo, correctImageSrc, getImageInfo } from "@muya/utils/image"; +import logger from "@muya/utils/logger"; +import AtxHeading from "../commonMark/atxHeading"; +import BulletList from "../commonMark/bulletList"; +import SetextHeading from "../commonMark/setextHeading"; + +interface IOffset { + offset: number; +} + +interface IOffsetWithDelta extends IOffset { + delta: number; +} + +const debug = logger("block.format:"); + +function isEmojiToken(token: Token): token is CodeEmojiMathToken { + return token.type === "emoji"; +} + +const INLINE_UPDATE_FRAGMENTS = [ + "(?:^|\n) {0,3}([*+-] {1,4})", // Bullet list + "^(\\[[x ]{1}\\] {1,4})", // Task list **match from beginning** + "(?:^|\n) {0,3}(\\d{1,9}(?:\\.|\\)) {1,4})", // Order list + "(?:^|\n) {0,3}(#{1,6})(?=\\s{1,}|$)", // ATX headings + "^(?:[\\s\\S]+?)\\n {0,3}(\\={3,}|\\-{3,})(?= {1,}|$)", // Setext headings **match from beginning** + "(?:^|\n) {0,3}(>).+", // Block quote + "^( {4,})", // Indent code **match from beginning** + // '^(\\[\\^[^\\^\\[\\]\\s]+?(?= 0 && dis < MARKER_LEN) return -dis; + if (dis >= MARKER_LEN && dis <= len - MARKER_LEN) return -MARKER_LEN; + if (dis > len - MARKER_LEN && dis <= len) + return len - dis - 2 * MARKER_LEN; + if (dis > len) return -2 * MARKER_LEN; + + break; + } + + case "html_tag": { + const { tag } = token; + // handle underline, sup, sub + const OPEN_MARKER_LEN = FORMAT_TAG_MAP[tag].open.length; + const CLOSE_MARKER_LEN = FORMAT_TAG_MAP[tag].close.length; + + if (dis < 0) return 0; + if (dis >= 0 && dis < OPEN_MARKER_LEN) return -dis; + if (dis >= OPEN_MARKER_LEN && dis <= len - CLOSE_MARKER_LEN) + return -OPEN_MARKER_LEN; + if (dis > len - CLOSE_MARKER_LEN && dis <= len) + return len - dis - OPEN_MARKER_LEN - CLOSE_MARKER_LEN; + if (dis > len) return -OPEN_MARKER_LEN - CLOSE_MARKER_LEN; + + break; + } + + case "link": { + const { anchor } = token; + const MARKER_LEN = 1; + + if (dis < MARKER_LEN) return 0; + if (dis >= MARKER_LEN && dis <= MARKER_LEN + anchor.length) return -1; + if (dis > MARKER_LEN + anchor.length) return anchor.length - dis; + + break; + } + + case "image": { + const { alt } = token; + const MARKER_LEN = 1; + + if (dis < MARKER_LEN) return 0; + if (dis >= MARKER_LEN && dis < MARKER_LEN * 2) return -1; + if (dis >= MARKER_LEN * 2 && dis <= MARKER_LEN * 2 + alt.length) + return -2; + if (dis > MARKER_LEN * 2 + alt.length) return alt.length - dis; + + break; + } + } +} + +function clearFormat(token: Token, cursor: Cursor) { + switch (token.type) { + case "strong": + + case "del": + + case "em": + + case "link": + + case "html_tag": { + // underline, sub, sup + const { parent, children } = token; + const index = parent.indexOf(token); + parent.splice(index, 1, ...(children as Token[])); + + break; + } + + case "image": { + const { parent, range } = token; + const index = parent.indexOf(token); + const newToken: TextToken = { + type: "text", + raw: token.alt, + content: token.alt, // maybe src is better? + parent, + range, // the range is wrong, but it will not be used. + } + + parent.splice(index, 1, newToken); + + break; + } + + case "inline_math": + + case "inline_code": { + const { parent, range } = token; + const index = parent.indexOf(token); + const newToken: TextToken = { + type: "text", + raw: token.content, + content: token.content, + parent, + range, // the range is wrong, but it will not be used. + } + + parent.splice(index, 1, newToken); + + break; + } + } + + const start = cursor.start as IOffsetWithDelta; + const end = cursor.end as IOffsetWithDelta; + + if (start) { + const deltaStart = getOffset(start.offset, token)!; + start.delta += deltaStart; + } + + if (end) { + const deltaEnd = getOffset(end.offset, token)!; + end.delta += deltaEnd; + } +} + +const checkTokenIsInlineFormat = (token: Token) => { + const { type } = token; + + if (FORMAT_TYPES.includes(type)) { + return true; + } + + if ( + type === "html_tag" && + /^(?:u|sub|sup|mark)$/i.test((token as HTMLTagToken).tag) + ) { + return true; + } + + return false; +}; + +class Format extends Content { + static blockName = "format"; + + private _checkCursorInTokenType( + text: string, + offset: number, + type: Token["type"] + ): Token | null { + const tokens = tokenizer(text, { + hasBeginRules: false, + options: this.muya.options, + }); + + let result = null; + + const travel = (tokens: Token[]) => { + for (const token of tokens) { + if (token.range.start > offset) { + break; + } + + if ( + token.type === type && + offset > token.range.start && + offset < token.range.end + ) { + result = token; + break; + } else if ((token as StrongEmToken).children) { + // As StrongEmToken only used to pass the TS check. + travel((token as StrongEmToken).children); + } + } + }; + + travel(tokens); + + return result; + } + + private _checkNotSameToken(oldText: string, text: string) { + const { options } = this.muya; + const oldTokens = tokenizer(oldText, { + options, + }); + const tokens = tokenizer(text, { + options, + }); + + const oldCache: Record = {}; + const cache: Record = {}; + + for (const { type } of oldTokens) { + if (oldCache[type]) { + oldCache[type]++; + } else { + oldCache[type] = 1; + } + } + + for (const { type } of tokens) { + if (cache[type]) { + cache[type]++; + } else { + cache[type] = 1; + } + } + + if (Object.keys(oldCache).length !== Object.keys(cache).length) { + return true; + } + + for (const key of Object.keys(oldCache)) { + if (!cache[key] || oldCache[key] !== cache[key]) { + return true; + } + } + + return false; + } + + // TODO: @JOCS remove use this.selection directly + checkNeedRender(cursor: Cursor = this.selection as Cursor) { + const { labels } = this.inlineRenderer; + const { text } = this; + const { start: cStart, end: cEnd, anchor, focus } = cursor; + const anchorOffset = cStart ? cStart.offset : anchor!.offset; + const focusOffset = cEnd ? cEnd.offset : focus!.offset; + const NO_NEED_TOKEN_REG = /text|hard_line_break|soft_line_break/; + + for (const token of tokenizer(text, { + labels, + options: this.muya.options, + })) { + if (NO_NEED_TOKEN_REG.test(token.type)) continue; + + const { start, end } = token.range; + const textLen = text.length; + + if ( + conflict( + [Math.max(0, start - 1), Math.min(textLen, end + 1)], + [anchorOffset, anchorOffset] + ) || + conflict( + [Math.max(0, start - 1), Math.min(textLen, end + 1)], + [focusOffset, focusOffset] + ) + ) { + return true; + } + } + + return false; + } + + blurHandler() { + super.blurHandler(); + const needRender = this.checkNeedRender(); + if (needRender) { + this.update(); + } + } + + /** + * Update emoji text if cursor is in emoji syntax. + * @param {string} text emoji text + */ + setEmoji(text: string) { + // TODO: @JOCS remove use this.selection directly. + const { anchor } = this.selection; + const editEmoji = this._checkCursorInTokenType( + this.text, + anchor!.offset, + "emoji" + ); + + if (editEmoji) { + const { start, end } = editEmoji.range; + const oldText = this.text; + this.text = + oldText.substring(0, start) + `:${text}:` + oldText.substring(end); + const offset = start + text.length + 2; + this.setCursor(offset, offset, true); + } + } + + replaceImage({ token }: IImageInfo, { alt = "", src = "", title = "" }) { + const { type } = token; + const { start, end } = token.range; + const oldText = this.text; + let imageText = ""; + if (type === "image") { + imageText = "!["; + if (alt) { + imageText += alt; + } + imageText += "]("; + if (src) { + imageText += src + .replace(/ /g, encodeURI(" ")) + .replace(/#/g, encodeURIComponent("#")); + } + + if (title) { + imageText += ` "${title}"`; + } + imageText += ")"; + } else if (type === "html_tag") { + const { attrs } = token; + Object.assign(attrs, { alt, src, title }); + imageText = " -1 ? imageId : imageId + "_" + token.range.start + } img`; + const image: HTMLImageElement | null = document.querySelector(selector); + + if (image) { + image.click(); + } + } + + deleteImage({ token }: IImageInfo) { + const oldText = this.text; + const { start, end } = token.range; + const { eventCenter } = this.muya; + + this.text = oldText.substring(0, start) + oldText.substring(end); + this.setCursor(start, start, true); + + // Hide image toolbar and image transformer + eventCenter.emit("muya-transformer", { reference: null }); + eventCenter.emit("muya-image-toolbar", { reference: null }); + } + + clickHandler(event: Event): void { + if (!isMouseEvent(event)) { + return; + } + // Handler click inline math and inline ruby html. + const { target } = event; + const inlineRuleRenderEle = + (target as HTMLElement).closest(`.${CLASS_NAMES.MU_MATH_RENDER}`) || + (target as HTMLElement).closest(`.${CLASS_NAMES.MU_RUBY_RENDER}`); + + if (inlineRuleRenderEle) { + return this._handleClickInlineRuleRender(event, inlineRuleRenderEle); + } + + requestAnimationFrame(() => { + // TODO: @JOCS, remove use this.selection directly. + if (event.shiftKey && this.selection.anchorBlock !== this) { + // TODO: handle select multiple paragraphs + return; + } + + const currentCursor = this.getCursor(); + + if (!currentCursor) { + return; + } + + const cursor = Object.assign({}, currentCursor, { + block: this, + path: this.path, + }); + + // TODO: The codes bellow maybe is wrong? and remove use this.selection directly + const needRender = + this.selection.anchorBlock === this + ? this.checkNeedRender(cursor) || this.checkNeedRender() + : this.checkNeedRender(cursor); + + if (needRender) { + this.update(cursor); + } + + this.selection.setSelection(cursor); + + // Check and show format picker + if (cursor.start.offset !== cursor.end.offset) { + const reference = getCursorReference(); + + this.muya.eventCenter.emit("muya-format-picker", { + reference, + block: this, + }); + } + }); + } + + keyupHandler(): void { + if (this.isComposed) { + return; + } + // TODO: @JOCS remove use this.selection directly + const { + anchor: oldAnchor, + focus: oldFocus, + isSelectionInSameBlock, + } = this.selection; + + if (!isSelectionInSameBlock) { + return; + } + + const { anchor, focus } = this.getCursor()!; + + if ( + anchor.offset !== oldAnchor?.offset || + focus.offset !== oldFocus?.offset + ) { + const needUpdate = this.checkNeedRender({ anchor, focus }); + const cursor = { anchor, focus, block: this, path: this.path }; + + if (needUpdate) { + this.update(cursor); + } + + this.selection.setSelection(cursor); + } + + // Check not edit emoji + const editEmoji = this._checkCursorInTokenType( + this.text, + anchor.offset, + "emoji" + ); + + if (!editEmoji) { + this.muya.eventCenter.emit("muya-emoji-picker", { + emojiText: "", + }); + } + + // Check and show format picker + if (anchor.offset !== focus.offset) { + const reference = getCursorReference(); + + this.muya.eventCenter.emit("muya-format-picker", { + reference, + block: this, + }); + } + } + + inputHandler(event: Event): void { + // Do not use `isInputEvent` util, because compositionEnd event also invoke this method. + if ( + this.isComposed || + /historyUndo|historyRedo/.test((event as InputEvent).inputType) + ) { + return; + } + const { domNode } = this; + const { start, end } = this.getCursor()!; + const textContent = getTextContent(domNode!, [ + CLASS_NAMES.MU_MATH_RENDER, + CLASS_NAMES.MU_RUBY_RENDER, + ]); + const isInInlineMath = !!this._checkCursorInTokenType( + textContent, + start.offset, + "inline_math" + ); + const isInInlineCode = !!this._checkCursorInTokenType( + textContent, + start.offset, + "inline_code" + ); + + // eslint-disable-next-line prefer-const + let { needRender, text } = this.autoPair( + event, + textContent, + start, + end, + isInInlineMath, + isInInlineCode, + "format" + ); + + if (this._checkNotSameToken(this.text, text)) { + needRender = true; + } + + this.text = text; + + const cursor = { + path: this.path, + block: this, + anchor: { + offset: start.offset, + }, + focus: { + offset: end.offset, + }, + }; + + const checkMarkedUpdate = this.checkNeedRender(cursor); + + if (checkMarkedUpdate || needRender) { + this.update(cursor); + } + + this.selection.setSelection(cursor); + // check edit emoji + if ( + (event as InputEvent).inputType !== "insertFromPaste" && + (event as InputEvent).inputType !== "deleteByCut" + ) { + const emojiToken = this._checkCursorInTokenType( + this.text, + start.offset, + "emoji" + ); + if (emojiToken && isEmojiToken(emojiToken)) { + const { content: emojiText } = emojiToken; + const reference = getCursorReference(); + + this.muya.eventCenter.emit("muya-emoji-picker", { + reference, + emojiText, + block: this, + }); + } + } + + // Check block convert if needed, and table cell no need to check. + if (this.blockName !== "table.cell.content") { + this._convertIfNeeded(); + } + } + + private _convertIfNeeded() { + const { text } = this; + + const [ + match, + bulletList, + taskList, + orderList, + atxHeading, + setextHeading, + blockquote, + indentedCodeBlock, + thematicBreak, + ] = text.match(INLINE_UPDATE_REG) || []; + + switch (true) { + case !!thematicBreak && + new Set(thematicBreak.split("").filter((i) => /\S/.test(i))).size === 1: + this._convertToThematicBreak(); + break; + + case !!bulletList: + this._convertToList(); + break; + + case !!orderList: + this._convertToList(); + break; + + case !!taskList: + this.convertToTaskList(); + break; + + case !!atxHeading: + this._convertToAtxHeading(atxHeading); + break; + + case !!setextHeading: + this._convertToSetextHeading(setextHeading); + break; + + case !!blockquote: + this._convertToBlockQuote(); + break; + + case !!indentedCodeBlock: + this._convertToIndentedCodeBlock(); + break; + + case !match: + default: + this.convertToParagraph(); + break; + } + } + + // Thematic Break + private _convertToThematicBreak() { + // If the block is already thematic break, no need to update. + if (this.parent?.blockName === "thematic-break") { + return; + } + const { hasSelection } = this; + const { start, end } = this.getCursor()!; + const { text, muya } = this; + const lines = text.split("\n"); + const preParagraphLines = []; + let thematicLine = ""; + const postParagraphLines = []; + let thematicLineHasPushed = false; + + for (const l of lines) { + const THEMATIC_BREAK_REG = + // eslint-disable-next-line no-useless-escape + / {0,3}(?:\* *\* *\*|- *- *-|_ *_ *_)[ \*\-\_]*$/; + if (THEMATIC_BREAK_REG.test(l) && !thematicLineHasPushed) { + thematicLine = l; + thematicLineHasPushed = true; + } else if (!thematicLineHasPushed) { + preParagraphLines.push(l); + } else { + postParagraphLines.push(l); + } + } + + const newNodeState = Object.assign({}, THEMATIC_BREAK_STATE, { + text: thematicLine, + }); + + if (preParagraphLines.length) { + const preParagraphState = Object.assign({}, PARAGRAPH_STATE, { + text: preParagraphLines.join("\n"), + }); + const preParagraphBlock = ScrollPage.loadBlock( + preParagraphState.name + ).create(muya, preParagraphState); + this.parent!.parent!.insertBefore(preParagraphBlock, this.parent); + } + + if (postParagraphLines.length) { + const postParagraphState = Object.assign({}, PARAGRAPH_STATE, { + text: postParagraphLines.join("\n"), + }); + const postParagraphBlock = ScrollPage.loadBlock( + postParagraphState.name + ).create(muya, postParagraphState); + this.parent!.parent!.insertAfter(postParagraphBlock, this.parent); + } + + const thematicBlock = ScrollPage.loadBlock(newNodeState.name).create( + muya, + newNodeState + ); + + this.parent!.replaceWith(thematicBlock); + + if (hasSelection) { + const thematicBreakContent = thematicBlock.children.head; + const preParagraphTextLength = preParagraphLines.reduce( + (acc, i) => acc + i.length + 1, + 0 + ); // Add one, because the `\n` + const startOffset = Math.max(0, start.offset - preParagraphTextLength); + const endOffset = Math.max(0, end.offset - preParagraphTextLength); + + thematicBreakContent.setCursor(startOffset, endOffset, true); + } + } + + private _convertToList() { + const { text, parent, muya, hasSelection } = this; + const { preferLooseListItem } = muya.options; + const matches = text.match( + /^([\s\S]*?) {0,3}([*+-]|\d{1,9}(?:\.|\))) {1,4}([\s\S]*)$/ + ); + const blockName = /\d/.test(matches![2]) ? "order-list" : "bullet-list"; + + if (matches![1]) { + const paragraphState = { + name: "paragraph", + text: matches![1].trim(), + }; + const paragraph = ScrollPage.loadBlock(paragraphState.name).create( + muya, + paragraphState + ); + parent!.parent!.insertBefore(paragraph, parent); + } + + const listState = { + name: blockName, + meta: { + loose: preferLooseListItem, + }, + children: [ + { + name: "list-item", + children: [ + { + name: "paragraph", + text: matches![3], + }, + ], + }, + ], + }; + + if (blockName === "order-list") { + (listState as IOrderListState).meta.delimiter = matches![2].slice(-1); + (listState as IOrderListState).meta.start = Number( + matches![2].slice(0, -1) + ); + } else { + (listState as IBulletListState).meta.marker = matches![2]; + } + + const list = ScrollPage.loadBlock(listState.name).create(muya, listState); + parent!.replaceWith(list); + + const firstContent = list.firstContentInDescendant(); + + if (hasSelection) { + firstContent.setCursor(0, 0, true); + } + + // convert `[*-+] \[[xX ]\] ` to task list. + const TASK_LIST_REG = /^\[[x ]\] {1,4}/i; + if (TASK_LIST_REG.test(firstContent.text)) { + firstContent.convertToTaskList(); + } + } + + convertToTaskList() { + const { text, parent, muya, hasSelection } = this; + const { preferLooseListItem } = muya.options; + const listItem = parent!.parent!; + const list = listItem?.parent as BulletList; + const matches = text.match(/^\[([x ]{1})\] {1,4}([\s\S]*)$/i); + + if ( + !list || + list.blockName !== "bullet-list" || + !parent!.isFirstChild() || + matches == null + ) { + return; + } + + const listState = { + name: "task-list", + meta: { + loose: preferLooseListItem, + marker: list.meta.marker, + }, + children: [ + { + name: "task-list-item", + meta: { + checked: matches[1] !== " ", + }, + children: listItem.map((node) => { + if (node === parent) { + return { + name: "paragraph", + text: matches[2], + }; + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (node as any).getState(); + } + }), + }, + ], + }; + + const newTaskList = ScrollPage.loadBlock(listState.name).create( + muya, + listState + ); + + switch (true) { + case listItem.isOnlyChild(): + list.replaceWith(newTaskList); + break; + + case listItem.isFirstChild(): + list.parent!.insertBefore(newTaskList, list); + listItem.remove(); + break; + + case listItem.isLastChild(): + list.parent!.insertAfter(newTaskList, list); + listItem.remove(); + break; + + default: { + const bulletListState: IBulletListState = { + name: "bullet-list", + meta: { + loose: preferLooseListItem, + marker: list.meta.marker, + }, + children: [], + }; + const offset = list.offset(listItem); + list.forEachAt(offset + 1, undefined, (node) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + bulletListState.children.push((node as any).getState()); + node.remove(); + }); + + const bulletList = ScrollPage.loadBlock(bulletListState.name).create( + muya, + bulletListState + ); + list.parent!.insertAfter(newTaskList, list); + newTaskList.parent.insertAfter(bulletList, newTaskList); + listItem.remove(); + break; + } + } + + if (hasSelection) { + newTaskList.firstContentInDescendant().setCursor(0, 0, true); + } + } + + // ATX Heading + private _convertToAtxHeading(atxHeading: string) { + const level = atxHeading.length; + if ( + this.parent!.blockName === "atx-heading" && + (this.parent as AtxHeading).meta.level === level + ) { + return; + } + + const { hasSelection } = this; + const { start, end } = this.getCursor()!; + const { text, muya } = this; + const lines = text.split("\n"); + const preParagraphLines = []; + let atxLine = ""; + const postParagraphLines = []; + let atxLineHasPushed = false; + + for (const l of lines) { + if (/^ {0,3}#{1,6}(?=\s{1,}|$)/.test(l) && !atxLineHasPushed) { + atxLine = l; + atxLineHasPushed = true; + } else if (!atxLineHasPushed) { + preParagraphLines.push(l); + } else { + postParagraphLines.push(l); + } + } + + if (preParagraphLines.length) { + const preParagraphState = { + name: "paragraph", + text: preParagraphLines.join("\n"), + }; + const preParagraphBlock = ScrollPage.loadBlock( + preParagraphState.name + ).create(muya, preParagraphState); + this.parent!.parent!.insertBefore(preParagraphBlock, this.parent); + } + + if (postParagraphLines.length) { + const postParagraphState = { + name: "paragraph", + text: postParagraphLines.join("\n"), + }; + const postParagraphBlock = ScrollPage.loadBlock( + postParagraphState.name + ).create(muya, postParagraphState); + this.parent!.parent!.insertAfter(postParagraphBlock, this.parent); + } + + const newNodeState = { + name: "atx-heading", + meta: { + level, + }, + text: atxLine, + }; + + const atxHeadingBlock = ScrollPage.loadBlock(newNodeState.name).create( + muya, + newNodeState + ); + + this.parent!.replaceWith(atxHeadingBlock); + + if (hasSelection) { + const atxHeadingContent = atxHeadingBlock.children.head; + const preParagraphTextLength = preParagraphLines.reduce( + (acc, i) => acc + i.length + 1, + 0 + ); // Add one, because the `\n` + const startOffset = Math.max(0, start.offset - preParagraphTextLength); + const endOffset = Math.max(0, end.offset - preParagraphTextLength); + atxHeadingContent.setCursor(startOffset, endOffset, true); + } + } + + // Setext Heading + private _convertToSetextHeading(setextHeading: string) { + const level = /=/.test(setextHeading) ? 2 : 1; + if ( + this.parent?.blockName === "setext-heading" && + (this.parent as SetextHeading).meta.level === level + ) { + return; + } + + const { hasSelection } = this; + const { text, muya } = this; + const lines = text.split("\n"); + const setextLines = []; + const postParagraphLines = []; + let setextLineHasPushed = false; + + for (const l of lines) { + if (/^ {0,3}(?:={3,}|-{3,})(?= {1,}|$)/.test(l) && !setextLineHasPushed) { + setextLineHasPushed = true; + } else if (!setextLineHasPushed) { + setextLines.push(l); + } else { + postParagraphLines.push(l); + } + } + + const newNodeState = { + name: "setext-heading", + meta: { + level, + underline: setextHeading, + }, + text: setextLines.join("\n"), + }; + + const setextHeadingBlock = ScrollPage.loadBlock(newNodeState.name).create( + muya, + newNodeState + ); + + this.parent!.replaceWith(setextHeadingBlock); + + if (postParagraphLines.length) { + const postParagraphState = { + name: "paragraph", + text: postParagraphLines.join("\n"), + }; + const postParagraphBlock = ScrollPage.loadBlock( + postParagraphState.name + ).create(muya, postParagraphState); + setextHeadingBlock.parent.insertAfter( + postParagraphBlock, + setextHeadingBlock + ); + } + + if (hasSelection) { + const cursorBlock = setextHeadingBlock.children.head; + const offset = cursorBlock.text.length; + cursorBlock.setCursor(offset, offset, true); + } + } + + // Block Quote + private _convertToBlockQuote() { + const { text, muya, hasSelection } = this; + const { start, end } = this.getCursor()!; + const lines = text.split("\n"); + const preParagraphLines = []; + const quoteLines = []; + let quoteLinesHasPushed = false; + let delta = 0; + + for (const l of lines) { + if (/^ {0,3}>/.test(l) && !quoteLinesHasPushed) { + quoteLinesHasPushed = true; + const tokens = /( *> *)(.*)/.exec(l); + delta = tokens![1].length; + quoteLines.push(tokens![2]); + } else if (!quoteLinesHasPushed) { + preParagraphLines.push(l); + } else { + quoteLines.push(l); + } + } + + let quoteParagraphState; + if (this.blockName === "setextheading.content") { + quoteParagraphState = { + name: "setext-heading", + meta: (this.parent as SetextHeading).meta, + text: quoteLines.join("\n"), + }; + } else if (this.blockName === "atxheading.content") { + quoteParagraphState = { + name: "atx-heading", + meta: (this.parent as AtxHeading).meta, + text: quoteLines.join(" "), + }; + } else { + quoteParagraphState = { + name: "paragraph", + text: quoteLines.join("\n"), + }; + } + + const newNodeState = { + name: "block-quote", + children: [quoteParagraphState], + }; + + const quoteBlock = ScrollPage.loadBlock(newNodeState.name).create( + muya, + newNodeState + ); + + this.parent!.replaceWith(quoteBlock); + + if (preParagraphLines.length) { + const preParagraphState = { + name: "paragraph", + text: preParagraphLines.join("\n"), + }; + const preParagraphBlock = ScrollPage.loadBlock( + preParagraphState.name + ).create(muya, preParagraphState); + quoteBlock.parent.insertBefore(preParagraphBlock, quoteBlock); + } + + if (hasSelection) { + // TODO: USE `firstContentInDescendant` + const cursorBlock = quoteBlock.children.head.children.head; + cursorBlock.setCursor( + Math.max(0, start.offset - delta), + Math.max(0, end.offset - delta), + true + ); + } + } + + // Indented Code Block + private _convertToIndentedCodeBlock() { + const { text, muya, hasSelection } = this; + const lines = text.split("\n"); + const codeLines = []; + const paragraphLines = []; + let canBeCodeLine = true; + + for (const l of lines) { + if (/^ {4,}/.test(l) && canBeCodeLine) { + codeLines.push(l.replace(/^ {4}/, "")); + } else { + canBeCodeLine = false; + paragraphLines.push(l); + } + } + + const codeState = { + name: "code-block", + meta: { + lang: "", + type: "indented", + }, + text: codeLines.join("\n"), + }; + + const codeBlock = ScrollPage.loadBlock(codeState.name).create( + muya, + codeState + ); + this.parent!.replaceWith(codeBlock); + + if (paragraphLines.length > 0) { + const paragraphState = { + name: "paragraph", + text: paragraphLines.join("\n"), + }; + const paragraphBlock = ScrollPage.loadBlock(paragraphState.name).create( + muya, + paragraphState + ); + codeBlock.parent.insertAfter(paragraphBlock, codeBlock); + } + + if (hasSelection) { + const cursorBlock = codeBlock.lastContentInDescendant(); + cursorBlock.setCursor(0, 0); + } + } + + // Paragraph + convertToParagraph(force = false) { + if ( + !force && + (this.parent!.blockName === "setext-heading" || + this.parent!.blockName === "paragraph") + ) { + return; + } + + const { text, muya, hasSelection } = this; + const { start, end } = this.getCursor()!; + + const newNodeState = { + name: "paragraph", + text, + }; + + const paragraphBlock = ScrollPage.loadBlock(newNodeState.name).create( + muya, + newNodeState + ); + + this.parent!.replaceWith(paragraphBlock); + + if (hasSelection) { + const cursorBlock = paragraphBlock.children.head; + cursorBlock.setCursor(start.offset, end.offset, true); + } + } + + backspaceHandler(event: Event): void { + const { start, end } = this.getCursor() ?? {}; + // Let input handler to handle this case. + if (!start || !end || start?.offset !== end?.offset) { + return; + } + + // fix: #897 in marktext repo + const { text } = this; + const { footnote, superSubScript } = this.muya.options; + const tokens = tokenizer(text, { + options: { footnote, superSubScript }, + }); + let needRender = false; + let preToken = null; + let needSelectImage = false; + + for (const token of tokens) { + // handle delete the second marker(et:*、$) in inline syntax.(Firefox compatible) + // Fix: https://github.com/marktext/muya/issues/113 + // for example: foo **strong**| + if (token.range.end === start.offset) { + needRender = true; + token.raw = token.raw.substring(0, token.raw.length - 1); + break; + } + + // If preToken is a syntax token, the the cursor is at offset 1, need to set the cursor manually.(Firefox compatible) + // // Fix: https://github.com/marktext/muya/issues/113 + // for example: foo **strong**w| + if (token.range.start + 1 === start.offset) { + needRender = true; + token.raw = token.raw.substring(1); + break; + } + + // handle pre token is a image, need preventdefault. + if ( + token.range.start + 1 === start.offset && + preToken && + preToken.type === "image" + ) { + needSelectImage = true; + needRender = true; + token.raw = token.raw.substring(1); + break; + } + + preToken = token; + } + + if (needRender) { + event.preventDefault(); + this.text = generator(tokens); + + start.offset--; + end.offset--; + this.setCursor(start.offset, end.offset, true); + } + + if (needSelectImage) { + event.stopPropagation(); + const images: NodeListOf = + this.domNode!.querySelectorAll(`.${CLASS_NAMES.MU_INLINE_IMAGE}`); + const imageWrapper = images[images.length - 1]; + const imageInfo = getImageInfo(imageWrapper); + + this.muya.editor.selection.selectedImage = Object.assign({}, imageInfo, { + block: this, + }); + this.muya.editor.activeContentBlock = null; + this.muya.editor.selection.setSelection({ + anchor: null, + focus: null, + block: this, + path: this.path, + }); + } + } + + deleteHandler(event: KeyboardEvent): void { + const { start, end } = this.getCursor()!; + const { text } = this; + // Let input handler to handle this case. + if (start.offset !== end.offset || start.offset !== text.length) { + return; + } + const nextBlock = this.nextContentInContext(); + if (!nextBlock || nextBlock.blockName !== "paragraph.content") { + // If the next block is code content or table cell, nothing need to do. + event.preventDefault(); + return; + } + + const paragraphBlock = nextBlock.parent; + let needRemovedBlock = paragraphBlock; + + while ( + needRemovedBlock && + needRemovedBlock.isOnlyChild() && + !needRemovedBlock.isScrollPage + ) { + needRemovedBlock = needRemovedBlock.parent; + } + + this.text = text + nextBlock.text; + this.setCursor(start.offset, end.offset, true); + needRemovedBlock!.remove(); + } + + shiftEnterHandler(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + + const { text: oldText } = this; + const { start, end } = this.getCursor()!; + this.text = + oldText.substring(0, start.offset) + "\n" + oldText.substring(end.offset); + this.setCursor(start.offset + 1, end.offset + 1, true); + } + + enterHandler(event: KeyboardEvent): void { + event.preventDefault(); + const { text: oldText, muya, parent } = this; + const { start, end } = this.getCursor()!; + this.text = oldText.substring(0, start.offset); + const textOfNewNode = oldText.substring(end.offset); + const newParagraphState = { + name: "paragraph", + text: textOfNewNode, + }; + + const newNode = ScrollPage.loadBlock(newParagraphState.name).create( + muya, + newParagraphState + ); + + parent!.parent!.insertAfter(newNode, parent); + + this.update(); + const cursorBlock = newNode.firstContentInDescendant(); + cursorBlock.setCursor(0, 0, true); + } + + getFormatsInRange(cursor = this.getCursor()) { + if (cursor == null) { + return { formats: [], tokens: [], neighbors: [] }; + } + + const { start, end } = cursor; + + const { text } = this; + const formats = []; + const neighbors = []; + const tokens = tokenizer(text, { + options: this.muya.options, + }); + + (function iterator(tks) { + for (const token of tks) { + if ( + checkTokenIsInlineFormat(token) && + start.offset >= token.range.start && + end.offset <= token.range.end + ) { + formats.push(token); + } + + if ( + checkTokenIsInlineFormat(token) && + ((start.offset >= token.range.start && + start.offset <= token.range.end) || + (end.offset >= token.range.start && + end.offset <= token.range.end) || + (start.offset <= token.range.start && + token.range.end <= end.offset)) + ) { + neighbors.push(token); + } + + // As StrongEmToken only used to pass TS check. + if ( + (token as StrongEmToken).children && + (token as StrongEmToken).children.length + ) { + iterator((token as StrongEmToken).children); + } + } + })(tokens); + + return { formats, tokens, neighbors }; + } + + format(type: string) { + const cursor = this.getCursor(); + if (cursor == null) { + return; + } + + const start = cursor.start as IOffsetWithDelta; + const end = cursor.end as IOffsetWithDelta; + + if (start == null || end == null) { + return debug.warn("You need to special the range you want to format."); + } + + start.delta = end.delta = 0; + const { formats, tokens, neighbors } = this.getFormatsInRange(cursor); + + const [currentFormats, currentNeighbors] = [formats, neighbors].map( + (item) => + item + .filter((format) => { + return ( + format.type === type || + (format.type === "html_tag" && format.tag === type) + ); + }) + .reverse() + ); + + // cache delta + if (type === "clear") { + for (const neighbor of neighbors) { + clearFormat(neighbor, { start, end }); + } + + start.offset += start.delta; + end.offset += end.delta; + + this.text = generator(tokens); + } else if (currentFormats.length) { + for (const token of currentFormats) { + clearFormat(token, { start, end }); + } + + start.offset += start.delta; + end.offset += end.delta; + this.text = generator(tokens); + } else { + if (currentNeighbors.length) { + for (const neighbor of currentNeighbors) { + clearFormat(neighbor, { start, end }); + } + } + + start.offset += start.delta; + end.offset += end.delta; + this.text = generator(tokens); + + this._addFormat(type, { start, end }); + + if (type === "image") { + // Show image selector when create a inline image by menu/shortcut/or just input `![]()` + requestAnimationFrame(() => { + const startNode = Selection.getSelectionStart(); + + if (startNode) { + const imageWrapper: Nullable = ( + startNode as HTMLElement + ).closest(".mu-inline-image"); + + if ( + imageWrapper && + imageWrapper.classList.contains("mu-empty-image") + ) { + const imageInfo = getImageInfo(imageWrapper); + const rect = imageWrapper.getBoundingClientRect(); + + const reference = { + getBoundingClientRect: () => rect, + width: imageWrapper.offsetWidth, + height: imageWrapper.offsetHeight, + }; + + this.muya.eventCenter.emit("muya-image-selector", { + block: this, + reference, + imageInfo, + }); + } + } + }); + } + } + + this.setCursor(start.offset, end.offset, true); + } + + private _addFormat(type: string, { start, end }: { start: IOffset; end: IOffset }) { + switch (type) { + case "em": + + case "del": + + case "inline_code": + + case "strong": + + case "inline_math": { + const MARKER = FORMAT_MARKER_MAP[type]; + const oldText = this.text; + this.text = + oldText.substring(0, start.offset) + + MARKER + + oldText.substring(start.offset, end.offset) + + MARKER + + oldText.substring(end.offset); + start.offset += MARKER.length; + end.offset += MARKER.length; + break; + } + + case "sub": + + case "sup": + + case "mark": + + case "u": { + const MARKER = FORMAT_TAG_MAP[type]; + const oldText = this.text; + this.text = + oldText.substring(0, start.offset) + + MARKER.open + + oldText.substring(start.offset, end.offset) + + MARKER.close + + oldText.substring(end.offset); + start.offset += MARKER.open.length; + end.offset += MARKER.open.length; + break; + } + + case "link": + + case "image": { + const oldText = this.text; + const anchorTextLen = end.offset - start.offset; + this.text = + oldText.substring(0, start.offset) + + (type === "link" ? "[" : "![") + + oldText.substring(start.offset, end.offset) + + "]()" + + oldText.substring(end.offset); + // put cursor between `()` + start.offset += type === "link" ? 3 + anchorTextLen : 4 + anchorTextLen; + end.offset = start.offset; + break; + } + } + } + + // Click the rendering of inline syntax, such as Inline Math, and select the math formula. + private _handleClickInlineRuleRender( + event: Event, + inlineRuleRenderEle: Element + ) { + event.preventDefault(); + event.stopPropagation(); + + const startOffset = +inlineRuleRenderEle.getAttribute("data-start")!; + const endOffset = +inlineRuleRenderEle.getAttribute("data-end")!; + + return this.setCursor(startOffset, endOffset, true); + } +} + +export default Format; diff --git a/lib/block/base/format/backspace.ts b/lib/block/base/format/backspace.ts deleted file mode 100644 index 824df1c3..00000000 --- a/lib/block/base/format/backspace.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { CLASS_NAMES } from "@muya/config"; -import { generator, tokenizer } from "@muya/inlineRenderer/lexer"; -import { getImageInfo } from "@muya/utils/image"; -import type Format from "./index"; - -export default { - backspaceHandler(this: Format, event: Event): void { - const { start, end } = this.getCursor() ?? {}; - // Let input handler to handle this case. - if (!start || !end || start?.offset !== end?.offset) { - return; - } - - // fix: #897 in marktext repo - const { text } = this; - const { footnote, superSubScript } = this.muya.options; - const tokens = tokenizer(text, { - options: { footnote, superSubScript }, - }); - let needRender = false; - let preToken = null; - let needSelectImage = false; - - for (const token of tokens) { - // handle delete the second marker(et:*、$) in inline syntax.(Firefox compatible) - // Fix: https://github.com/marktext/muya/issues/113 - // for example: foo **strong**| - if (token.range.end === start.offset) { - needRender = true; - token.raw = token.raw.substring(0, token.raw.length - 1); - break; - } - - // If preToken is a syntax token, the the cursor is at offset 1, need to set the cursor manually.(Firefox compatible) - // // Fix: https://github.com/marktext/muya/issues/113 - // for example: foo **strong**w| - if ( - token.range.start + 1 === start.offset - ) { - needRender = true; - token.raw = token.raw.substring(1); - break; - } - - // handle pre token is a image, need preventdefault. - if ( - token.range.start + 1 === start.offset && - preToken && - preToken.type === "image" - ) { - needSelectImage = true; - needRender = true; - token.raw = token.raw.substring(1); - break; - } - - preToken = token; - } - - if (needRender) { - event.preventDefault(); - this.text = generator(tokens); - - start.offset--; - end.offset--; - this.setCursor(start.offset, end.offset, true); - } - - if (needSelectImage) { - event.stopPropagation(); - const images: NodeListOf = this.domNode!.querySelectorAll( - `.${CLASS_NAMES.MU_INLINE_IMAGE}` - ); - const imageWrapper = images[images.length - 1]; - const imageInfo = getImageInfo(imageWrapper); - - this.muya.editor.selection.selectedImage = Object.assign({}, imageInfo, { - block: this, - }); - this.muya.editor.activeContentBlock = null; - this.muya.editor.selection.setSelection({ - anchor: null, - focus: null, - block: this, - path: this.path, - }); - } - }, -}; diff --git a/lib/block/base/format/clickHandler.ts b/lib/block/base/format/clickHandler.ts deleted file mode 100644 index ff5dfbc9..00000000 --- a/lib/block/base/format/clickHandler.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { CLASS_NAMES } from "@muya/config"; -import { getCursorReference, isMouseEvent } from "@muya/utils"; -import type Format from "./index"; - -export default { - // Click the rendering of inline syntax, such as Inline Math, and select the math formula. - handleClickInlineRuleRender(this: Format, event: Event, inlineRuleRenderEle: Element) { - event.preventDefault(); - event.stopPropagation(); - - const startOffset = +inlineRuleRenderEle.getAttribute("data-start")!; - const endOffset = +inlineRuleRenderEle.getAttribute("data-end")!; - - return this.setCursor(startOffset, endOffset, true); - }, - - clickHandler(this: Format, event: Event): void { - if (!isMouseEvent(event)) { - return; - } - // Handler click inline math and inline ruby html. - const { target } = event; - const inlineRuleRenderEle = - (target as HTMLElement).closest(`.${CLASS_NAMES.MU_MATH_RENDER}`) || - (target as HTMLElement).closest(`.${CLASS_NAMES.MU_RUBY_RENDER}`); - - if (inlineRuleRenderEle) { - return this.handleClickInlineRuleRender(event, inlineRuleRenderEle); - } - - requestAnimationFrame(() => { - // TODO: @JOCS, remove use this.selection directly. - if (event.shiftKey && this.selection.anchorBlock !== this) { - // TODO: handle select multiple paragraphs - return; - } - - const currentCursor = this.getCursor(); - - if (!currentCursor) { - return; - } - - const cursor = Object.assign({}, currentCursor, { - block: this, - path: this.path, - }); - - // TODO: The codes bellow maybe is wrong? and remove use this.selection directly - const needRender = - this.selection.anchorBlock === this - ? this.checkNeedRender(cursor) || this.checkNeedRender() - : this.checkNeedRender(cursor); - - if (needRender) { - this.update(cursor); - } - - this.selection.setSelection(cursor); - - // Check and show format picker - if (cursor.start.offset !== cursor.end.offset) { - const reference = getCursorReference(); - - this.muya.eventCenter.emit("muya-format-picker", { - reference, - block: this, - }); - } - }); - }, -}; diff --git a/lib/block/base/format/converter.ts b/lib/block/base/format/converter.ts deleted file mode 100644 index da7fe102..00000000 --- a/lib/block/base/format/converter.ts +++ /dev/null @@ -1,598 +0,0 @@ -import ScrollPage from "@muya/block"; -import { PARAGRAPH_STATE, THEMATIC_BREAK_STATE } from "@muya/config"; -import { IBulletListState, IOrderListState } from "../../../state/types"; -import type Format from "./index"; - -const INLINE_UPDATE_FRAGMENTS = [ - "(?:^|\n) {0,3}([*+-] {1,4})", // Bullet list - "^(\\[[x ]{1}\\] {1,4})", // Task list **match from beginning** - "(?:^|\n) {0,3}(\\d{1,9}(?:\\.|\\)) {1,4})", // Order list - "(?:^|\n) {0,3}(#{1,6})(?=\\s{1,}|$)", // ATX headings - "^(?:[\\s\\S]+?)\\n {0,3}(\\={3,}|\\-{3,})(?= {1,}|$)", // Setext headings **match from beginning** - "(?:^|\n) {0,3}(>).+", // Block quote - "^( {4,})", // Indent code **match from beginning** - // '^(\\[\\^[^\\^\\[\\]\\s]+?(? /\S/.test(i))).size === 1: - this.convertToThematicBreak(); - break; - - case !!bulletList: - this.convertToList(); - break; - - case !!orderList: - this.convertToList(); - break; - - case !!taskList: - this.convertToTaskList(); - break; - - case !!atxHeading: - this.convertToAtxHeading(atxHeading); - break; - - case !!setextHeading: - this.convertToSetextHeading(setextHeading); - break; - - case !!blockquote: - this.convertToBlockQuote(blockquote); - break; - - case !!indentedCodeBlock: - this.convertToIndentedCodeBlock(indentedCodeBlock); - break; - - case !match: - default: - this.convertToParagraph(); - break; - } - }, - - // Thematic Break - convertToThematicBreak(this: Format) { - // If the block is already thematic break, no need to update. - if (this.parent?.blockName === "thematic-break") { - return; - } - const { hasSelection } = this; - const { start, end } = this.getCursor(); - const { text, muya } = this; - const lines = text.split("\n"); - const preParagraphLines = []; - let thematicLine = ""; - const postParagraphLines = []; - let thematicLineHasPushed = false; - - for (const l of lines) { - const THEMATIC_BREAK_REG = - / {0,3}(?:\* *\* *\*|- *- *-|_ *_ *_)[ \*\-\_]*$/; - if (THEMATIC_BREAK_REG.test(l) && !thematicLineHasPushed) { - thematicLine = l; - thematicLineHasPushed = true; - } else if (!thematicLineHasPushed) { - preParagraphLines.push(l); - } else { - postParagraphLines.push(l); - } - } - - const newNodeState = Object.assign({}, THEMATIC_BREAK_STATE, { - text: thematicLine, - }); - - if (preParagraphLines.length) { - const preParagraphState = Object.assign({}, PARAGRAPH_STATE, { - text: preParagraphLines.join("\n"), - }); - const preParagraphBlock = ScrollPage.loadBlock( - preParagraphState.name - ).create(muya, preParagraphState); - this.parent.parent.insertBefore(preParagraphBlock, this.parent); - } - - if (postParagraphLines.length) { - const postParagraphState = Object.assign({}, PARAGRAPH_STATE, { - text: postParagraphLines.join("\n"), - }); - const postParagraphBlock = ScrollPage.loadBlock( - postParagraphState.name - ).create(muya, postParagraphState); - this.parent.parent.insertAfter(postParagraphBlock, this.parent); - } - - const thematicBlock = ScrollPage.loadBlock(newNodeState.name).create( - muya, - newNodeState - ); - - this.parent.replaceWith(thematicBlock); - - if (hasSelection) { - const thematicBreakContent = thematicBlock.children.head; - const preParagraphTextLength = preParagraphLines.reduce( - (acc, i) => acc + i.length + 1, - 0 - ); // Add one, because the `\n` - const startOffset = Math.max(0, start.offset - preParagraphTextLength); - const endOffset = Math.max(0, end.offset - preParagraphTextLength); - - thematicBreakContent.setCursor(startOffset, endOffset, true); - } - }, - - convertToList(this: Format) { - const { text, parent, muya, hasSelection } = this; - const { preferLooseListItem } = muya.options; - const matches = text.match( - /^([\s\S]*?) {0,3}([*+-]|\d{1,9}(?:\.|\))) {1,4}([\s\S]*)$/ - ); - const blockName = /\d/.test(matches[2]) ? "order-list" : "bullet-list"; - - if (matches[1]) { - const paragraphState = { - name: "paragraph", - text: matches[1].trim(), - }; - const paragraph = ScrollPage.loadBlock(paragraphState.name).create( - muya, - paragraphState - ); - parent.parent.insertBefore(paragraph, parent); - } - - const listState = { - name: blockName, - meta: { - loose: preferLooseListItem, - }, - children: [ - { - name: "list-item", - children: [ - { - name: "paragraph", - text: matches[3], - }, - ], - }, - ], - }; - - if (blockName === "order-list") { - (listState as IOrderListState).meta.delimiter = matches[2].slice(-1); - (listState as IOrderListState).meta.start = Number(matches[2].slice(0, -1)); - } else { - (listState as IBulletListState).meta.marker = matches[2]; - } - - const list = ScrollPage.loadBlock(listState.name).create(muya, listState); - parent.replaceWith(list); - - const firstContent = list.firstContentInDescendant(); - - if (hasSelection) { - firstContent.setCursor(0, 0, true); - } - - // convert `[*-+] \[[xX ]\] ` to task list. - const TASK_LIST_REG = /^\[[x ]\] {1,4}/i; - if (TASK_LIST_REG.test(firstContent.text)) { - firstContent.convertToTaskList(); - } - }, - - convertToTaskList(this: Format) { - const { text, parent, muya, hasSelection } = this; - const { preferLooseListItem } = muya.options; - const listItem = parent.parent; - const list = listItem?.parent; - const matches = text.match(/^\[([x ]{1})\] {1,4}([\s\S]*)$/i); - - if (!list || list.blockName !== "bullet-list" || !parent.isFirstChild()) { - return; - } - - const listState = { - name: "task-list", - meta: { - loose: preferLooseListItem, - marker: list.meta.marker, - }, - children: [ - { - name: "task-list-item", - meta: { - checked: matches[1] !== " ", - }, - children: listItem.map((node) => { - if (node === parent) { - return { - name: "paragraph", - text: matches[2], - }; - } else { - return node.getState(); - } - }), - }, - ], - }; - - const newTaskList = ScrollPage.loadBlock(listState.name).create( - muya, - listState - ); - - switch (true) { - case listItem.isOnlyChild(): - list.replaceWith(newTaskList); - break; - - case listItem.isFirstChild(): - list.parent.insertBefore(newTaskList, list); - listItem.remove(); - break; - - case listItem.isLastChild(): - list.parent.insertAfter(newTaskList, list); - listItem.remove(); - break; - - default: { - const bulletListState = { - name: "bullet-list", - meta: { - loose: preferLooseListItem, - marker: list.meta.marker, - }, - children: [], - }; - const offset = list.offset(listItem); - list.forEachAt(offset + 1, undefined, (node) => { - bulletListState.children.push(node.getState()); - node.remove(); - }); - - const bulletList = ScrollPage.loadBlock(bulletListState.name).create( - muya, - bulletListState - ); - list.parent.insertAfter(newTaskList, list); - newTaskList.parent.insertAfter(bulletList, newTaskList); - listItem.remove(); - break; - } - } - - if (hasSelection) { - newTaskList.firstContentInDescendant().setCursor(0, 0, true); - } - }, - - // ATX Heading - convertToAtxHeading(this: Format, atxHeading) { - const level = atxHeading.length; - if ( - this.parent.blockName === "atx-heading" && - this.parent.meta.level === level - ) { - return; - } - - const { hasSelection } = this; - const { start, end } = this.getCursor(); - const { text, muya } = this; - const lines = text.split("\n"); - const preParagraphLines = []; - let atxLine = ""; - const postParagraphLines = []; - let atxLineHasPushed = false; - - for (const l of lines) { - if (/^ {0,3}#{1,6}(?=\s{1,}|$)/.test(l) && !atxLineHasPushed) { - atxLine = l; - atxLineHasPushed = true; - } else if (!atxLineHasPushed) { - preParagraphLines.push(l); - } else { - postParagraphLines.push(l); - } - } - - if (preParagraphLines.length) { - const preParagraphState = { - name: "paragraph", - text: preParagraphLines.join("\n"), - }; - const preParagraphBlock = ScrollPage.loadBlock( - preParagraphState.name - ).create(muya, preParagraphState); - this.parent.parent.insertBefore(preParagraphBlock, this.parent); - } - - if (postParagraphLines.length) { - const postParagraphState = { - name: "paragraph", - text: postParagraphLines.join("\n"), - }; - const postParagraphBlock = ScrollPage.loadBlock( - postParagraphState.name - ).create(muya, postParagraphState); - this.parent.parent.insertAfter(postParagraphBlock, this.parent); - } - - const newNodeState = { - name: "atx-heading", - meta: { - level, - }, - text: atxLine, - }; - - const atxHeadingBlock = ScrollPage.loadBlock(newNodeState.name).create( - muya, - newNodeState - ); - - this.parent.replaceWith(atxHeadingBlock); - - if (hasSelection) { - const atxHeadingContent = atxHeadingBlock.children.head; - const preParagraphTextLength = preParagraphLines.reduce( - (acc, i) => acc + i.length + 1, - 0 - ); // Add one, because the `\n` - const startOffset = Math.max(0, start.offset - preParagraphTextLength); - const endOffset = Math.max(0, end.offset - preParagraphTextLength); - atxHeadingContent.setCursor(startOffset, endOffset, true); - } - }, - - // Setext Heading - convertToSetextHeading(this: Format, setextHeading) { - const level = /=/.test(setextHeading) ? 2 : 1; - if ( - this.parent.blockName === "setext-heading" && - this.parent.meta.level === level - ) { - return; - } - - const { hasSelection } = this; - const { text, muya } = this; - const lines = text.split("\n"); - const setextLines = []; - const postParagraphLines = []; - let setextLineHasPushed = false; - - for (const l of lines) { - if (/^ {0,3}(?:={3,}|-{3,})(?= {1,}|$)/.test(l) && !setextLineHasPushed) { - setextLineHasPushed = true; - } else if (!setextLineHasPushed) { - setextLines.push(l); - } else { - postParagraphLines.push(l); - } - } - - const newNodeState = { - name: "setext-heading", - meta: { - level, - underline: setextHeading, - }, - text: setextLines.join("\n"), - }; - - const setextHeadingBlock = ScrollPage.loadBlock(newNodeState.name).create( - muya, - newNodeState - ); - - this.parent.replaceWith(setextHeadingBlock); - - if (postParagraphLines.length) { - const postParagraphState = { - name: "paragraph", - text: postParagraphLines.join("\n"), - }; - const postParagraphBlock = ScrollPage.loadBlock( - postParagraphState.name - ).create(muya, postParagraphState); - setextHeadingBlock.parent.insertAfter( - postParagraphBlock, - setextHeadingBlock - ); - } - - if (hasSelection) { - const cursorBlock = setextHeadingBlock.children.head; - const offset = cursorBlock.text.length; - cursorBlock.setCursor(offset, offset, true); - } - }, - - // Block Quote - convertToBlockQuote(this: Format) { - const { text, muya, hasSelection } = this; - const { start, end } = this.getCursor(); - const lines = text.split("\n"); - const preParagraphLines = []; - const quoteLines = []; - let quoteLinesHasPushed = false; - let delta = 0; - - for (const l of lines) { - if (/^ {0,3}>/.test(l) && !quoteLinesHasPushed) { - quoteLinesHasPushed = true; - const tokens = /( *> *)(.*)/.exec(l); - delta = tokens[1].length; - quoteLines.push(tokens[2]); - } else if (!quoteLinesHasPushed) { - preParagraphLines.push(l); - } else { - quoteLines.push(l); - } - } - - let quoteParagraphState; - if (this.blockName === "setextheading.content") { - quoteParagraphState = { - name: "setext-heading", - meta: this.parent.meta, - text: quoteLines.join("\n"), - }; - } else if (this.blockName === "atxheading.content") { - quoteParagraphState = { - name: "atx-heading", - meta: this.parent.meta, - text: quoteLines.join(" "), - }; - } else { - quoteParagraphState = { - name: "paragraph", - text: quoteLines.join("\n"), - }; - } - - const newNodeState = { - name: "block-quote", - children: [quoteParagraphState], - }; - - const quoteBlock = ScrollPage.loadBlock(newNodeState.name).create( - muya, - newNodeState - ); - - this.parent.replaceWith(quoteBlock); - - if (preParagraphLines.length) { - const preParagraphState = { - name: "paragraph", - text: preParagraphLines.join("\n"), - }; - const preParagraphBlock = ScrollPage.loadBlock( - preParagraphState.name - ).create(muya, preParagraphState); - quoteBlock.parent.insertBefore(preParagraphBlock, quoteBlock); - } - - if (hasSelection) { - // TODO: USE `firstContentInDecendent` - const cursorBlock = quoteBlock.children.head.children.head; - cursorBlock.setCursor( - Math.max(0, start.offset - delta), - Math.max(0, end.offset - delta), - true - ); - } - }, - - // Indented Code Block - convertToIndentedCodeBlock(this: Format) { - const { text, muya, hasSelection } = this; - const lines = text.split("\n"); - const codeLines = []; - const paragraphLines = []; - let canBeCodeLine = true; - - for (const l of lines) { - if (/^ {4,}/.test(l) && canBeCodeLine) { - codeLines.push(l.replace(/^ {4}/, "")); - } else { - canBeCodeLine = false; - paragraphLines.push(l); - } - } - - const codeState = { - name: "code-block", - meta: { - lang: "", - type: "indented", - }, - text: codeLines.join("\n"), - }; - - const codeBlock = ScrollPage.loadBlock(codeState.name).create( - muya, - codeState - ); - this.parent.replaceWith(codeBlock); - - if (paragraphLines.length > 0) { - const paragraphState = { - name: "paragraph", - text: paragraphLines.join("\n"), - }; - const paragraphBlock = ScrollPage.loadBlock(paragraphState.name).create( - muya, - paragraphState - ); - codeBlock.parent.insertAfter(paragraphBlock, codeBlock); - } - - if (hasSelection) { - const cursorBlock = codeBlock.lastContentInDescendant(); - cursorBlock.setCursor(0, 0); - } - }, - - // Paragraph - convertToParagraph(this: Format, force = false) { - if ( - !force && - (this.parent.blockName === "setext-heading" || - this.parent.blockName === "paragraph") - ) { - return; - } - - const { text, muya, hasSelection } = this; - const { start, end } = this.getCursor(); - - const newNodeState = { - name: "paragraph", - text, - }; - - const paragraphBlock = ScrollPage.loadBlock(newNodeState.name).create( - muya, - newNodeState - ); - - this.parent.replaceWith(paragraphBlock); - - if (hasSelection) { - const cursorBlock = paragraphBlock.children.head; - cursorBlock.setCursor(start.offset, end.offset, true); - } - }, -}; diff --git a/lib/block/base/format/delete.ts b/lib/block/base/format/delete.ts deleted file mode 100644 index 1387203f..00000000 --- a/lib/block/base/format/delete.ts +++ /dev/null @@ -1,33 +0,0 @@ -import Format from "./index"; - -export default { - deleteHandler(this: Format, event: KeyboardEvent): void { - const { start, end } = this.getCursor()!; - const { text } = this; - // Let input handler to handle this case. - if (start.offset !== end.offset || start.offset !== text.length) { - return; - } - const nextBlock = this.nextContentInContext(); - if (!nextBlock || nextBlock.blockName !== "paragraph.content") { - // If the next block is code content or table cell, nothing need to do. - event.preventDefault(); - return; - } - - const paragraphBlock = nextBlock.parent; - let needRemovedBlock = paragraphBlock; - - while ( - needRemovedBlock && - needRemovedBlock.isOnlyChild() && - !needRemovedBlock.isScrollPage - ) { - needRemovedBlock = needRemovedBlock.parent; - } - - this.text = text + nextBlock.text; - this.setCursor(start.offset, end.offset, true); - needRemovedBlock!.remove(); - }, -}; diff --git a/lib/block/base/format/enterHandler.ts b/lib/block/base/format/enterHandler.ts deleted file mode 100644 index cc466d13..00000000 --- a/lib/block/base/format/enterHandler.ts +++ /dev/null @@ -1,38 +0,0 @@ -import ScrollPage from "@muya/block"; -import Format from "./index"; - -export default { - shiftEnterHandler(this: Format, event: Event): void { - event.preventDefault(); - event.stopPropagation(); - - const { text: oldText } = this; - const { start, end } = this.getCursor()!; - this.text = - oldText.substring(0, start.offset) + "\n" + oldText.substring(end.offset); - this.setCursor(start.offset + 1, end.offset + 1, true); - }, - - enterHandler(this: Format, event: KeyboardEvent): void { - event.preventDefault(); - const { text: oldText, muya, parent } = this; - const { start, end } = this.getCursor()!; - this.text = oldText.substring(0, start.offset); - const textOfNewNode = oldText.substring(end.offset); - const newParagraphState = { - name: "paragraph", - text: textOfNewNode, - }; - - const newNode = ScrollPage.loadBlock(newParagraphState.name).create( - muya, - newParagraphState - ); - - parent!.parent!.insertAfter(newNode, parent); - - this.update(); - const cursorBlock = newNode.firstContentInDescendant(); - cursorBlock.setCursor(0, 0, true); - }, -}; diff --git a/lib/block/base/format/format.ts b/lib/block/base/format/format.ts deleted file mode 100644 index f511edf6..00000000 --- a/lib/block/base/format/format.ts +++ /dev/null @@ -1,317 +0,0 @@ -import { FORMAT_MARKER_MAP, FORMAT_TYPES } from "@muya/config"; -import { generator, tokenizer } from "@muya/inlineRenderer/lexer"; -import Selection from "@muya/selection"; -import { getImageInfo } from "@muya/utils/image"; -import logger from "@muya/utils/logger"; -import Format from "./index"; - -const debug = logger("block.format:"); - -const getOffset = ( - offset, - { range: { start, end }, type, tag, anchor, alt } -) => { - const dis = offset - start; - const len = end - start; - switch (type) { - case "strong": - - case "del": - - case "em": - - case "inline_code": - - case "inline_math": { - const MARKER_LEN = type === "strong" || type === "del" ? 2 : 1; - if (dis < 0) return 0; - if (dis >= 0 && dis < MARKER_LEN) return -dis; - if (dis >= MARKER_LEN && dis <= len - MARKER_LEN) return -MARKER_LEN; - if (dis > len - MARKER_LEN && dis <= len) - return len - dis - 2 * MARKER_LEN; - if (dis > len) return -2 * MARKER_LEN; - break; - } - - case "html_tag": { - // handle underline, sup, sub - const OPEN_MARKER_LEN = FORMAT_MARKER_MAP[tag].open.length; - const CLOSE_MARKER_LEN = FORMAT_MARKER_MAP[tag].close.length; - if (dis < 0) return 0; - if (dis >= 0 && dis < OPEN_MARKER_LEN) return -dis; - if (dis >= OPEN_MARKER_LEN && dis <= len - CLOSE_MARKER_LEN) - return -OPEN_MARKER_LEN; - if (dis > len - CLOSE_MARKER_LEN && dis <= len) - return len - dis - OPEN_MARKER_LEN - CLOSE_MARKER_LEN; - if (dis > len) return -OPEN_MARKER_LEN - CLOSE_MARKER_LEN; - break; - } - - case "link": { - const MARKER_LEN = 1; - if (dis < MARKER_LEN) return 0; - if (dis >= MARKER_LEN && dis <= MARKER_LEN + anchor.length) return -1; - if (dis > MARKER_LEN + anchor.length) return anchor.length - dis; - break; - } - - case "image": { - const MARKER_LEN = 1; - if (dis < MARKER_LEN) return 0; - if (dis >= MARKER_LEN && dis < MARKER_LEN * 2) return -1; - if (dis >= MARKER_LEN * 2 && dis <= MARKER_LEN * 2 + alt.length) - return -2; - if (dis > MARKER_LEN * 2 + alt.length) return alt.length - dis; - break; - } - } -}; - -const clearFormat = (token, { start, end }) => { - if (start) { - const deltaStart = getOffset(start.offset, token); - start.delata += deltaStart; - } - - if (end) { - const delataEnd = getOffset(end.offset, token); - end.delata += delataEnd; - } - - switch (token.type) { - case "strong": - - case "del": - - case "em": - - case "link": - - case "html_tag": { - // underline, sub, sup - const { parent } = token; - const index = parent.indexOf(token); - parent.splice(index, 1, ...token.children); - break; - } - - case "image": { - token.type = "text"; - token.raw = token.alt; - delete token.marker; - delete token.src; - break; - } - - case "inline_math": - - case "inline_code": { - token.type = "text"; - token.raw = token.content; - delete token.marker; - break; - } - } -}; - -const addFormat = (type, block, { start, end }) => { - switch (type) { - case "em": - - case "del": - - case "inline_code": - - case "strong": - - case "inline_math": { - const MARKER = FORMAT_MARKER_MAP[type]; - const oldText = block.text; - block.text = - oldText.substring(0, start.offset) + - MARKER + - oldText.substring(start.offset, end.offset) + - MARKER + - oldText.substring(end.offset); - start.offset += MARKER.length; - end.offset += MARKER.length; - break; - } - - case "sub": - - case "sup": - - case "mark": - - case "u": { - const MARKER = FORMAT_MARKER_MAP[type]; - const oldText = block.text; - block.text = - oldText.substring(0, start.offset) + - MARKER.open + - oldText.substring(start.offset, end.offset) + - MARKER.close + - oldText.substring(end.offset); - start.offset += MARKER.open.length; - end.offset += MARKER.open.length; - break; - } - - case "link": - - case "image": { - const oldText = block.text; - const anchorTextLen = end.offset - start.offset; - block.text = - oldText.substring(0, start.offset) + - (type === "link" ? "[" : "![") + - oldText.substring(start.offset, end.offset) + - "]()" + - oldText.substring(end.offset); - // put cursor between `()` - start.offset += type === "link" ? 3 + anchorTextLen : 4 + anchorTextLen; - end.offset = start.offset; - break; - } - } -}; - -const checkTokenIsInlineFormat = (token) => { - const { type, tag } = token; - - if (FORMAT_TYPES.includes(type)) { - return true; - } - - if (type === "html_tag" && /^(?:u|sub|sup|mark)$/i.test(tag)) { - return true; - } - - return false; -}; - -export default { - getFormatsInRange(this: Format, { start, end } = this.getCursor()) { - if (!start || !end) { - return { formats: [], tokens: [], neighbors: [] }; - } - - const { text } = this; - const formats = []; - const neighbors = []; - const tokens = tokenizer(text, { - options: this.muya.options, - }); - - (function iterator(tks) { - for (const token of tks) { - if ( - checkTokenIsInlineFormat(token) && - start.offset >= token.range.start && - end.offset <= token.range.end - ) { - formats.push(token); - } - - if ( - checkTokenIsInlineFormat(token) && - ((start.offset >= token.range.start && - start.offset <= token.range.end) || - (end.offset >= token.range.start && - end.offset <= token.range.end) || - (start.offset <= token.range.start && - token.range.end <= end.offset)) - ) { - neighbors.push(token); - } - - if (token.children && token.children.length) { - iterator(token.children); - } - } - })(tokens); - - return { formats, tokens, neighbors }; - }, - - format(this: Format, type, { start, end } = this.getCursor()) { - if (!start || !end) { - return debug.warn("You need to special the range you want to format."); - } - - start.delata = end.delata = 0; - const { formats, tokens, neighbors } = this.getFormatsInRange({ - start, - end, - }); - const [currentFormats, currentNeightbors] = [formats, neighbors].map( - (item) => - item - .filter((format) => { - return ( - format.type === type || - (format.type === "html_tag" && format.tag === type) - ); - }) - .reverse() - ); - - // cache delata - if (type === "clear") { - for (const neighbor of neighbors) { - clearFormat(neighbor, { start, end }); - } - start.offset += start.delata; - end.offset += end.delata; - this.text = generator(tokens); - } else if (currentFormats.length) { - for (const token of currentFormats) { - clearFormat(token, { start, end }); - } - start.offset += start.delata; - end.offset += end.delata; - this.text = generator(tokens); - } else { - if (currentNeightbors.length) { - for (const neighbor of currentNeightbors) { - clearFormat(neighbor, { start, end }); - } - } - start.offset += start.delata; - end.offset += end.delata; - this.text = generator(tokens); - addFormat(type, this, { start, end }); - if (type === "image") { - // Show image selector when create a inline image by menu/shortcut/or just input `![]()` - requestAnimationFrame(() => { - const startNode = Selection.getSelectionStart(); - - if (startNode) { - const imageWrapper: HTMLElement = (startNode as HTMLElement).closest(".mu-inline-image"); - if ( - imageWrapper && - imageWrapper.classList.contains("mu-empty-image") - ) { - const imageInfo = getImageInfo(imageWrapper); - const rect = imageWrapper.getBoundingClientRect(); - - const reference = { - getBoundingClientRect: () => rect, - width: imageWrapper.offsetWidth, - height: imageWrapper.offsetHeight, - }; - - this.muya.eventCenter.emit("muya-image-selector", { - block: this, - reference, - imageInfo, - }); - } - } - }); - } - } - this.setCursor(start.offset, end.offset, true); - }, -}; diff --git a/lib/block/base/format/index.ts b/lib/block/base/format/index.ts deleted file mode 100644 index e4034a83..00000000 --- a/lib/block/base/format/index.ts +++ /dev/null @@ -1,280 +0,0 @@ -import Content from "@muya/block/base/content"; -import { tokenizer } from "@muya/inlineRenderer/lexer"; -import type { Token } from "@muya/inlineRenderer/types"; -import { ImageToken } from "@muya/inlineRenderer/types"; -import { Cursor } from "@muya/selection/types"; -import { conflict, methodMixins } from "@muya/utils"; -import { correctImageSrc } from "@muya/utils/image"; -import backspaceHandler from "./backspace"; -import clickHandler from "./clickHandler"; -import converter from "./converter"; -import deleteHandler from "./delete"; -import enterHandler from "./enterHandler"; -import formatMethods from "./format"; -import inputHandler from "./inputHandler"; -import keyupHandler from "./keyupHandler"; - -type FormatMethods = typeof formatMethods; -type ClickHandler = typeof clickHandler; -type EnterHandler = typeof enterHandler; -type InputHandler = typeof inputHandler; -type KeyupHandler = typeof keyupHandler; -type BackSpaceHandler = typeof backspaceHandler; -type DeleteHandler = typeof deleteHandler; -type Converter = typeof converter; - -interface Format - extends FormatMethods, - ClickHandler, - EnterHandler, - InputHandler, - KeyupHandler, - BackSpaceHandler, - DeleteHandler, - Converter {} - -@methodMixins( - formatMethods, - clickHandler, - enterHandler, - inputHandler, - keyupHandler, - backspaceHandler, - deleteHandler, - converter -) -class Format extends Content { - static blockName = "format"; - - checkCursorInTokenType(text: string, offset: number, type: Token["type"]): Token | null { - const tokens = tokenizer(text, { - hasBeginRules: false, - options: this.muya.options, - }); - - let result = null; - - const travel = (tokens: Token[]) => { - for (const token of tokens) { - if (token.range.start > offset) { - break; - } - - if ( - token.type === type && - offset > token.range.start && - offset < token.range.end - ) { - result = token; - break; - } else if (token.children) { - travel(token.children); - } - } - }; - - travel(tokens); - - return result; - } - - checkNotSameToken(oldText: string, text: string) { - const { options } = this.muya; - const oldTokens = tokenizer(oldText, { - options, - }); - const tokens = tokenizer(text, { - options, - }); - - const oldCache: Record = {}; - const cache: Record = {}; - - for (const { type } of oldTokens) { - if (oldCache[type]) { - oldCache[type]++; - } else { - oldCache[type] = 1; - } - } - - for (const { type } of tokens) { - if (cache[type]) { - cache[type]++; - } else { - cache[type] = 1; - } - } - - if (Object.keys(oldCache).length !== Object.keys(cache).length) { - return true; - } - - for (const key of Object.keys(oldCache)) { - if (!cache[key] || oldCache[key] !== cache[key]) { - return true; - } - } - - return false; - } - - // TODO: @JOCS remove use this.selection directly - checkNeedRender(cursor: Cursor = this.selection) { - const { labels } = this.inlineRenderer; - const { text } = this; - const { start: cStart, end: cEnd, anchor, focus } = cursor; - const anchorOffset = cStart ? cStart.offset : anchor.offset; - const focusOffset = cEnd ? cEnd.offset : focus.offset; - const NO_NEED_TOKEN_REG = /text|hard_line_break|soft_line_break/; - - for (const token of tokenizer(text, { - labels, - options: this.muya.options, - })) { - if (NO_NEED_TOKEN_REG.test(token.type)) continue; - const { start, end } = token.range; - const textLen = text.length; - if ( - conflict( - [Math.max(0, start - 1), Math.min(textLen, end + 1)], - [anchorOffset, anchorOffset] - ) || - conflict( - [Math.max(0, start - 1), Math.min(textLen, end + 1)], - [focusOffset, focusOffset] - ) - ) { - return true; - } - } - - return false; - } - - blurHandler() { - super.blurHandler(); - const needRender = this.checkNeedRender(); - if (needRender) { - this.update(); - } - } - - /** - * Update emoji text if cursor is in emoji syntax. - * @param {string} text emoji text - */ - setEmoji(text) { - // TODO: @JOCS remove use this.selection directly - const { anchor } = this.selection; - const editEmoji = this.checkCursorInTokenType( - this.text, - anchor.offset, - "emoji" - ); - if (editEmoji) { - const { start, end } = editEmoji.range; - const oldText = this.text; - this.text = - oldText.substring(0, start) + `:${text}:` + oldText.substring(end); - const offset = start + text.length + 2; - this.setCursor(offset, offset, true); - } - } - - replaceImage({ token }, { alt = "", src = "", title = "" }) { - const { type } = token; - const { start, end } = token.range; - const oldText = this.text; - let imageText = ""; - if (type === "image") { - imageText = "!["; - if (alt) { - imageText += alt; - } - imageText += "]("; - if (src) { - imageText += src - .replace(/ /g, encodeURI(" ")) - .replace(/#/g, encodeURIComponent("#")); - } - - if (title) { - imageText += ` "${title}"`; - } - imageText += ")"; - } else if (type === "html_tag") { - const { attrs } = token; - Object.assign(attrs, { alt, src, title }); - imageText = " -1 ? imageId : imageId + "_" + token.range.start - } img`; - const image: HTMLImageElement | null = document.querySelector(selector); - - if (image) { - image.click(); - } - } - - deleteImage({ token }) { - const oldText = this.text; - const { start, end } = token.range; - const { eventCenter } = this.muya; - - this.text = oldText.substring(0, start) + oldText.substring(end); - this.setCursor(start, start, true); - - // Hide image toolbar and image transformer - eventCenter.emit("muya-transformer", { reference: null }); - eventCenter.emit("muya-image-toolbar", { reference: null }); - } -} - -export default Format; diff --git a/lib/block/base/format/inputHandler.ts b/lib/block/base/format/inputHandler.ts deleted file mode 100644 index 4b75da95..00000000 --- a/lib/block/base/format/inputHandler.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { CLASS_NAMES } from "@muya/config"; -import { CodeEmojiMathToken, Token } from "@muya/inlineRenderer/types"; -import { getTextContent } from "@muya/selection/dom"; -import { getCursorReference } from "@muya/utils"; -import Format from "./index"; - -function isEmojiToken(token: Token): token is CodeEmojiMathToken { - return token.type === "emoji"; -} - -export default { - inputHandler(this: Format, event: Event): void { - // Do not use `isInputEvent` util, because compositionEnd event also invoke this method. - if (this.isComposed || /historyUndo|historyRedo/.test((event as InputEvent).inputType)) { - return; - } - const { domNode } = this; - const { start, end } = this.getCursor()!; - const textContent = getTextContent(domNode!, [ - CLASS_NAMES.MU_MATH_RENDER, - CLASS_NAMES.MU_RUBY_RENDER, - ]); - const isInInlineMath = !!this.checkCursorInTokenType( - textContent, - start.offset, - "inline_math" - ); - const isInInlineCode = !!this.checkCursorInTokenType( - textContent, - start.offset, - "inline_code" - ); - - // eslint-disable-next-line prefer-const - let { needRender, text } = this.autoPair( - event, - textContent, - start, - end, - isInInlineMath, - isInInlineCode, - "format" - ); - - if (this.checkNotSameToken(this.text, text)) { - needRender = true; - } - - this.text = text; - - const cursor = { - path: this.path, - block: this, - anchor: { - offset: start.offset, - }, - focus: { - offset: end.offset, - }, - }; - - const checkMarkedUpdate = this.checkNeedRender(cursor); - - if (checkMarkedUpdate || needRender) { - this.update(cursor); - } - - this.selection.setSelection(cursor); - // check edit emoji - if ( - (event as InputEvent).inputType !== "insertFromPaste" && - (event as InputEvent).inputType !== "deleteByCut" - ) { - const emojiToken = this.checkCursorInTokenType( - this.text, - start.offset, - "emoji" - ); - if (emojiToken && isEmojiToken(emojiToken)) { - const { content: emojiText } = emojiToken; - const reference = getCursorReference(); - - this.muya.eventCenter.emit("muya-emoji-picker", { - reference, - emojiText, - block: this, - }); - } - } - - // Check block convert if needed, and table cell no need to check. - if (this.blockName !== "table.cell.content") { - this.convertIfNeeded(); - } - }, -}; diff --git a/lib/block/base/format/keyupHandler.ts b/lib/block/base/format/keyupHandler.ts deleted file mode 100644 index b610ac6e..00000000 --- a/lib/block/base/format/keyupHandler.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { getCursorReference } from "@muya/utils"; -import Format from "./index"; - -export default { - keyupHandler(this: Format): void { - if (this.isComposed) { - return; - } - // TODO: @JOCS remove use this.selection directly - const { - anchor: oldAnchor, - focus: oldFocus, - isSelectionInSameBlock, - } = this.selection; - - if (!isSelectionInSameBlock) { - return; - } - - const { anchor, focus } = this.getCursor()!; - - if ( - anchor.offset !== oldAnchor?.offset || - focus.offset !== oldFocus?.offset - ) { - const needUpdate = - this.checkNeedRender({ anchor, focus }); - const cursor = { anchor, focus, block: this, path: this.path }; - - if (needUpdate) { - this.update(cursor); - } - - this.selection.setSelection(cursor); - } - - // Check not edit emoji - const editEmoji = this.checkCursorInTokenType( - this.text, - anchor.offset, - "emoji" - ); - - if (!editEmoji) { - this.muya.eventCenter.emit("muya-emoji-picker", { - emojiText: "", - }); - } - - // Check and show format picker - if (anchor.offset !== focus.offset) { - const reference = getCursorReference(); - - this.muya.eventCenter.emit("muya-format-picker", { - reference, - block: this, - }); - } - }, -}; diff --git a/lib/block/base/parent.ts b/lib/block/base/parent.ts index 1e961678..f3046a2e 100644 --- a/lib/block/base/parent.ts +++ b/lib/block/base/parent.ts @@ -4,7 +4,7 @@ import { CLASS_NAMES } from "@muya/config"; import Muya from "@muya/index"; import { operateClassName } from "@muya/utils/dom"; import logger from "@muya/utils/logger"; -import Content from "./content/index"; +import Content from "./content"; const debug = logger("parent:"); diff --git a/lib/block/base/treeNode.ts b/lib/block/base/treeNode.ts index d6ac1dda..d66abb86 100644 --- a/lib/block/base/treeNode.ts +++ b/lib/block/base/treeNode.ts @@ -210,7 +210,7 @@ class TreeNode extends LinkedNode { /** * Remove the current block in the block tree. */ - remove(this: Parent | Content) { + remove() { if (!this.parent) { return; } diff --git a/lib/block/mixins/containerQueryBlock.ts b/lib/block/mixins/containerQueryBlock.ts index 472de435..82af9318 100644 --- a/lib/block/mixins/containerQueryBlock.ts +++ b/lib/block/mixins/containerQueryBlock.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ import type { Path } from "ot-json1"; -import Parent from "../base/parent"; import Content from "../base/content"; +import Parent from "../base/parent"; interface ContainerQueryBlock { find(p: number): Parent | Content; diff --git a/lib/config/index.ts b/lib/config/index.ts index 93437742..60be4d4e 100644 --- a/lib/config/index.ts +++ b/lib/config/index.ts @@ -7,12 +7,20 @@ export const VOID_HTML_TAGS = voidHtmlTags; export const HTML_TAGS = htmlTags; export const BLOCK_DOM_PROPERTY = "__MUYA_BLOCK__"; -export const FORMAT_MARKER_MAP = { +interface ITag { + open: string; + close: string; +} + +export const FORMAT_MARKER_MAP: Record = { em: "*", inline_code: "`", strong: "**", del: "~~", inline_math: "$", +}; + +export const FORMAT_TAG_MAP: Record = { u: { open: "", close: "", @@ -29,7 +37,7 @@ export const FORMAT_MARKER_MAP = { open: "", close: "", }, -}; +} export const FORMAT_TYPES = [ "strong", diff --git a/lib/editor/index.ts b/lib/editor/index.ts index 24faa013..bb5aa628 100644 --- a/lib/editor/index.ts +++ b/lib/editor/index.ts @@ -62,7 +62,7 @@ class Editor { this.scrollPage = ScrollPage.create(muya, state); this._dispatchEvents(); - this._focus(); + this.focus(); this.exportAPI(); } @@ -128,7 +128,7 @@ class Editor { eventCenter.attachDOMEvent(domNode, "compositionstart", eventHandler); } - private _focus() { + focus() { // TODO: the cursor maybe passed by muya options.cursor, and no need to find the first leaf block. const firstLeafBlock = this.scrollPage!.firstContentInDescendant(); @@ -315,7 +315,7 @@ class Editor { this.history.clear(); if (autoFocus) { - this._focus(); + this.focus(); } } diff --git a/lib/inlineRenderer/renderer/highlight.ts b/lib/inlineRenderer/renderer/highlight.ts index c4521560..f18d74ff 100644 --- a/lib/inlineRenderer/renderer/highlight.ts +++ b/lib/inlineRenderer/renderer/highlight.ts @@ -1,6 +1,6 @@ +import type Format from "@muya/block/base/format"; import { union } from "@muya/utils"; import type { H, Token } from "../types"; -import type Format from "@muya/block/base/format"; import type Renderer from "./index"; // change text to highlight vnode diff --git a/lib/inlineRenderer/renderer/index.ts b/lib/inlineRenderer/renderer/index.ts index e19b85a8..67c9f547 100644 --- a/lib/inlineRenderer/renderer/index.ts +++ b/lib/inlineRenderer/renderer/index.ts @@ -1,43 +1,43 @@ -import backlashInToken from "./backlashInToken"; -import backlash from "./backlash"; -import highlight from "./highlight"; -import header from "./header"; -import link from "./link"; -import htmlTag from "./htmlTag"; -import hr from "./hr"; -import tailHeader from "./tailHeader"; -import hardLineBreak from "./hardLineBreak"; -import softLineBreak from "./softLineBreak"; -import codeFence from "./codeFence"; -import inlineMath from "./inlineMath"; +import type Format from "@muya/block/base/format"; +import { CLASS_NAMES } from "@muya/config"; +import type Muya from "@muya/index"; +import type { Cursor } from "@muya/selection/types"; +import { conflict, methodMixins, snakeToCamel } from "@muya/utils"; +import { h, toHTML } from "@muya/utils/snabbdom"; +import type { VNode } from "snabbdom"; +import type InlineRenderer from "../index"; +import type { Token } from "../types"; import autoLink from "./autoLink"; import autoLinkExtension from "./autoLinkExtension"; -import loadImageAsync from "./loadImageAsync"; -import image from "./image"; -import delEmStrongFac from "./delEmStrongFactory"; -import emoji from "./emoji"; -import inlineCode from "./inlineCode"; -import text from "./text"; +import backlash from "./backlash"; +import backlashInToken from "./backlashInToken"; +import codeFence from "./codeFence"; import del from "./del"; +import delEmStrongFac from "./delEmStrongFactory"; import em from "./em"; -import strong from "./strong"; +import emoji from "./emoji"; +import footnoteIdentifier from "./footnoteIdentifier"; +import hardLineBreak from "./hardLineBreak"; +import header from "./header"; +import highlight from "./highlight"; +import hr from "./hr"; import htmlEscape from "./htmlEscape"; +import htmlRuby from "./htmlRuby"; +import htmlTag from "./htmlTag"; +import image from "./image"; +import inlineCode from "./inlineCode"; +import inlineMath from "./inlineMath"; +import link from "./link"; +import loadImageAsync from "./loadImageAsync"; import multipleMath from "./multipleMath"; import referenceDefinition from "./referenceDefinition"; -import htmlRuby from "./htmlRuby"; -import referenceLink from "./referenceLink"; import referenceImage from "./referenceImage"; +import referenceLink from "./referenceLink"; +import softLineBreak from "./softLineBreak"; +import strong from "./strong"; import superSubScript from "./superSubScript"; -import footnoteIdentifier from "./footnoteIdentifier"; -import { CLASS_NAMES } from "@muya/config"; -import { methodMixins, conflict, snakeToCamel } from "@muya/utils"; -import { h, toHTML } from "@muya/utils/snabbdom"; -import type Muya from "@muya/index"; -import type Format from "@muya/block/base/format"; -import type InlineRenderer from "../index"; -import type { VNode } from "snabbdom"; -import type { Token } from "../types"; -import type { Cursor } from "@muya/selection/types"; +import tailHeader from "./tailHeader"; +import text from "./text"; const inlineSyntaxRenderer = { backlashInToken, diff --git a/lib/selection/index.ts b/lib/selection/index.ts index 66e19f4b..1cd1a264 100644 --- a/lib/selection/index.ts +++ b/lib/selection/index.ts @@ -161,6 +161,166 @@ class Selection { this.listenSelectActions(); } + selectAll() { + const { + anchor, + focus, + isSelectionInSameBlock, + anchorBlock, + anchorPath, + scrollPage, + } = this; + // Select all in one content block. + // Can use getSelection here? + if ( + isSelectionInSameBlock && + anchor && + focus && + anchorBlock && + Math.abs(focus.offset - anchor.offset) < anchorBlock.text.length + ) { + const cursor: Cursor = { + anchor: { offset: 0 }, + focus: { offset: anchorBlock.text.length }, + block: anchorBlock, + path: anchorPath, + }; + + this.setSelection(cursor); + return; + } + // Select all content in all blocks. + const aBlock: Content = scrollPage.firstContentInDescendant(); + const fBlock: Content = scrollPage.lastContentInDescendant(); + + const cursor: Cursor = { + anchor: { offset: 0 }, + focus: { offset: fBlock.text.length }, + anchorBlock: aBlock, + anchorPath: aBlock.path, + focusBlock: fBlock, + focusPath: fBlock.path, + }; + + this.setSelection(cursor); + const activeEle = this.doc.activeElement; + if (activeEle && activeEle.classList.contains("mu-content")) { + (activeEle as HTMLElement).blur(); + } + } + + /** + * Return the current selection of doc or null if has no selection. + * @returns + */ + getSelection(): TSelection | null { + const selection = document.getSelection(); + + if (!selection) { + return null; + } + + const { anchorNode, anchorOffset, focusNode, focusOffset } = selection; + + if (!anchorNode || !focusNode) { + return null; + } + + const anchorDomNode = findContentDOM(anchorNode); + const focusDomNode = findContentDOM(focusNode); + + if (!anchorDomNode || !focusDomNode) { + return null; + } + + const anchorBlock = anchorDomNode[BLOCK_DOM_PROPERTY] as ContentBlock; + const focusBlock = focusDomNode[BLOCK_DOM_PROPERTY] as ContentBlock; + const anchorPath = anchorBlock.path; + const focusPath = focusBlock.path; + + const aOffset = + getOffsetOfParagraph(anchorNode, anchorDomNode) + anchorOffset; + const fOffset = getOffsetOfParagraph(focusNode, focusDomNode) + focusOffset; + const anchor = { offset: aOffset }; + const focus = { offset: fOffset }; + + const isCollapsed = + anchorBlock === focusBlock && anchor.offset === focus.offset; + + const isSelectionInSameBlock = anchorBlock === focusBlock; + let direction = "none"; + let type = "None"; + + if (isCollapsed) { + direction = "none"; + } + if (isSelectionInSameBlock) { + direction = anchor.offset < focus.offset ? "forward" : "backward"; + } else { + const aDom = anchorBlock.domNode!; + const fDom = focusBlock.domNode!; + const order = compareParagraphsOrder(aDom, fDom); + direction = order ? "forward" : "backward"; + } + + type = isCollapsed ? "Caret" : "Range"; + + return { + anchor, + focus, + anchorBlock, + anchorPath, + focusBlock, + focusPath, + isCollapsed, + isSelectionInSameBlock, + direction, + type, + }; + } + + setSelection({ + anchor, + focus, + block, + path, + anchorBlock, + anchorPath, + focusBlock, + focusPath, + }: Cursor) { + this.anchor = anchor ?? null; + this.focus = focus ?? null; + this.anchorBlock = anchorBlock ?? block ?? null; + this.anchorPath = anchorPath ?? path ?? []; + this.focusBlock = focusBlock ?? block ?? null; + this.focusPath = focusPath ?? path ?? []; + // Update cursor. + this.setCursor(); + + const { + isCollapsed, + isSelectionInSameBlock, + direction, + type, + selectedImage, + } = this; + + this.muya.eventCenter.emit("selection-change", { + anchor, + focus, + anchorBlock, + anchorPath, + focusBlock, + focusPath, + isCollapsed, + isSelectionInSameBlock, + direction, + type, + selectedImage, + }); + } + private listenSelectActions() { const { eventCenter, domNode } = this.muya; @@ -438,166 +598,6 @@ class Selection { } } - selectAll() { - const { - anchor, - focus, - isSelectionInSameBlock, - anchorBlock, - anchorPath, - scrollPage, - } = this; - // Select all in one content block. - // Can use getSelection here? - if ( - isSelectionInSameBlock && - anchor && - focus && - anchorBlock && - Math.abs(focus.offset - anchor.offset) < anchorBlock.text.length - ) { - const cursor: Cursor = { - anchor: { offset: 0 }, - focus: { offset: anchorBlock.text.length }, - block: anchorBlock, - path: anchorPath, - }; - - this.setSelection(cursor); - return; - } - // Select all content in all blocks. - const aBlock: Content = scrollPage.firstContentInDescendant(); - const fBlock: Content = scrollPage.lastContentInDescendant(); - - const cursor: Cursor = { - anchor: { offset: 0 }, - focus: { offset: fBlock.text.length }, - anchorBlock: aBlock, - anchorPath: aBlock.path, - focusBlock: fBlock, - focusPath: fBlock.path, - }; - - this.setSelection(cursor); - const activeEle = this.doc.activeElement; - if (activeEle && activeEle.classList.contains("mu-content")) { - (activeEle as HTMLElement).blur(); - } - } - - /** - * Return the current selection of doc or null if has no selection. - * @returns - */ - getSelection(): TSelection | null { - const selection = document.getSelection(); - - if (!selection) { - return null; - } - - const { anchorNode, anchorOffset, focusNode, focusOffset } = selection; - - if (!anchorNode || !focusNode) { - return null; - } - - const anchorDomNode = findContentDOM(anchorNode); - const focusDomNode = findContentDOM(focusNode); - - if (!anchorDomNode || !focusDomNode) { - return null; - } - - const anchorBlock = anchorDomNode[BLOCK_DOM_PROPERTY] as ContentBlock; - const focusBlock = focusDomNode[BLOCK_DOM_PROPERTY] as ContentBlock; - const anchorPath = anchorBlock.path; - const focusPath = focusBlock.path; - - const aOffset = - getOffsetOfParagraph(anchorNode, anchorDomNode) + anchorOffset; - const fOffset = getOffsetOfParagraph(focusNode, focusDomNode) + focusOffset; - const anchor = { offset: aOffset }; - const focus = { offset: fOffset }; - - const isCollapsed = - anchorBlock === focusBlock && anchor.offset === focus.offset; - - const isSelectionInSameBlock = anchorBlock === focusBlock; - let direction = "none"; - let type = "None"; - - if (isCollapsed) { - direction = "none"; - } - if (isSelectionInSameBlock) { - direction = anchor.offset < focus.offset ? "forward" : "backward"; - } else { - const aDom = anchorBlock.domNode!; - const fDom = focusBlock.domNode!; - const order = compareParagraphsOrder(aDom, fDom); - direction = order ? "forward" : "backward"; - } - - type = isCollapsed ? "Caret" : "Range"; - - return { - anchor, - focus, - anchorBlock, - anchorPath, - focusBlock, - focusPath, - isCollapsed, - isSelectionInSameBlock, - direction, - type, - }; - } - - setSelection({ - anchor, - focus, - block, - path, - anchorBlock, - anchorPath, - focusBlock, - focusPath, - }: Cursor) { - this.anchor = anchor ?? null; - this.focus = focus ?? null; - this.anchorBlock = anchorBlock ?? block ?? null; - this.anchorPath = anchorPath ?? path ?? []; - this.focusBlock = focusBlock ?? block ?? null; - this.focusPath = focusPath ?? path ?? []; - // Update cursor. - this.setCursor(); - - const { - isCollapsed, - isSelectionInSameBlock, - direction, - type, - selectedImage, - } = this; - - this.muya.eventCenter.emit("selection-change", { - anchor, - focus, - anchorBlock, - anchorPath, - focusBlock, - focusPath, - isCollapsed, - isSelectionInSameBlock, - direction, - type, - selectedImage, - }); - } - private selectRange(range: Range) { const selection = this.doc.getSelection(); diff --git a/lib/state/types.ts b/lib/state/types.ts index 942bd987..2c7e39ac 100644 --- a/lib/state/types.ts +++ b/lib/state/types.ts @@ -15,7 +15,7 @@ export interface ISetextHeadingState { name: "setext-heading"; meta: { level: number; - underline: "===" | "---"; + underline: string; // "===" | "---"; }; text: string; } @@ -28,7 +28,7 @@ export interface IThematicBreakState { export interface ICodeBlockState { name: "code-block"; meta: { - type: "indented" | "fenced"; + type: string; // "indented" | "fenced"; lang: string; }; text: string; @@ -59,7 +59,7 @@ export interface IOrderListState { meta: { start: number; loose: boolean; - delimiter: "." | ")"; + delimiter: string; // "." | ")"; }; children: IListItemState[]; } @@ -67,7 +67,7 @@ export interface IOrderListState { export interface IBulletListState { name: "bullet-list"; meta: { - marker: "-" | "+" | "*"; + marker: string; // "-" | "+" | "*"; loose: boolean; }; children: IListItemState[]; @@ -104,7 +104,7 @@ export interface ITaskListItemState { } export interface ITaskListMeta { - marker: "-" | "+" | "*"; + marker: string; // "-" | "+" | "*"; loose: boolean; } @@ -115,7 +115,7 @@ export interface ITaskListState { } export interface IMathMeta { - mathStyle: "" | "gitlab"; + mathStyle: string; // "" | "gitlab"; } export interface IMathBlockState { @@ -125,8 +125,8 @@ export interface IMathBlockState { } export interface IFrontmatterMeta { - lang: "yaml" | "toml" | "json"; - style: "-" | "+" | ";" | "{"; + lang: string; // "yaml" | "toml" | "json"; + style: string; // "-" | "+" | ";" | "{"; } export interface IFrontmatterState { diff --git a/lib/ui/imageResizeBar/index.ts b/lib/ui/imageResizeBar/index.ts index 6ca48588..e41fb404 100644 --- a/lib/ui/imageResizeBar/index.ts +++ b/lib/ui/imageResizeBar/index.ts @@ -1,9 +1,9 @@ -import type Muya from "@muya/index"; import type Format from "@muya/block/base/format"; +import type Muya from "@muya/index"; import type { ImageToken } from "@muya/inlineRenderer/types"; -import "./index.css"; import { isMouseEvent } from "@muya/utils"; +import "./index.css"; const VERTICAL_BAR = ["left", "right"]; diff --git a/lib/ui/imageToolbar/index.ts b/lib/ui/imageToolbar/index.ts index 40e71242..3894267a 100644 --- a/lib/ui/imageToolbar/index.ts +++ b/lib/ui/imageToolbar/index.ts @@ -1,13 +1,13 @@ -import { patch, h } from "@muya/utils/snabbdom"; +import { h, patch } from "@muya/utils/snabbdom"; import BaseFloat from "../baseFloat"; import icons, { Icon } from "./config"; -import "./index.css"; -import { VNode } from "snabbdom"; -import { ImageToken } from "@muya/inlineRenderer/types"; -import type { ReferenceObject } from "popper.js"; import Format from "@muya/block/base/format"; import Muya from "@muya/index"; +import { ImageToken } from "@muya/inlineRenderer/types"; +import type { ReferenceObject } from "popper.js"; +import { VNode } from "snabbdom"; +import "./index.css"; const defaultOptions = { placement: "top", diff --git a/lib/ui/inlineFormatToolbar/index.ts b/lib/ui/inlineFormatToolbar/index.ts index 99814616..d98abe6f 100644 --- a/lib/ui/inlineFormatToolbar/index.ts +++ b/lib/ui/inlineFormatToolbar/index.ts @@ -168,6 +168,8 @@ class FormatPicker extends BaseFloat { // TODO: @JOCS, remove use this.selection directly. const { anchor, focus, anchorBlock, anchorPath, focusBlock, focusPath } = selection; + const { block } = this; + selection.setSelection({ anchor, focus, @@ -176,8 +178,8 @@ class FormatPicker extends BaseFloat { focusBlock: focusBlock!, focusPath, }); - const { block } = this; block!.format(item.type); + if (/link|image/.test(item.type)) { this.hide(); } else { diff --git a/lib/utils/image.ts b/lib/utils/image.ts index 954ca267..9f2fdf4c 100644 --- a/lib/utils/image.ts +++ b/lib/utils/image.ts @@ -3,7 +3,12 @@ import { tokenizer } from "@muya/inlineRenderer/lexer"; import type { ImageToken } from "@muya/inlineRenderer/types"; import { findContentDOM, getOffsetOfParagraph } from "@muya/selection/dom"; -export const getImageInfo = (image: HTMLElement) => { +export interface IImageInfo { + token: ImageToken; + imageId: string; +} + +export function getImageInfo(image: HTMLElement): IImageInfo { const paragraph = findContentDOM(image)!; const raw = image.getAttribute("data-raw")!; const offset = getOffsetOfParagraph(image, paragraph); @@ -18,7 +23,7 @@ export const getImageInfo = (image: HTMLElement) => { token, imageId: image.id, }; -}; +} export const getImageSrc = (src: string) => { const EXT_REG = /\.(jpeg|jpg|png|gif|svg|webp)(?=\?|$)/i;