Skip to content

Commit

Permalink
Поддержка параметров для эмоджи (#24)
Browse files Browse the repository at this point in the history
  • Loading branch information
rvrhiv authored Dec 6, 2023
1 parent d7de31b commit 3a3322a
Show file tree
Hide file tree
Showing 8 changed files with 165 additions and 18 deletions.
18 changes: 14 additions & 4 deletions src/editor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -246,7 +257,6 @@ export default class Editor {
});
}
}

this.paste(fragment, range[0], range[1]);
this.setSelection(range[0] + len);

Expand Down Expand Up @@ -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;
}
Expand Down
9 changes: 7 additions & 2 deletions src/editor/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
});

Expand Down
105 changes: 100 additions & 5 deletions src/formatted-string/index.ts
Original file line number Diff line number Diff line change
@@ -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 }


/**
Expand Down Expand Up @@ -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 ]);
}

/**
* Универсальный метод для обновления списка токенов: добавление, удаление и замена
* текста в списке указанных токенов
Expand Down Expand Up @@ -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);

// Применяем форматирование из концевых токенов, но только если можем
// сделать это безопасно: применяем только для текста
Expand Down
13 changes: 13 additions & 0 deletions src/formatted-string/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Token, TokenFormat } from '../parser';
import type { EmojiData } from '../parser/types';

/**
* Объект для обновления формата формата
Expand All @@ -19,3 +20,15 @@ export interface CutText {
/** Модифицированная строка без вырезанного текста */
tokens: Token[];
}

export interface EmojiUpdatePayload {
/** Позиция эмоджи относительно всей строки */
pos: number;
/** Данные, которые нужно добавить. Если `null` — удалить данные */
data: EmojiData | null;
/**
* Эмоджи-подсказка. Если указано, то сначала проверим, что эмоджи в модели
* совпадает с переданным. Если не совпадает — ничего не меняем
*/
hint?: string;
}
17 changes: 17 additions & 0 deletions src/formatted-string/utils.ts
Original file line number Diff line number Diff line change
@@ -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, если такой токен не найден) */
Expand Down Expand Up @@ -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 };
});
}
6 changes: 3 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
6 changes: 6 additions & 0 deletions src/parser/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,10 @@ export interface TokenNewline extends TokenBase {
type: TokenType.Newline;
}

export interface EmojiData {
[key: string]: number | string;
}

export interface Emoji {
/** Начало эмоджи в родительском токене */
from: number;
Expand All @@ -189,4 +193,6 @@ export interface Emoji {
* Используется для текстовых эмоджи (алиасов)
* */
emoji?: string;
/** Дополнительные данные эмоджи */
emojiData?: EmojiData;
}
9 changes: 5 additions & 4 deletions src/render/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
/**
Expand Down Expand Up @@ -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;
});

Expand Down Expand Up @@ -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) {
Expand Down

0 comments on commit 3a3322a

Please sign in to comment.