beez-ui 0.6.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +150 -96
- package/README.md +33 -6
- package/dist/components/account-menu.d.ts +53 -0
- package/dist/components/account-menu.js +405 -0
- package/dist/components/alert.js +1 -1
- package/dist/components/animated-collapse.d.ts +19 -0
- package/dist/components/animated-collapse.js +81 -0
- package/dist/components/animated-count.d.ts +13 -0
- package/dist/components/animated-count.js +105 -0
- package/dist/components/animated-list-item.d.ts +25 -0
- package/dist/components/animated-list-item.js +132 -0
- package/dist/components/avatar.d.ts +2 -1
- package/dist/components/avatar.js +4 -3
- package/dist/components/bouncing-dots-loader.d.ts +19 -0
- package/dist/components/bouncing-dots-loader.js +47 -0
- package/dist/components/calendar.js +102 -0
- package/dist/components/carousel.js +61 -48
- package/dist/components/checkbox.js +1 -1
- package/dist/components/confirm-delete-button.d.ts +29 -0
- package/dist/components/confirm-delete-button.js +254 -0
- package/dist/components/data-table.js +34 -4
- package/dist/components/dropdown-menu.js +5 -2
- package/dist/components/empty-state.d.ts +24 -0
- package/dist/components/empty-state.js +37 -0
- package/dist/components/error-state.d.ts +20 -0
- package/dist/components/error-state.js +145 -0
- package/dist/components/external-browser-handoff.d.ts +27 -0
- package/dist/components/external-browser-handoff.js +306 -0
- package/dist/components/file-upload.d.ts +78 -0
- package/dist/components/file-upload.js +678 -0
- package/dist/components/filter-query-bar.js +11 -6
- package/dist/components/form.d.ts +5 -0
- package/dist/components/form.js +11 -9
- package/dist/components/info-popover.d.ts +23 -0
- package/dist/components/info-popover.js +158 -0
- package/dist/components/input-group.js +1 -1
- package/dist/components/input.js +1 -1
- package/dist/components/month-calendar-header.d.ts +65 -0
- package/dist/components/month-calendar-header.js +337 -0
- package/dist/components/month-grid.d.ts +59 -0
- package/dist/components/month-grid.js +301 -0
- package/dist/components/notification-bell.d.ts +50 -0
- package/dist/components/notification-bell.js +338 -0
- package/dist/components/notification-panel.d.ts +56 -0
- package/dist/components/notification-panel.js +281 -0
- package/dist/components/open-in-browser-cta.d.ts +22 -0
- package/dist/components/open-in-browser-cta.js +61 -0
- package/dist/components/pagination.d.ts +1 -0
- package/dist/components/pagination.js +9 -4
- package/dist/components/presence-swap.d.ts +22 -0
- package/dist/components/presence-swap.js +162 -0
- package/dist/components/progress-ring.d.ts +20 -0
- package/dist/components/progress-ring.js +53 -0
- package/dist/components/pwa-update-control.d.ts +18 -0
- package/dist/components/pwa-update-control.js +119 -0
- package/dist/components/radio-group.js +1 -1
- package/dist/components/reaction-button.d.ts +26 -0
- package/dist/components/reaction-button.js +114 -0
- package/dist/components/rich-link-editor.d.ts +25 -0
- package/dist/components/rich-link-editor.js +320 -0
- package/dist/components/rich-markdown-content.d.ts +10 -0
- package/dist/components/rich-markdown-content.js +56 -0
- package/dist/components/rich-text-content.d.ts +19 -0
- package/dist/components/rich-text-content.js +26 -0
- package/dist/components/select.js +1 -1
- package/dist/components/sheet.js +12 -9
- package/dist/components/sidebar.js +9 -5
- package/dist/components/skeleton.d.ts +6 -0
- package/dist/components/skeleton.js +7 -1
- package/dist/components/switch.js +1 -1
- package/dist/components/textarea.js +1 -1
- package/dist/components/typing-animation.js +42 -15
- package/dist/emoji-picker.d.ts +27 -0
- package/dist/emoji-picker.js +283 -0
- package/dist/hooks/use-horizontal-swipe.d.ts +23 -0
- package/dist/hooks/use-horizontal-swipe.js +80 -0
- package/dist/hooks/use-is-hydrated.d.ts +6 -0
- package/dist/hooks/use-is-hydrated.js +20 -0
- package/dist/hooks/use-minute-clock.d.ts +23 -0
- package/dist/hooks/use-minute-clock.js +147 -0
- package/dist/hooks/use-month-transition-direction.d.ts +21 -0
- package/dist/hooks/use-month-transition-direction.js +110 -0
- package/dist/hooks/use-rich-link-editor.d.ts +55 -0
- package/dist/hooks/use-rich-link-editor.js +515 -0
- package/dist/hooks/use-viewer-time-zone.d.ts +6 -0
- package/dist/hooks/use-viewer-time-zone.js +20 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.js +43 -0
- package/dist/lib/browser-clipboard.d.ts +13 -0
- package/dist/lib/browser-clipboard.js +48 -0
- package/dist/lib/browser-navigation.d.ts +21 -0
- package/dist/lib/browser-navigation.js +42 -0
- package/dist/lib/column-filter-value.d.ts +12 -0
- package/dist/lib/column-filter-value.js +52 -0
- package/dist/lib/file-acceptance.d.ts +43 -0
- package/dist/lib/file-acceptance.js +107 -0
- package/dist/lib/format-file-size.d.ts +16 -0
- package/dist/lib/format-file-size.js +34 -0
- package/dist/lib/fuzzy-search.d.ts +17 -0
- package/dist/lib/fuzzy-search.js +183 -0
- package/dist/lib/horizontal-swipe.d.ts +23 -0
- package/dist/lib/horizontal-swipe.js +29 -0
- package/dist/lib/in-app-browser.d.ts +35 -0
- package/dist/lib/in-app-browser.js +80 -0
- package/dist/lib/month-grid.d.ts +42 -0
- package/dist/lib/month-grid.js +74 -0
- package/dist/lib/name-initials.d.ts +7 -0
- package/dist/lib/name-initials.js +11 -0
- package/dist/lib/rich-text/link-markdown-constants.d.ts +147 -0
- package/dist/lib/rich-text/link-markdown-constants.js +458 -0
- package/dist/lib/rich-text/link-markdown-types.d.ts +53 -0
- package/dist/lib/rich-text/link-markdown-types.js +1 -0
- package/dist/lib/rich-text/link-markdown.d.ts +193 -0
- package/dist/lib/rich-text/link-markdown.js +592 -0
- package/dist/lib/rich-text/rich-markdown.d.ts +41 -0
- package/dist/lib/rich-text/rich-markdown.js +105 -0
- package/dist/lib/rich-text/rich-text-editor-dom.d.ts +26 -0
- package/dist/lib/rich-text/rich-text-editor-dom.js +79 -0
- package/dist/motion/glide-indicator.d.ts +37 -3
- package/dist/motion/glide-indicator.js +37 -3
- package/dist/motion/motion-slot.js +47 -18
- package/dist/motion/surface-keyframes.js +18 -6
- package/dist/motion/tokens.d.ts +41 -2
- package/dist/motion/tokens.js +40 -1
- package/dist/providers/beez-ui-provider.js +49 -17
- package/dist/styles.css +1 -1
- package/package.json +212 -2
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the rich-text link layer: URL normalization, markdown
|
|
3
|
+
* link (de)serialization, plain-text autolink parsing, and editor link
|
|
4
|
+
* bookkeeping. None of these touch the DOM, so they are unit-testable in
|
|
5
|
+
* isolation and shared by the renderer, the editor hook, and persistence.
|
|
6
|
+
*/
|
|
7
|
+
import { KNOWN_TOP_LEVEL_DOMAINS, LINK_MARKDOWN_ALLOWED_PROTOCOL, LINK_MARKDOWN_ESCAPE_PATTERN, LINK_MARKDOWN_ESCAPE_VALUE, LINK_MARKDOWN_FORMAT, LINK_MARKDOWN_MATCH_GROUP, LINK_MARKDOWN_URL_PAREN, LINK_PATTERN, LINK_PROTOCOL_PREFIX, PREVIEW_LINK_KEY_SEPARATOR, RICH_LINK_KIND, RICH_PREVIEW_LINK_SOURCE, RICH_TEXT_EDITOR_TEXT, RICH_TEXT_EDITOR_WORD_DIRECTION, RICH_TEXT_SEGMENT_TYPE, TEXT_DIFF_FALLBACK_INDEX } from "./link-markdown-constants.js";
|
|
8
|
+
const EMAIL_LOCAL_PART_SEPARATOR = "@";
|
|
9
|
+
/** Separator between the labels of a host name (`sub.example.com`). */
|
|
10
|
+
const LINK_HOST_LABEL_SEPARATOR = ".";
|
|
11
|
+
/** A linkable host needs at least a domain label and a TLD label. */
|
|
12
|
+
const MINIMUM_HOST_LABELS = 2;
|
|
13
|
+
/**
|
|
14
|
+
* Normalizes a raw markdown URL into a safe absolute `http(s)` URL, or `null`
|
|
15
|
+
* when it is not a usable web link. Bare domains gain an `https://` prefix.
|
|
16
|
+
*
|
|
17
|
+
* A candidate that contains whitespace is returned as its parsed `href`, which
|
|
18
|
+
* percent-encodes those characters. The renderer's `LINK_PATTERN.markdown` URL
|
|
19
|
+
* group forbids whitespace, so persisting the raw candidate would serialize a
|
|
20
|
+
* `[text](url with space)` link that cannot be read back and renders as broken
|
|
21
|
+
* markdown. Whitespace-free candidates are returned verbatim to preserve their
|
|
22
|
+
* exact form (no trailing slash, stable synchronization checks).
|
|
23
|
+
*
|
|
24
|
+
* A scheme-less candidate that parses into userinfo (`john.doe@example.com`) is
|
|
25
|
+
* rejected: the `bareDomain` pattern allows `@` in its trailing class, so an
|
|
26
|
+
* email or literal slips through and prepending `https://` would yield
|
|
27
|
+
* `https://john.doe@example.com`, where the browser reads `john.doe` as
|
|
28
|
+
* credentials for `example.com`. That turns the value into a misleading
|
|
29
|
+
* outbound link, against the convention that email addresses are not linked.
|
|
30
|
+
* The check uses the parsed `username`/`password` rather than a naive `@` scan
|
|
31
|
+
* so a legitimate `@` past the authority (a path or query) still links.
|
|
32
|
+
*/
|
|
33
|
+
export function normalizeMarkdownUrl(rawUrl) {
|
|
34
|
+
const trimmedUrl = rawUrl?.trim();
|
|
35
|
+
if (!trimmedUrl) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const hasExplicitProtocol = LINK_PATTERN.protocolPrefix.test(trimmedUrl);
|
|
39
|
+
const candidateUrl = hasExplicitProtocol ? trimmedUrl : LINK_PATTERN.bareDomain.test(trimmedUrl) ? LINK_PROTOCOL_PREFIX.default + trimmedUrl : null;
|
|
40
|
+
if (!candidateUrl) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const url = new URL(candidateUrl);
|
|
45
|
+
if (url.protocol !== LINK_MARKDOWN_ALLOWED_PROTOCOL.http && url.protocol !== LINK_MARKDOWN_ALLOWED_PROTOCOL.https) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
if (!hasExplicitProtocol && (url.username || url.password)) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return LINK_PATTERN.containsWhitespace.test(candidateUrl) ? url.href : candidateUrl;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Extracts the top-level domain (the last dot-separated label of the host) from
|
|
58
|
+
* a bare or scheme-prefixed candidate, lowercased, or `null` when the candidate
|
|
59
|
+
* has no host label past a dot. The host ends at the first path, query,
|
|
60
|
+
* fragment, or port boundary so a path segment is never mistaken for the TLD.
|
|
61
|
+
*/
|
|
62
|
+
export function getCandidateTopLevelDomain(candidate) {
|
|
63
|
+
const host = candidate.replace(LINK_PATTERN.protocolPrefix, "").split(LINK_PATTERN.hostBoundary)[0];
|
|
64
|
+
const labels = host.split(LINK_HOST_LABEL_SEPARATOR);
|
|
65
|
+
if (labels.length < MINIMUM_HOST_LABELS) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
return labels[labels.length - 1].toLowerCase() || null;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Normalizes a candidate for the automatic detection path. Scheme-prefixed URLs
|
|
72
|
+
* are trusted and normalized as-is; a scheme-less bare domain is only accepted
|
|
73
|
+
* when its TLD is a recognized public suffix, so prose abbreviations such as
|
|
74
|
+
* `EE.UU.` or `China.Por` stay plain text instead of resolving to bogus hosts.
|
|
75
|
+
* Explicit paste-as-link flows keep using `normalizeMarkdownUrl` directly and
|
|
76
|
+
* stay permissive.
|
|
77
|
+
*/
|
|
78
|
+
export function normalizeAutolinkUrl(candidate) {
|
|
79
|
+
if (LINK_PATTERN.protocolPrefix.test(candidate)) {
|
|
80
|
+
return normalizeMarkdownUrl(candidate);
|
|
81
|
+
}
|
|
82
|
+
const topLevelDomain = getCandidateTopLevelDomain(candidate);
|
|
83
|
+
if (!topLevelDomain || !KNOWN_TOP_LEVEL_DOMAINS.has(topLevelDomain)) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
return normalizeMarkdownUrl(candidate);
|
|
87
|
+
}
|
|
88
|
+
/** Strips the `http(s)://` prefix from a value for synchronization checks. */
|
|
89
|
+
export function removeLinkProtocolPrefix(value) {
|
|
90
|
+
return value.trim().replace(LINK_PATTERN.protocolPrefix, "");
|
|
91
|
+
}
|
|
92
|
+
/** Whether a link's visible text matches its URL ignoring the protocol. */
|
|
93
|
+
export function isLinkSynchronized(text, url) {
|
|
94
|
+
return removeLinkProtocolPrefix(text) === removeLinkProtocolPrefix(url);
|
|
95
|
+
}
|
|
96
|
+
/** Returns the protocol prefix of a URL, defaulting to `https://`. */
|
|
97
|
+
export function getLinkProtocolPrefix(url) {
|
|
98
|
+
const protocolPrefix = url.match(LINK_PATTERN.protocolPrefix)?.[0];
|
|
99
|
+
return protocolPrefix ?? LINK_PROTOCOL_PREFIX.default;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Rebuilds the URL of a synchronized link after its visible text changed, so a
|
|
103
|
+
* link whose label is the URL keeps tracking the edited text.
|
|
104
|
+
*/
|
|
105
|
+
export function getSynchronizedLinkUrl(text, currentUrl) {
|
|
106
|
+
const trimmedText = text.trim();
|
|
107
|
+
if (!trimmedText) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
return normalizeMarkdownUrl(LINK_PATTERN.protocolPrefix.test(trimmedText) ? trimmedText : getLinkProtocolPrefix(currentUrl) + trimmedText);
|
|
111
|
+
}
|
|
112
|
+
/** Whether the value contains only whitespace. */
|
|
113
|
+
export function isWhitespaceOnly(value) {
|
|
114
|
+
return LINK_PATTERN.whitespace.test(value);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Computes the range a word-wise deletion (Ctrl/Alt+Backspace/Delete) should
|
|
118
|
+
* remove from a collapsed caret, mirroring native word deletion.
|
|
119
|
+
*/
|
|
120
|
+
export function getWordDeletionRange(input) {
|
|
121
|
+
if (input.selectionRange.start !== input.selectionRange.end) {
|
|
122
|
+
return input.selectionRange;
|
|
123
|
+
}
|
|
124
|
+
if (input.direction === RICH_TEXT_EDITOR_WORD_DIRECTION.forward) {
|
|
125
|
+
let end = input.selectionRange.end;
|
|
126
|
+
while (end < input.text.length && isWhitespaceOnly(input.text[end])) {
|
|
127
|
+
end += 1;
|
|
128
|
+
}
|
|
129
|
+
while (end < input.text.length && !isWhitespaceOnly(input.text[end])) {
|
|
130
|
+
end += 1;
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
end,
|
|
134
|
+
start: input.selectionRange.start
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
let start = input.selectionRange.start;
|
|
138
|
+
while (start > 0 && isWhitespaceOnly(input.text[start - 1])) {
|
|
139
|
+
start -= 1;
|
|
140
|
+
}
|
|
141
|
+
while (start > 0 && !isWhitespaceOnly(input.text[start - 1])) {
|
|
142
|
+
start -= 1;
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
end: input.selectionRange.end,
|
|
146
|
+
start
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Finds the index of the `)` that closes a balanced single-level paren pair
|
|
151
|
+
* opened at `openIndex`, or `null` when the run cannot form one the markdown URL
|
|
152
|
+
* group accepts. A pair is balanced only when nothing but non-paren,
|
|
153
|
+
* non-whitespace characters sit between the parens, mirroring the `\([^()\s]*\)`
|
|
154
|
+
* alternative in `LINK_PATTERN.markdown`.
|
|
155
|
+
*/
|
|
156
|
+
function findBalancedParenCloseIndex(url, openIndex) {
|
|
157
|
+
for (let index = openIndex + 1; index < url.length; index += 1) {
|
|
158
|
+
const character = url[index];
|
|
159
|
+
if (character === LINK_MARKDOWN_URL_PAREN.close) {
|
|
160
|
+
return index;
|
|
161
|
+
}
|
|
162
|
+
if (character === LINK_MARKDOWN_URL_PAREN.open || isWhitespaceOnly(character)) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Percent-encodes the parentheses a URL cannot keep literal inside a
|
|
170
|
+
* `[text](url)` target, so an explicit link whose URL contains an unmatched or
|
|
171
|
+
* nested `(`/`)` (for example `https://example.com/a)b`) serializes to markdown
|
|
172
|
+
* that `LINK_PATTERN.markdown` reads back as the same link instead of truncating
|
|
173
|
+
* the target at the stray paren and spilling the rest into plain text.
|
|
174
|
+
*
|
|
175
|
+
* The URL group only matches balanced single-level `(...)` pairs, so those are
|
|
176
|
+
* preserved verbatim (keeping links like `.../Foo_(bar)` intact); every other
|
|
177
|
+
* paren becomes `%28`/`%29`, which is decoded back to the literal character when
|
|
178
|
+
* the link is opened.
|
|
179
|
+
*/
|
|
180
|
+
export function escapeMarkdownLinkUrl(url) {
|
|
181
|
+
let escapedUrl = "";
|
|
182
|
+
let index = 0;
|
|
183
|
+
while (index < url.length) {
|
|
184
|
+
const character = url[index];
|
|
185
|
+
if (character === LINK_MARKDOWN_URL_PAREN.open) {
|
|
186
|
+
const closeIndex = findBalancedParenCloseIndex(url, index);
|
|
187
|
+
if (closeIndex !== null) {
|
|
188
|
+
escapedUrl += url.slice(index, closeIndex + 1);
|
|
189
|
+
index = closeIndex + 1;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
escapedUrl += LINK_MARKDOWN_URL_PAREN.encodedOpen;
|
|
193
|
+
index += 1;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (character === LINK_MARKDOWN_URL_PAREN.close) {
|
|
197
|
+
escapedUrl += LINK_MARKDOWN_URL_PAREN.encodedClose;
|
|
198
|
+
index += 1;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
escapedUrl += character;
|
|
202
|
+
index += 1;
|
|
203
|
+
}
|
|
204
|
+
return escapedUrl;
|
|
205
|
+
}
|
|
206
|
+
/** Builds a `[text](url)` markdown link, escaping the label and the URL. */
|
|
207
|
+
export function buildMarkdownLinkFromSelection(text, url) {
|
|
208
|
+
return LINK_MARKDOWN_FORMAT.openLabel + escapeMarkdownLinkText(text) + LINK_MARKDOWN_FORMAT.openUrl + escapeMarkdownLinkUrl(url) + LINK_MARKDOWN_FORMAT.closeUrl;
|
|
209
|
+
}
|
|
210
|
+
/** Escapes characters that would break a markdown link label. */
|
|
211
|
+
export function escapeMarkdownLinkText(text) {
|
|
212
|
+
return text.replace(LINK_MARKDOWN_ESCAPE_PATTERN.backslash, LINK_MARKDOWN_ESCAPE_VALUE.escapedBackslash).replace(LINK_MARKDOWN_ESCAPE_PATTERN.lineBreak, LINK_MARKDOWN_ESCAPE_VALUE.escapedLineBreak).replace(LINK_MARKDOWN_ESCAPE_PATTERN.openLabel, LINK_MARKDOWN_ESCAPE_VALUE.escapedOpenLabel).replace(LINK_MARKDOWN_ESCAPE_PATTERN.closeLabel, LINK_MARKDOWN_ESCAPE_VALUE.escapedCloseLabel);
|
|
213
|
+
}
|
|
214
|
+
/** Reverses `escapeMarkdownLinkText`, restoring line breaks and literals. */
|
|
215
|
+
export function unescapeMarkdownLinkText(text) {
|
|
216
|
+
let unescapedText = "";
|
|
217
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
218
|
+
const currentCharacter = text[index];
|
|
219
|
+
const nextCharacter = text[index + 1];
|
|
220
|
+
if (currentCharacter === LINK_MARKDOWN_ESCAPE_VALUE.backslash && nextCharacter) {
|
|
221
|
+
unescapedText += nextCharacter === LINK_MARKDOWN_ESCAPE_VALUE.lineBreakToken ? RICH_TEXT_EDITOR_TEXT.lineBreak : nextCharacter;
|
|
222
|
+
index += 1;
|
|
223
|
+
} else {
|
|
224
|
+
unescapedText += currentCharacter;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return unescapedText;
|
|
228
|
+
}
|
|
229
|
+
/** Builds a stable React key for a preview link segment. */
|
|
230
|
+
export function buildPreviewLinkKey(segment) {
|
|
231
|
+
return [
|
|
232
|
+
segment.source,
|
|
233
|
+
segment.id ?? String(segment.start),
|
|
234
|
+
segment.url
|
|
235
|
+
].join(PREVIEW_LINK_KEY_SEPARATOR.value);
|
|
236
|
+
}
|
|
237
|
+
/** Creates a plain-text rendered segment. */
|
|
238
|
+
export function createTextSegment(text) {
|
|
239
|
+
return {
|
|
240
|
+
text,
|
|
241
|
+
type: RICH_TEXT_SEGMENT_TYPE.text
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
/** Creates a link rendered segment. */
|
|
245
|
+
export function createLinkSegment(text, url) {
|
|
246
|
+
return {
|
|
247
|
+
text,
|
|
248
|
+
type: RICH_TEXT_SEGMENT_TYPE.link,
|
|
249
|
+
url
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
/** Creates a plain-text editor preview segment. */
|
|
253
|
+
export function createTextPreviewSegment(text) {
|
|
254
|
+
return {
|
|
255
|
+
text,
|
|
256
|
+
type: RICH_TEXT_SEGMENT_TYPE.text
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
/** Creates a link editor preview segment with a stable key. */
|
|
260
|
+
export function createLinkPreviewSegment(input) {
|
|
261
|
+
return {
|
|
262
|
+
...input,
|
|
263
|
+
key: buildPreviewLinkKey(input),
|
|
264
|
+
type: RICH_TEXT_SEGMENT_TYPE.link
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
/** Splits a matched bare URL from any trailing sentence punctuation. */
|
|
268
|
+
export function splitBareUrlMatch(matchedUrl) {
|
|
269
|
+
const urlText = matchedUrl.replace(LINK_PATTERN.trailingPunctuation, "");
|
|
270
|
+
return {
|
|
271
|
+
trailingText: matchedUrl.slice(urlText.length),
|
|
272
|
+
urlText
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
/** Whether two `[start, end)` ranges overlap. */
|
|
276
|
+
export function rangesOverlap(firstRange, secondRange) {
|
|
277
|
+
return firstRange.start < secondRange.end && secondRange.start < firstRange.end;
|
|
278
|
+
}
|
|
279
|
+
/** Whether a matched bare URL is actually part of an email address. */
|
|
280
|
+
export function isBareUrlMatchInsideEmail(input) {
|
|
281
|
+
if (LINK_PATTERN.protocolPrefix.test(input.matchedUrl)) {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
return input.matchedUrl.includes(EMAIL_LOCAL_PART_SEPARATOR) || input.matchedIndex > 0 && input.content[input.matchedIndex - 1] === EMAIL_LOCAL_PART_SEPARATOR;
|
|
285
|
+
}
|
|
286
|
+
/** Parses plain text into rendered segments, auto-linking bare URLs. */
|
|
287
|
+
export function parseBareUrlSegments(content) {
|
|
288
|
+
const segments = [];
|
|
289
|
+
let currentIndex = 0;
|
|
290
|
+
for (const match of content.matchAll(LINK_PATTERN.bareUrl)) {
|
|
291
|
+
const matchedUrl = match[0];
|
|
292
|
+
const { trailingText, urlText } = splitBareUrlMatch(matchedUrl);
|
|
293
|
+
const matchedIndex = match.index ?? 0;
|
|
294
|
+
const isEmailDomain = isBareUrlMatchInsideEmail({
|
|
295
|
+
content,
|
|
296
|
+
matchedIndex,
|
|
297
|
+
matchedUrl
|
|
298
|
+
});
|
|
299
|
+
const safeUrl = normalizeAutolinkUrl(urlText);
|
|
300
|
+
if (matchedIndex > currentIndex) {
|
|
301
|
+
segments.push(createTextSegment(content.slice(currentIndex, matchedIndex)));
|
|
302
|
+
}
|
|
303
|
+
segments.push(safeUrl && !isEmailDomain ? createLinkSegment(urlText, safeUrl) : createTextSegment(urlText));
|
|
304
|
+
if (trailingText) {
|
|
305
|
+
segments.push(createTextSegment(trailingText));
|
|
306
|
+
}
|
|
307
|
+
currentIndex = matchedIndex + matchedUrl.length;
|
|
308
|
+
}
|
|
309
|
+
if (currentIndex < content.length) {
|
|
310
|
+
segments.push(createTextSegment(content.slice(currentIndex)));
|
|
311
|
+
}
|
|
312
|
+
return segments;
|
|
313
|
+
}
|
|
314
|
+
/** Parses plain text into editor preview segments, auto-linking bare URLs. */
|
|
315
|
+
export function parseBareUrlPreviewSegments(content, offset, suppressedLinks) {
|
|
316
|
+
const segments = [];
|
|
317
|
+
let currentIndex = 0;
|
|
318
|
+
for (const match of content.matchAll(LINK_PATTERN.bareUrl)) {
|
|
319
|
+
const matchedUrl = match[0];
|
|
320
|
+
const { trailingText, urlText } = splitBareUrlMatch(matchedUrl);
|
|
321
|
+
const matchedIndex = match.index ?? 0;
|
|
322
|
+
const absoluteStart = offset + matchedIndex;
|
|
323
|
+
const absoluteEnd = absoluteStart + urlText.length;
|
|
324
|
+
const isEmailDomain = isBareUrlMatchInsideEmail({
|
|
325
|
+
content,
|
|
326
|
+
matchedIndex,
|
|
327
|
+
matchedUrl
|
|
328
|
+
});
|
|
329
|
+
const safeUrl = normalizeAutolinkUrl(urlText);
|
|
330
|
+
const isSuppressed = suppressedLinks.some((suppressedLink) => rangesOverlap(suppressedLink, {
|
|
331
|
+
end: absoluteEnd,
|
|
332
|
+
start: absoluteStart
|
|
333
|
+
}));
|
|
334
|
+
if (matchedIndex > currentIndex) {
|
|
335
|
+
segments.push(createTextPreviewSegment(content.slice(currentIndex, matchedIndex)));
|
|
336
|
+
}
|
|
337
|
+
segments.push(safeUrl && !isEmailDomain && !isSuppressed ? createLinkPreviewSegment({
|
|
338
|
+
end: absoluteEnd,
|
|
339
|
+
source: RICH_PREVIEW_LINK_SOURCE.automatic,
|
|
340
|
+
start: absoluteStart,
|
|
341
|
+
text: urlText,
|
|
342
|
+
url: safeUrl
|
|
343
|
+
}) : createTextPreviewSegment(urlText));
|
|
344
|
+
if (trailingText) {
|
|
345
|
+
segments.push(createTextPreviewSegment(trailingText));
|
|
346
|
+
}
|
|
347
|
+
currentIndex = matchedIndex + matchedUrl.length;
|
|
348
|
+
}
|
|
349
|
+
if (currentIndex < content.length) {
|
|
350
|
+
segments.push(createTextPreviewSegment(content.slice(currentIndex)));
|
|
351
|
+
}
|
|
352
|
+
return segments;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Parses the editor's plain-text content plus its tracked links into preview
|
|
356
|
+
* segments, layering explicit links over auto-detected bare URLs.
|
|
357
|
+
*/
|
|
358
|
+
export function parsePreviewSegments(content, links) {
|
|
359
|
+
const segments = [];
|
|
360
|
+
const visibleLinks = links.filter((link) => link.end > link.start).sort((firstLink, secondLink) => firstLink.start - secondLink.start);
|
|
361
|
+
const suppressedLinks = visibleLinks.filter((link) => link.kind === RICH_LINK_KIND.suppressed);
|
|
362
|
+
let currentIndex = 0;
|
|
363
|
+
visibleLinks.forEach((link) => {
|
|
364
|
+
if (link.start < currentIndex) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (link.start > currentIndex) {
|
|
368
|
+
segments.push(...parseBareUrlPreviewSegments(content.slice(currentIndex, link.start), currentIndex, suppressedLinks));
|
|
369
|
+
}
|
|
370
|
+
const text = content.slice(link.start, link.end);
|
|
371
|
+
segments.push(link.kind === RICH_LINK_KIND.explicit ? createLinkPreviewSegment({
|
|
372
|
+
end: link.end,
|
|
373
|
+
id: link.id,
|
|
374
|
+
source: RICH_PREVIEW_LINK_SOURCE.explicit,
|
|
375
|
+
start: link.start,
|
|
376
|
+
text,
|
|
377
|
+
url: link.url
|
|
378
|
+
}) : createTextPreviewSegment(text));
|
|
379
|
+
currentIndex = link.end;
|
|
380
|
+
});
|
|
381
|
+
if (currentIndex < content.length) {
|
|
382
|
+
segments.push(...parseBareUrlPreviewSegments(content.slice(currentIndex), currentIndex, suppressedLinks));
|
|
383
|
+
}
|
|
384
|
+
return segments;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Whether a markdown link whose URL is not a safe web link is a genuine
|
|
388
|
+
* suppressed autolink (`[some-url.com](#)`) produced by the editor, rather than
|
|
389
|
+
* literal text that merely looks like a markdown link (`[PDF](pendiente)`).
|
|
390
|
+
*
|
|
391
|
+
* Suppression only applies when the label itself is a normalizable URL, which
|
|
392
|
+
* mirrors `serializeEditorContent`. Any other `[text](url)` whose URL cannot be
|
|
393
|
+
* normalized is treated as authored plain text and preserved verbatim.
|
|
394
|
+
*/
|
|
395
|
+
export function isSuppressedAutolinkMarkdown(linkText, linkUrl) {
|
|
396
|
+
return linkUrl === LINK_MARKDOWN_FORMAT.suppressedUrl && normalizeMarkdownUrl(linkText) !== null;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Parses persisted content (markdown links plus bare URLs) into rendered
|
|
400
|
+
* segments for read-only display.
|
|
401
|
+
*/
|
|
402
|
+
export function parseRichTextSegments(content) {
|
|
403
|
+
const segments = [];
|
|
404
|
+
let currentIndex = 0;
|
|
405
|
+
for (const match of content.matchAll(LINK_PATTERN.markdown)) {
|
|
406
|
+
const matchedMarkdown = match[0];
|
|
407
|
+
const linkText = unescapeMarkdownLinkText(match[LINK_MARKDOWN_MATCH_GROUP.text] ?? "");
|
|
408
|
+
const linkUrl = match[LINK_MARKDOWN_MATCH_GROUP.url] ?? "";
|
|
409
|
+
const matchedIndex = match.index ?? 0;
|
|
410
|
+
const safeUrl = normalizeMarkdownUrl(linkUrl);
|
|
411
|
+
if (matchedIndex > currentIndex) {
|
|
412
|
+
segments.push(...parseBareUrlSegments(content.slice(currentIndex, matchedIndex)));
|
|
413
|
+
}
|
|
414
|
+
segments.push(safeUrl ? createLinkSegment(linkText, safeUrl) : isSuppressedAutolinkMarkdown(linkText, linkUrl) ? createTextSegment(linkText) : createTextSegment(matchedMarkdown));
|
|
415
|
+
currentIndex = matchedIndex + matchedMarkdown.length;
|
|
416
|
+
}
|
|
417
|
+
if (currentIndex < content.length) {
|
|
418
|
+
segments.push(...parseBareUrlSegments(content.slice(currentIndex)));
|
|
419
|
+
}
|
|
420
|
+
return segments;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Converts persisted markdown into the editor's plain-text content plus the
|
|
424
|
+
* tracked links (explicit and suppressed) needed to re-render it for editing.
|
|
425
|
+
*/
|
|
426
|
+
export function deserializeMarkdownForEditor(content) {
|
|
427
|
+
const links = [];
|
|
428
|
+
let displayContent = "";
|
|
429
|
+
let currentIndex = 0;
|
|
430
|
+
for (const match of content.matchAll(LINK_PATTERN.markdown)) {
|
|
431
|
+
const matchedMarkdown = match[0];
|
|
432
|
+
const linkText = unescapeMarkdownLinkText(match[LINK_MARKDOWN_MATCH_GROUP.text] ?? "");
|
|
433
|
+
const linkUrl = match[LINK_MARKDOWN_MATCH_GROUP.url] ?? "";
|
|
434
|
+
const matchedIndex = match.index ?? 0;
|
|
435
|
+
const safeUrl = normalizeMarkdownUrl(linkUrl);
|
|
436
|
+
displayContent += content.slice(currentIndex, matchedIndex);
|
|
437
|
+
if (safeUrl) {
|
|
438
|
+
const linkStart = displayContent.length;
|
|
439
|
+
displayContent += linkText;
|
|
440
|
+
links.push({
|
|
441
|
+
end: displayContent.length,
|
|
442
|
+
id: crypto.randomUUID(),
|
|
443
|
+
isSynced: isLinkSynchronized(linkText, safeUrl),
|
|
444
|
+
kind: RICH_LINK_KIND.explicit,
|
|
445
|
+
start: linkStart,
|
|
446
|
+
url: safeUrl
|
|
447
|
+
});
|
|
448
|
+
} else if (isSuppressedAutolinkMarkdown(linkText, linkUrl)) {
|
|
449
|
+
const linkStart = displayContent.length;
|
|
450
|
+
displayContent += linkText;
|
|
451
|
+
links.push({
|
|
452
|
+
end: displayContent.length,
|
|
453
|
+
id: crypto.randomUUID(),
|
|
454
|
+
kind: RICH_LINK_KIND.suppressed,
|
|
455
|
+
start: linkStart
|
|
456
|
+
});
|
|
457
|
+
} else {
|
|
458
|
+
displayContent += matchedMarkdown;
|
|
459
|
+
}
|
|
460
|
+
currentIndex = matchedIndex + matchedMarkdown.length;
|
|
461
|
+
}
|
|
462
|
+
displayContent += content.slice(currentIndex);
|
|
463
|
+
return {
|
|
464
|
+
content: displayContent,
|
|
465
|
+
links
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Serializes the editor's plain-text content plus tracked links back into
|
|
470
|
+
* persisted markdown, emitting `[text](url)` for explicit links and
|
|
471
|
+
* `[text](#)` for suppressed auto-detected URLs.
|
|
472
|
+
*/
|
|
473
|
+
export function serializeEditorContent(content, links) {
|
|
474
|
+
const persistedLinks = links.filter((link) => link.end > link.start).sort((firstLink, secondLink) => firstLink.start - secondLink.start);
|
|
475
|
+
let serializedContent = "";
|
|
476
|
+
let currentIndex = 0;
|
|
477
|
+
persistedLinks.forEach((link) => {
|
|
478
|
+
if (link.start < currentIndex) {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
serializedContent += content.slice(currentIndex, link.start);
|
|
482
|
+
serializedContent += buildMarkdownLinkFromSelection(content.slice(link.start, link.end), link.kind === RICH_LINK_KIND.explicit ? link.url : LINK_MARKDOWN_FORMAT.suppressedUrl);
|
|
483
|
+
currentIndex = link.end;
|
|
484
|
+
});
|
|
485
|
+
return serializedContent + content.slice(currentIndex);
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Computes the minimal changed range between two strings: the common prefix
|
|
489
|
+
* end (`start`), the changed-region ends in each string, and the length delta.
|
|
490
|
+
*/
|
|
491
|
+
export function getTextDiff(input) {
|
|
492
|
+
const minLength = Math.min(input.previousText.length, input.nextText.length);
|
|
493
|
+
let start = 0;
|
|
494
|
+
while (start < minLength && input.previousText[start] === input.nextText[start]) {
|
|
495
|
+
start += 1;
|
|
496
|
+
}
|
|
497
|
+
if (start === minLength && input.previousText.length === input.nextText.length) {
|
|
498
|
+
return {
|
|
499
|
+
delta: 0,
|
|
500
|
+
endInNextText: start,
|
|
501
|
+
endInPreviousText: start,
|
|
502
|
+
start: TEXT_DIFF_FALLBACK_INDEX.notFound
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
let previousEnd = input.previousText.length;
|
|
506
|
+
let nextEnd = input.nextText.length;
|
|
507
|
+
while (previousEnd > start && nextEnd > start && input.previousText[previousEnd - 1] === input.nextText[nextEnd - 1]) {
|
|
508
|
+
previousEnd -= 1;
|
|
509
|
+
nextEnd -= 1;
|
|
510
|
+
}
|
|
511
|
+
return {
|
|
512
|
+
delta: nextEnd - previousEnd,
|
|
513
|
+
endInNextText: nextEnd,
|
|
514
|
+
endInPreviousText: previousEnd,
|
|
515
|
+
start
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Adjusts tracked link ranges after the editor's plain text changed, shifting,
|
|
520
|
+
* shrinking, or dropping links and keeping synchronized links in step with
|
|
521
|
+
* their visible text.
|
|
522
|
+
*
|
|
523
|
+
* A suppressed autolink is dropped once its visible text is no longer a
|
|
524
|
+
* normalizable URL, mirroring `isSuppressedAutolinkMarkdown`: with nothing left
|
|
525
|
+
* to suppress, the edited text serializes as plain text instead of `[text](#)`,
|
|
526
|
+
* which would otherwise be rendered back as literal markdown.
|
|
527
|
+
*/
|
|
528
|
+
export function getLinksAfterTextChange(input) {
|
|
529
|
+
const diff = getTextDiff({
|
|
530
|
+
nextText: input.nextText,
|
|
531
|
+
previousText: input.previousText
|
|
532
|
+
});
|
|
533
|
+
if (diff.start === TEXT_DIFF_FALLBACK_INDEX.notFound) {
|
|
534
|
+
return input.links;
|
|
535
|
+
}
|
|
536
|
+
const insertedText = input.nextText.slice(diff.start, diff.endInNextText);
|
|
537
|
+
const deletedText = input.previousText.slice(diff.start, diff.endInPreviousText);
|
|
538
|
+
return input.links.map((link) => {
|
|
539
|
+
if (diff.endInPreviousText <= link.start) {
|
|
540
|
+
return {
|
|
541
|
+
...link,
|
|
542
|
+
end: link.end + diff.delta,
|
|
543
|
+
start: link.start + diff.delta
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
if (diff.start > link.end) {
|
|
547
|
+
return link;
|
|
548
|
+
}
|
|
549
|
+
if (diff.start === link.end && isWhitespaceOnly(insertedText)) {
|
|
550
|
+
return link;
|
|
551
|
+
}
|
|
552
|
+
// A change that begins exactly at the link's exclusive end boundary and
|
|
553
|
+
// removes text only deletes characters past the link, so the link's own
|
|
554
|
+
// text is untouched and must stay intact. This holds regardless of
|
|
555
|
+
// whether the removed text was whitespace; gating it on whitespace would
|
|
556
|
+
// wrongly shrink the link when a non-whitespace character right after it
|
|
557
|
+
// (for example deleting "X" from "abcX" where only "abc" is linked) is
|
|
558
|
+
// removed. Pure insertions still fall through so non-whitespace typed
|
|
559
|
+
// against the boundary can extend the link.
|
|
560
|
+
if (diff.start === link.end && deletedText.length > 0) {
|
|
561
|
+
return link;
|
|
562
|
+
}
|
|
563
|
+
// When the change starts before the link's start, it deleted part of the
|
|
564
|
+
// link's prefix, so the surviving link text now begins where the changed
|
|
565
|
+
// region ends in the new text. Clamping start there (instead of leaving
|
|
566
|
+
// the stale offset) keeps the link anchored to the remaining text and
|
|
567
|
+
// excludes any replacement text from the link.
|
|
568
|
+
const nextStart = diff.start < link.start ? diff.endInNextText : link.start;
|
|
569
|
+
// A deletion can start inside the link and run past its end. Only the
|
|
570
|
+
// removed characters that fell within the link should shrink it; the ones
|
|
571
|
+
// deleted beyond `link.end` were never part of the link, so adding them
|
|
572
|
+
// back to `diff.delta` keeps them from being subtracted from `link.end`.
|
|
573
|
+
const deletedLengthPastLinkEnd = Math.max(0, diff.endInPreviousText - link.end);
|
|
574
|
+
return {
|
|
575
|
+
...link,
|
|
576
|
+
end: Math.max(nextStart, link.end + diff.delta + deletedLengthPastLinkEnd),
|
|
577
|
+
start: nextStart
|
|
578
|
+
};
|
|
579
|
+
}).filter((link) => link.end > link.start).filter((link) => link.kind !== RICH_LINK_KIND.suppressed || normalizeMarkdownUrl(input.nextText.slice(link.start, link.end)) !== null).map((link) => {
|
|
580
|
+
if (link.kind !== RICH_LINK_KIND.explicit) {
|
|
581
|
+
return link;
|
|
582
|
+
}
|
|
583
|
+
const linkText = input.nextText.slice(link.start, link.end);
|
|
584
|
+
const isSynced = link.isSynced || isLinkSynchronized(linkText, link.url);
|
|
585
|
+
const syncedUrl = isSynced ? getSynchronizedLinkUrl(linkText, link.url) : null;
|
|
586
|
+
return {
|
|
587
|
+
...link,
|
|
588
|
+
isSynced: Boolean(syncedUrl),
|
|
589
|
+
url: syncedUrl ?? link.url
|
|
590
|
+
};
|
|
591
|
+
});
|
|
592
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Block-level markdown subset for long-form content such as descriptions and stories.
|
|
3
|
+
*
|
|
4
|
+
* It builds on top of the shared link parser (`parseRichTextSegments`) without
|
|
5
|
+
* touching it, adding paragraphs, unordered lists (`- item` / `* item`) and
|
|
6
|
+
* inline bold (`**text**`). Everything else stays plain text, so the renderer
|
|
7
|
+
* never interprets raw HTML.
|
|
8
|
+
*/
|
|
9
|
+
export declare const RICH_MARKDOWN_BLOCK_TYPE: {
|
|
10
|
+
readonly list: "list";
|
|
11
|
+
readonly paragraph: "paragraph";
|
|
12
|
+
};
|
|
13
|
+
export declare const RICH_MARKDOWN_INLINE_TYPE: {
|
|
14
|
+
readonly bold: "bold";
|
|
15
|
+
readonly link: "link";
|
|
16
|
+
readonly text: "text";
|
|
17
|
+
};
|
|
18
|
+
export type RichMarkdownInlineSegment = {
|
|
19
|
+
text: string;
|
|
20
|
+
type: typeof RICH_MARKDOWN_INLINE_TYPE.bold;
|
|
21
|
+
} | {
|
|
22
|
+
text: string;
|
|
23
|
+
type: typeof RICH_MARKDOWN_INLINE_TYPE.text;
|
|
24
|
+
} | {
|
|
25
|
+
text: string;
|
|
26
|
+
type: typeof RICH_MARKDOWN_INLINE_TYPE.link;
|
|
27
|
+
url: string;
|
|
28
|
+
};
|
|
29
|
+
export type RichMarkdownBlock = {
|
|
30
|
+
segments: RichMarkdownInlineSegment[];
|
|
31
|
+
type: typeof RICH_MARKDOWN_BLOCK_TYPE.paragraph;
|
|
32
|
+
} | {
|
|
33
|
+
items: RichMarkdownInlineSegment[][];
|
|
34
|
+
type: typeof RICH_MARKDOWN_BLOCK_TYPE.list;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Flattens the parsed content into plain text (formatting stripped) and trims it
|
|
38
|
+
* to `maxLength`, for SEO descriptions and previews.
|
|
39
|
+
*/
|
|
40
|
+
export declare function buildRichMarkdownExcerpt(content: string, maxLength: number): string;
|
|
41
|
+
export declare function parseRichMarkdownBlocks(content: string): RichMarkdownBlock[];
|