UNPKG

@atlaskit/editor-plugin-show-diff

Version:

ShowDiff plugin for @atlaskit/editor-core

283 lines (274 loc) 8.73 kB
/** * A half-open offset span `[from, to)` measured in *content offsets* relative to the * start of a textblock (i.e. the same coordinate space as `buildCharsByOffset`). */ /** * Minimal local typing for `Intl.Segmenter`. The TS lib target configured for this * package does not include the `Intl.Segmenter` declarations, so we declare just the * surface we use and access it via a runtime-checked cast. Remove when the lib target is * bumped to include `es2022.intl`/`esnext.intl`. */ // A character is treated as part of a word unless it is whitespace or common punctuation. // We deliberately avoid Unicode property escapes (`\p{...}`) and the `u` flag here: the `u` // flag is only valid when targeting es6+, and the declaration-generation build config for // this package targets an older lib, which rejects it with TS1501. This negated approach // keeps word segmentation reasonable across scripts (any non-punctuation, non-space glyph // counts as a word char) without needing Unicode-aware regex. const NON_WORD_CHARS = ' \t\n\r\f\v.,;:!?…。!?、"\'`“”‘’()[]{}<>/\\|@#$%^&*-+=~'; const NON_WORD_CHAR_SET = new Set(NON_WORD_CHARS.split('')); const isWordChar = ch => !NON_WORD_CHAR_SET.has(ch); // Whitespace is checked via a Set rather than a regex to avoid both the `require-unicode-regexp` // lint rule and the TS1501 error the `u` flag triggers under this package's build lib target. const WHITESPACE_CHAR_SET = new Set([' ', '\t', '\n', '\r', '\f', '\v', '\u00a0']); const isWhitespaceChar = ch => WHITESPACE_CHAR_SET.has(ch); // Sentence terminators used by the regex fallback. We intentionally keep this small and // conservative — the Intl.Segmenter path is the source of truth when available. const SENTENCE_TERMINATORS = new Set(['.', '!', '?', '…', '。', '!', '?']); /** * Build a per-content-offset view of a textblock's characters. * * `chars[i]` is the character at content offset `i`, or `null` when that offset lies * inside a non-text inline node (mention, date, emoji, hardBreak, …). A `null` acts as an * *opaque single token*: it counts as one word and never terminates a sentence. */ export const buildCharsByOffset = parent => { const chars = Array.from({ length: parent.content.size }, () => null); parent.content.forEach((child, offset) => { var _child$text; if (!child.isText) { return; } const text = (_child$text = child.text) !== null && _child$text !== void 0 ? _child$text : ''; for (let i = 0; i < text.length; i++) { chars[offset + i] = text[i]; } }); return chars; }; const getSegmenterCtor = () => { if (typeof Intl === 'undefined') { return undefined; } const ctor = Intl.Segmenter; return typeof ctor === 'function' ? ctor : undefined; }; let cachedLocale; let cachedSentenceSegmenter; let cachedWordSegmenter; const getSegmenters = locale => { const Ctor = getSegmenterCtor(); if (!Ctor) { return undefined; } if (cachedLocale !== locale || !cachedSentenceSegmenter || !cachedWordSegmenter) { cachedLocale = locale; cachedSentenceSegmenter = new Ctor(locale, { granularity: 'sentence' }); cachedWordSegmenter = new Ctor(locale, { granularity: 'word' }); } return { sentence: cachedSentenceSegmenter, word: cachedWordSegmenter }; }; /** * Convert a char-view into a plain string plus an index map back to content offsets. * * Opaque inline tokens (`null`) are represented in the string by a single sentinel * character (Unicode Object Replacement Character, U+FFFC) so that: * - the segmenter treats them as a word-like glyph rather than whitespace/punctuation; * - they never look like a sentence terminator. * `indexMap[j]` gives the content offset of string index `j`. */ const OBJECT_REPLACEMENT = '\uFFFC'; const flattenChars = chars => { let text = ''; const indexMap = []; for (let offset = 0; offset < chars.length; offset++) { const ch = chars[offset]; text += ch === null ? OBJECT_REPLACEMENT : ch; indexMap.push(offset); } return { text, indexMap }; }; const stringIndexToOffset = (indexMap, strIdx, fallback) => { if (strIdx < 0) { return fallback; } if (strIdx >= indexMap.length) { // One-past-the-end maps to the offset after the last char. return indexMap.length > 0 ? indexMap[indexMap.length - 1] + 1 : fallback; } return indexMap[strIdx]; }; /** * Segment a textblock char-view into sentence spans (content-offset coordinates). * Uses Intl.Segmenter when available, otherwise a conservative regex fallback. */ export const segmentSentences = (chars, locale) => { if (chars.length === 0) { return []; } const { text, indexMap } = flattenChars(chars); const segmenters = getSegmenters(locale); if (segmenters) { const spans = []; for (const seg of segmenters.sentence.segment(text)) { const startOffset = stringIndexToOffset(indexMap, seg.index, 0); const endOffset = stringIndexToOffset(indexMap, seg.index + seg.segment.length, chars.length); if (endOffset > startOffset) { spans.push({ from: startOffset, to: endOffset }); } } return spans.length > 0 ? spans : [{ from: 0, to: chars.length }]; } // Regex fallback: split after a sentence terminator followed by whitespace/EOL. // A `null` (OBJECT_REPLACEMENT) is never a terminator. const spans = []; let start = 0; for (let i = 0; i < chars.length; i++) { const ch = chars[i]; if (ch !== null && SENTENCE_TERMINATORS.has(ch)) { // Consume trailing spaces as part of this sentence. let end = i + 1; while (end < chars.length && chars[end] !== null && isWhitespaceChar(chars[end])) { end++; } spans.push({ from: start, to: end }); start = end; } } if (start < chars.length) { spans.push({ from: start, to: chars.length }); } return spans.length > 0 ? spans : [{ from: 0, to: chars.length }]; }; /** * Count word-like tokens within a content-offset range of the char-view. * Each opaque inline token (`null`) counts as exactly one word. */ export const countWords = (chars, span, locale) => { const slice = chars.slice(span.from, span.to); if (slice.length === 0) { return 0; } const segmenters = getSegmenters(locale); const { text } = flattenChars(slice); if (segmenters) { let count = 0; for (const seg of segmenters.word.segment(text)) { // Object-replacement sentinel is word-like; count it. if (seg.isWordLike || seg.segment.includes(OBJECT_REPLACEMENT)) { count++; } } return count; } // Regex fallback: a run of word chars OR a single opaque token is one word. let count = 0; let inWord = false; for (const ch of slice) { if (ch === null) { count++; inWord = false; continue; } if (isWordChar(ch)) { if (!inWord) { count++; inWord = true; } } else { inWord = false; } } return count; }; /** * Return the word-token spans (content-offset coordinates) within a range. Used to decide * which words a change overlaps. Each opaque inline token is its own span. */ export const segmentWordSpans = (chars, span, locale) => { const segmenters = getSegmenters(locale); const slice = chars.slice(span.from, span.to); const { text, indexMap } = flattenChars(slice); const spans = []; if (segmenters) { for (const seg of segmenters.word.segment(text)) { if (seg.isWordLike || seg.segment.includes(OBJECT_REPLACEMENT)) { const from = span.from + stringIndexToOffset(indexMap, seg.index, 0); const to = span.from + stringIndexToOffset(indexMap, seg.index + seg.segment.length, slice.length); spans.push({ from, to }); } } return spans; } let wordStart = -1; for (let i = 0; i < slice.length; i++) { const ch = slice[i]; if (ch === null) { if (wordStart !== -1) { spans.push({ from: span.from + wordStart, to: span.from + i }); wordStart = -1; } spans.push({ from: span.from + i, to: span.from + i + 1 }); } else if (isWordChar(ch)) { if (wordStart === -1) { wordStart = i; } } else if (wordStart !== -1) { spans.push({ from: span.from + wordStart, to: span.from + i }); wordStart = -1; } } if (wordStart !== -1) { spans.push({ from: span.from + wordStart, to: span.from + slice.length }); } return spans; };