From 3a3322a07007d411c5379cbc386bcd93def0e4b7 Mon Sep 17 00:00:00 2001 From: Alexander Rakhmatullin <43933761+rvrhiv@users.noreply.github.com> Date: Wed, 6 Dec 2023 11:36:01 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BF=D0=B0=D1=80=D0=B0=D0=BC=D0=B5=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=B4=D0=BB=D1=8F=20=D1=8D=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D0=B6=D0=B8=20(#24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/editor/index.ts | 18 ++++-- src/editor/update.ts | 9 ++- src/formatted-string/index.ts | 105 ++++++++++++++++++++++++++++++++-- src/formatted-string/types.ts | 13 +++++ src/formatted-string/utils.ts | 17 ++++++ src/index.ts | 6 +- src/parser/types.ts | 6 ++ src/render/index.ts | 9 +-- 8 files changed, 165 insertions(+), 18 deletions(-) diff --git a/src/editor/index.ts b/src/editor/index.ts index 1e669ab..a51788c 100644 --- a/src/editor/index.ts +++ b/src/editor/index.ts @@ -4,7 +4,18 @@ import render, { dispatch, isEmoji } from '../render'; import type { BaseEditorOptions, TextRange, Model } from './types'; import History, { HistoryEntry } from './history'; import { getTextRange, rangeToLocation, setDOMRange, setRange } from './range'; -import { cutText, getText, insertText, removeText, replaceText, setFormat, toggleFormat, updateFromInputEvent, updateFromInputEventFallback, updateFromOldEvent } from './update'; +import { + cutText, + getText, + insertText, + removeText, + replaceText, + setFormat, + toggleFormat, + updateFromInputEvent, + updateFromInputEventFallback, + updateFromOldEvent +} from './update'; import { setLink, slice, mdToText, textToMd, getFormat } from '../formatted-string'; import type { TokenFormatUpdate, TextRange as Rng } from '../formatted-string'; import Shortcuts from './shortcuts'; @@ -246,7 +257,6 @@ export default class Editor { }); } } - this.paste(fragment, range[0], range[1]); this.setSelection(range[0] + len); @@ -405,9 +415,9 @@ export default class Editor { } /** - * Заменяет текст в указанном диапазоне `from:to` на новый + * Заменяет текст в указанном диапазоне `from:to` на новый текст или токены */ - replaceText(from: number, to: number, text: string): Model { + replaceText(from: number, to: number, text: string | Model): Model { const result = this.paste(text, from, to); return result; } diff --git a/src/editor/update.ts b/src/editor/update.ts index 3f08f9d..82469ba 100644 --- a/src/editor/update.ts +++ b/src/editor/update.ts @@ -3,10 +3,10 @@ import parse, { TokenFormat } from '../parser'; import { insertText as plainInsertText, removeText as plainRemoveText, replaceText as plainReplaceText, mdInsertText, mdRemoveText, mdReplaceText, mdCutText, mdToText, textToMd, - cutText as plainCutText, setFormat as plainSetFormat, setLink, slice, + cutText as plainCutText, setFormat as plainSetFormat, setLink, slice, updateEmojiData, } from '../formatted-string'; import type { TokenFormatUpdate, CutText } from '../formatted-string'; -import { isCustomLink, tokenForPos } from '../formatted-string/utils'; +import { createEmojiUpdatePayload, isCustomLink, tokenForPos } from '../formatted-string/utils'; import type { BaseEditorOptions, TextRange, Model } from './types'; import { getInputText, isCollapsed, startsWith } from './utils'; @@ -120,6 +120,11 @@ export function applyFormatFromFragment(model: Model, fragment: Model, offset = model = setLink(model, token.link, offset, len); } + if (token.emoji?.length) { + const payload = createEmojiUpdatePayload(token.emoji, offset, token.value); + model = updateEmojiData(model, payload); + } + offset += len; }); diff --git a/src/formatted-string/index.ts b/src/formatted-string/index.ts index b90c7a7..ad514aa 100644 --- a/src/formatted-string/index.ts +++ b/src/formatted-string/index.ts @@ -1,15 +1,16 @@ import parse, { getText, getLength, normalize, TokenType } from '../parser'; import type { ParserOptions, Token, TokenFormat, TokenLink, TokenText } from '../parser'; import { mdToText, textToMd } from './markdown'; -import type { TokenFormatUpdate, TextRange, CutText } from './types'; +import type { TokenFormatUpdate, TextRange, CutText, EmojiUpdatePayload } from './types'; import { tokenForPos, isSolidToken, isCustomLink, isAutoLink, splitToken, - sliceToken, toLink, toText, tokenRange, createToken + sliceToken, toLink, toText, tokenRange, createToken, createEmojiUpdatePayload } from './utils'; import { objectMerge } from '../utils/objectMerge'; +import type { Emoji } from '../parser/types'; -export { mdToText, textToMd, tokenForPos } -export type { CutText, TokenFormatUpdate, TextRange } +export { mdToText, textToMd, tokenForPos, createEmojiUpdatePayload } +export type { CutText, TokenFormatUpdate, TextRange, EmojiUpdatePayload } /** @@ -257,6 +258,96 @@ export function mdSetFormat(tokens: Token[], format: TokenFormatUpdate | TokenFo return parse(textToMd(updated, range), options); } +/** + * Обновляет данные эмоджи в нужном токене. + * @param tokens массив токенов + * @param payload массив данных эмоджи, которые нужно обновить. + * NB позиция эмоджи указывается относительно всего текста + */ +export function updateEmojiData(tokens: Token[], payload: EmojiUpdatePayload[]): Token[] { + const updatedTokens = tokens.slice(); + + // Убедимся, что payloads отсортирован + payload = payload.sort((a, b) => a.pos - b.pos); + + // Для каждого элемента из payload находим токен и эмоджи в нём + payload.forEach((emojiPayload) => { + const start = tokenForPos(updatedTokens, emojiPayload.pos, 'start'); + + // убедимся, что нашли нужный токен и позицию в нем + if (start.index === -1 || start.offset === -1) { + return; + } + + const token = updatedTokens[start.index]; + const emoji = token.emoji?.find((item) => item.from === start.offset); + + if (emoji) { + // Если есть hint, убедимся, что эмоджи совпадают + if (emojiPayload.hint) { + const emojiRaw = token.value.slice(emoji.from, emoji.to); + + // Если не совпадают, то не меняем данные эмоджи + if (emojiPayload.hint !== emojiRaw) { + return; + } + } + + // нашли эмоджи, проверили hint, следовательно меняем/удаляем данные + if (emojiPayload.data) { + emoji.emojiData = emojiPayload.data; + } else if (emoji.emojiData) { + delete emoji.emojiData; + } + } + }); + + return updatedTokens; +} + +/** + * Переносит данные эмоджи из `startToken` (до start.offset) и `endToken` (после end.offset) в `tokens` + * @param tokens токены в которые нужно перенести данные + * @param startToken токен в котором было начало вставки + * @param startOffset в какую позицию была вставка относительно текста `startToken` + * @param endToken токен в котором был конец вставки (может быть равен `startToken`) + * @param endOffset в какую позицию была вставка относительно текста `endToken` + * @param textBound длина нового текста без учета `endToken.value` (после `endOffset`) + */ +function saveEmojiDataForUpdate(tokens: Token[], startToken: Token, startOffset: number, endToken: Token, endOffset: number, textBound: number) { + // собираем эмоджи из startToken до startOffset + const startTokenEmojis: Emoji[] = []; + for (let index = 0; index < startToken.emoji?.length || 0; index++) { + const emoji = startToken.emoji[index]; + if (emoji.from < startOffset) { + startTokenEmojis.push(emoji); + } else { + break; + } + } + const startTokenEmojiPayload = createEmojiUpdatePayload(startTokenEmojis, 0, startToken.value); + + // собираем эмоджи из endToken после endOffset + const endTokenEmojis: Emoji[] = []; + for (let index = (endToken.emoji?.length || 0) - 1; index >= 0; index--) { + const emoji = endToken.emoji[index]; + if (emoji.from >= endOffset) { + endTokenEmojis.push(emoji); + } else { + break; + } + } + // так как updateEmojiData принимает позиции эмоджи относительно всего текста + // необходимо учесть длину текста до endToken + const tokenEndPos = tokenForPos(tokens, textBound, 'start'); + const textLengthBeforeEndToken = getText(tokens.slice(0, tokenEndPos.index)).length; + const offset = textLengthBeforeEndToken + tokenEndPos.offset - endOffset; + + const endTokenEmojiPayload = createEmojiUpdatePayload(endTokenEmojis, offset, endToken.value); + + return updateEmojiData(tokens, [ ...startTokenEmojiPayload, ...endTokenEmojiPayload ]); +} + /** * Универсальный метод для обновления списка токенов: добавление, удаление и замена * текста в списке указанных токенов @@ -299,7 +390,11 @@ function updateTokens(tokens: Token[], value: string, from: number, to: number, if (nextTokens.length) { // Вставляем/заменяем фрагмент - nextTokens.forEach(t => t.format = startToken.format); + nextTokens.forEach((t) => t.format = startToken.format); + + // переносим параметры эмоджи из startToken (до start.offset) и endToken (после end.offset), + // так как после parse() эти параметры теряются + nextTokens = saveEmojiDataForUpdate(nextTokens, startToken, start.offset, endToken, end.offset, textBound); // Применяем форматирование из концевых токенов, но только если можем // сделать это безопасно: применяем только для текста diff --git a/src/formatted-string/types.ts b/src/formatted-string/types.ts index 95c3fd1..d589dcb 100644 --- a/src/formatted-string/types.ts +++ b/src/formatted-string/types.ts @@ -1,4 +1,5 @@ import type { Token, TokenFormat } from '../parser'; +import type { EmojiData } from '../parser/types'; /** * Объект для обновления формата формата @@ -19,3 +20,15 @@ export interface CutText { /** Модифицированная строка без вырезанного текста */ tokens: Token[]; } + +export interface EmojiUpdatePayload { + /** Позиция эмоджи относительно всей строки */ + pos: number; + /** Данные, которые нужно добавить. Если `null` — удалить данные */ + data: EmojiData | null; + /** + * Эмоджи-подсказка. Если указано, то сначала проверим, что эмоджи в модели + * совпадает с переданным. Если не совпадает — ничего не меняем + */ + hint?: string; +} diff --git a/src/formatted-string/utils.ts b/src/formatted-string/utils.ts index 1946d8f..fad2fb2 100644 --- a/src/formatted-string/utils.ts +++ b/src/formatted-string/utils.ts @@ -1,6 +1,7 @@ import { TokenType, TokenFormat } from '../parser'; import type { Token, Emoji, TokenLink, TokenText } from '../parser'; import { objectMerge } from '../utils/objectMerge'; +import type { EmojiUpdatePayload } from './types'; export interface TokenForPos { /** Индекс найденного токена (будет -1, если такой токен не найден) */ @@ -246,3 +247,19 @@ export function createToken(text: string, format: TokenFormat = 0, sticky = fals export function isSticky(token: Token): boolean { return 'sticky' in token && token.sticky; } + +/** + * Создает EmojiUpdatePayload из массива emoji. + * @param text текст токена в котором находятся эмоджи + */ +export function createEmojiUpdatePayload(emojis: Emoji[], offset = 0, text?: string): EmojiUpdatePayload[] { + const sortedEmojis = emojis.slice().sort((a, b) => a.from - b.from); + + return sortedEmojis.map((emoji) => { + const pos = offset + emoji.from; + const data = emoji.emojiData || null; + const hint = text && text.slice(emoji.from, emoji.to); + + return { pos, data, hint }; + }); +} diff --git a/src/index.ts b/src/index.ts index a5ea673..b1f87b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,10 +5,10 @@ export type { EditorEvent } from './editor'; export { rangeToLocation, locationToRange } from './editor/range'; export { default as render } from './render'; export type { RenderOptions } from './render'; -export { setFormat, setLink, textToMd, mdToText, slice, tokenForPos } from './formatted-string'; -export type { TextRange } from './formatted-string'; +export { setFormat, setLink, textToMd, mdToText, slice, tokenForPos, updateEmojiData, createEmojiUpdatePayload } from './formatted-string'; +export type { TextRange, EmojiUpdatePayload } from './formatted-string'; export { default as split } from './formatted-string/split'; export { createTree, type Tree } from './parser/tree'; export { TokenType, TokenFormat } from './parser/types'; -export type { Token, TokenCommand, TokenLink, TokenHashTag, TokenMarkdown, TokenMention, TokenText, TokenUserSticker } from './parser/types'; +export type { Token, TokenCommand, TokenLink, TokenHashTag, TokenMarkdown, TokenMention, TokenText, TokenUserSticker, Emoji } from './parser/types'; diff --git a/src/parser/types.ts b/src/parser/types.ts index c40fea3..8d2ab47 100644 --- a/src/parser/types.ts +++ b/src/parser/types.ts @@ -179,6 +179,10 @@ export interface TokenNewline extends TokenBase { type: TokenType.Newline; } +export interface EmojiData { + [key: string]: number | string; +} + export interface Emoji { /** Начало эмоджи в родительском токене */ from: number; @@ -189,4 +193,6 @@ export interface Emoji { * Используется для текстовых эмоджи (алиасов) * */ emoji?: string; + /** Дополнительные данные эмоджи */ + emojiData?: EmojiData; } diff --git a/src/render/index.ts b/src/render/index.ts index c8265eb..e584c23 100644 --- a/src/render/index.ts +++ b/src/render/index.ts @@ -1,10 +1,11 @@ import { isAutoLink, isCustomLink } from '../formatted-string/utils'; import type { Emoji, Token, TokenHashTag, TokenLink, TokenMention, TokenCommand, TokenText } from '../parser'; import { TokenFormat, TokenType } from '../parser'; +import type { EmojiData } from '../parser/types'; import { objectMerge } from '../utils/objectMerge'; type ClassFormat = [type: TokenFormat, value: string]; -export type EmojiRender = (emoji: string | null, elem?: Element, rawEmoji?: string) => Element | void; +export type EmojiRender = (emoji: string | null, elem?: Element, rawEmoji?: string, emojiData?: EmojiData) => Element | void; export interface RenderOptions { /** @@ -210,7 +211,7 @@ function renderText(token: Token, state: ReconcileState): void { state.text(text); } - state.emoji(emoji, rawEmoji); + state.emoji(emoji, rawEmoji, emojiToken.emojiData); offset = emojiToken.to; }); @@ -319,11 +320,11 @@ class ReconcileState { /** * Ожидает элемент с указанным эмоджи, при необходимости создаёт или обновляет его */ - emoji(actualEmoji: string, rawEmoji: string): Element | void { + emoji(actualEmoji: string, rawEmoji: string, emojiData?: EmojiData): Element | void { const { emoji } = this.options; const node = this.container.childNodes[this.pos]; const isCurEmoji = node ? isEmoji(node) : false; - const next = emoji(actualEmoji, isCurEmoji ? node as Element : null, rawEmoji); + const next = emoji(actualEmoji, isCurEmoji ? node as Element : null, rawEmoji, emojiData); if (next) { if (node !== next) {