@37signals/lexxy 0.1.9-beta → 0.1.11-beta
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/README.md +78 -3
- package/dist/lexxy.esm.js +405 -116
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -341,9 +341,84 @@ The sandbox app is available at http://localhost:3000. There is also a CRUD exam
|
|
|
341
341
|
|
|
342
342
|
## Events
|
|
343
343
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
344
|
+
Lexxy fires a handful of custom events that you can hook into.
|
|
345
|
+
Each event is dispatched on the `<lexxy-editor>` element.
|
|
346
|
+
|
|
347
|
+
### `lexxy:initialize`
|
|
348
|
+
|
|
349
|
+
Fired when the `<lexxy-editor>` element is attached to the DOM and ready for use.
|
|
350
|
+
This is useful for one-time setup.
|
|
351
|
+
|
|
352
|
+
### `lexxy:change`
|
|
353
|
+
|
|
354
|
+
Fired whenever the editor content changes.
|
|
355
|
+
You can use this to sync the editor state with your application.
|
|
356
|
+
|
|
357
|
+
### `lexxy:file-accept`
|
|
358
|
+
|
|
359
|
+
Fired when a file is dropped or inserted into the editor.
|
|
360
|
+
|
|
361
|
+
- Access the file via `event.detail.file`.
|
|
362
|
+
- Call `event.preventDefault()` to cancel the upload and prevent attaching the file.
|
|
363
|
+
|
|
364
|
+
### `lexxy:insert-link`
|
|
365
|
+
|
|
366
|
+
Fired when a plain text link is pasted into the editor.
|
|
367
|
+
Access the link’s URL via `event.detail.url`.
|
|
368
|
+
|
|
369
|
+
You also get a handful of callback helpers on `event.detail`:
|
|
370
|
+
|
|
371
|
+
- **`replaceLinkWith(html, options)`** – replace the pasted link with your own HTML.
|
|
372
|
+
- **`insertBelowLink(html, options)`** – insert custom HTML below the link.
|
|
373
|
+
- **Attachment rendering** – pass `{ attachment: true }` in `options` to render as non-editable content,
|
|
374
|
+
or `{ attachment: { sgid: "your-sgid-here" } }` to provide a custom SGID.
|
|
375
|
+
|
|
376
|
+
#### Example: Link Unfurling with Stimulus
|
|
377
|
+
|
|
378
|
+
When a user pastes a link, you may want to turn it into a preview or embed.
|
|
379
|
+
Here’s a Stimulus controller that sends the URL to your app, retrieves metadata,
|
|
380
|
+
and replaces the plain text link with a richer version:
|
|
381
|
+
|
|
382
|
+
```javascript
|
|
383
|
+
// app/javascript/controllers/link_unfurl_controller.js
|
|
384
|
+
import { Controller } from "@hotwired/stimulus"
|
|
385
|
+
import { post } from "@rails/request.js"
|
|
386
|
+
|
|
387
|
+
export default class extends Controller {
|
|
388
|
+
static values = {
|
|
389
|
+
url: String, // endpoint that handles unfurling
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
unfurl(event) {
|
|
393
|
+
this.#unfurlLink(event.detail.url, event.detail)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async #unfurlLink(url, callbacks) {
|
|
397
|
+
const { response } = await post(this.urlValue, {
|
|
398
|
+
body: JSON.stringify({ url }),
|
|
399
|
+
headers: {
|
|
400
|
+
"Content-Type": "application/json",
|
|
401
|
+
"Accept": "application/json"
|
|
402
|
+
}
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
const metadata = await response.json()
|
|
406
|
+
this.#insertUnfurledLink(metadata, callbacks)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
#insertUnfurledLink(metadata, callbacks) {
|
|
410
|
+
// Replace the pasted link with your custom HTML
|
|
411
|
+
callbacks.replaceLinkWith(this.#renderUnfurledLinkHTML(metadata))
|
|
412
|
+
|
|
413
|
+
// Or, insert below the link as an attachment:
|
|
414
|
+
// callbacks.insertBelowLink(this.#renderUnfurledLinkHTML(metadata), { attachment: true })
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
#renderUnfurledLinkHTML(link) {
|
|
418
|
+
return `<a href="${link.canonical_url}">${link.title}</a>`
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
```
|
|
347
422
|
|
|
348
423
|
## Contributing
|
|
349
424
|
|
package/dist/lexxy.esm.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import DOMPurify from 'dompurify';
|
|
2
|
-
import { $getSelection, $isRangeSelection, $isTextNode, DecoratorNode, $getNodeByKey, HISTORY_MERGE_TAG, FORMAT_TEXT_COMMAND, PASTE_COMMAND, COMMAND_PRIORITY_LOW, $isNodeSelection, $getRoot, $isLineBreakNode, $isElementNode, KEY_ARROW_LEFT_COMMAND,
|
|
2
|
+
import { $getSelection, $isRangeSelection, $isTextNode, DecoratorNode, $getNodeByKey, HISTORY_MERGE_TAG, FORMAT_TEXT_COMMAND, PASTE_COMMAND, COMMAND_PRIORITY_LOW, $isNodeSelection, $getRoot, $isLineBreakNode, $isElementNode, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_DELETE_COMMAND, KEY_BACKSPACE_COMMAND, SELECTION_CHANGE_COMMAND, $createNodeSelection, $setSelection, $createParagraphNode, $createTextNode, $isParagraphNode, $insertNodes, $createLineBreakNode, CLEAR_HISTORY_COMMAND, $addUpdateTag, SKIP_DOM_SELECTION_TAG, createEditor, KEY_ENTER_COMMAND, COMMAND_PRIORITY_NORMAL, COMMAND_PRIORITY_HIGH, KEY_TAB_COMMAND, KEY_SPACE_COMMAND } from 'lexical';
|
|
3
3
|
import { $isListNode, $isListItemNode, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, ListNode, ListItemNode, registerList } from '@lexical/list';
|
|
4
4
|
import { $isQuoteNode, $isHeadingNode, $createQuoteNode, $createHeadingNode, QuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
|
|
5
5
|
import { $isCodeNode, $isCodeHighlightNode, CodeNode, CodeHighlightNode, registerCodeHighlighting, CODE_LANGUAGE_FRIENDLY_NAME_MAP, normalizeCodeLang } from '@lexical/code';
|
|
6
|
-
import { $isLinkNode, $toggleLink, LinkNode, AutoLinkNode } from '@lexical/link';
|
|
6
|
+
import { $isLinkNode, $toggleLink, $createLinkNode, LinkNode, AutoLinkNode } from '@lexical/link';
|
|
7
7
|
import { $generateNodesFromDOM, $generateHtmlFromNodes } from '@lexical/html';
|
|
8
8
|
import { registerMarkdownShortcuts, TRANSFORMERS } from '@lexical/markdown';
|
|
9
9
|
import { registerHistory, createEmptyHistoryState } from '@lexical/history';
|
|
@@ -42,6 +42,17 @@ function getListType(node) {
|
|
|
42
42
|
return null
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function isPrintableCharacter(event) {
|
|
46
|
+
// Ignore if modifier keys are pressed (except Shift for uppercase)
|
|
47
|
+
if (event.ctrlKey || event.metaKey || event.altKey) return false
|
|
48
|
+
|
|
49
|
+
// Ignore special keys
|
|
50
|
+
if (event.key.length > 1 && event.key !== 'Enter' && event.key !== 'Space') return false
|
|
51
|
+
|
|
52
|
+
// Accept single character keys (letters, numbers, punctuation)
|
|
53
|
+
return event.key.length === 1
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
class LexicalToolbarElement extends HTMLElement {
|
|
46
57
|
constructor() {
|
|
47
58
|
super();
|
|
@@ -881,6 +892,8 @@ class CommandDispatcher {
|
|
|
881
892
|
|
|
882
893
|
dispatchInsertUnorderedList() {
|
|
883
894
|
const selection = $getSelection();
|
|
895
|
+
if (!selection) return;
|
|
896
|
+
|
|
884
897
|
const anchorNode = selection.anchor.getNode();
|
|
885
898
|
|
|
886
899
|
if (this.selection.isInsideList && anchorNode && getListType(anchorNode) === "bullet") {
|
|
@@ -892,6 +905,8 @@ class CommandDispatcher {
|
|
|
892
905
|
|
|
893
906
|
dispatchInsertOrderedList() {
|
|
894
907
|
const selection = $getSelection();
|
|
908
|
+
if (!selection) return;
|
|
909
|
+
|
|
895
910
|
const anchorNode = selection.anchor.getNode();
|
|
896
911
|
|
|
897
912
|
if (this.selection.isInsideList && anchorNode && getListType(anchorNode) === "number") {
|
|
@@ -1064,11 +1079,14 @@ function nextFrame() {
|
|
|
1064
1079
|
class Selection {
|
|
1065
1080
|
constructor(editorElement) {
|
|
1066
1081
|
this.editorElement = editorElement;
|
|
1082
|
+
this.editorContentElement = editorElement.editorContentElement;
|
|
1067
1083
|
this.editor = this.editorElement.editor;
|
|
1068
1084
|
this.previouslySelectedKeys = new Set();
|
|
1069
1085
|
|
|
1070
1086
|
this.#listenForNodeSelections();
|
|
1071
1087
|
this.#processSelectionChangeCommands();
|
|
1088
|
+
this.#handleInputWhenDecoratorNodesSelected();
|
|
1089
|
+
this.#containEditorFocus();
|
|
1072
1090
|
}
|
|
1073
1091
|
|
|
1074
1092
|
clear() {
|
|
@@ -1162,6 +1180,21 @@ class Selection {
|
|
|
1162
1180
|
return this.#findNextSiblingUp(anchorNode)
|
|
1163
1181
|
}
|
|
1164
1182
|
|
|
1183
|
+
get topLevelNodeAfterCursor() {
|
|
1184
|
+
const { anchorNode, offset } = this.#getCollapsedSelectionData();
|
|
1185
|
+
if (!anchorNode) return null
|
|
1186
|
+
|
|
1187
|
+
if ($isTextNode(anchorNode)) {
|
|
1188
|
+
return this.#getNextNodeFromTextEnd(anchorNode)
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
if ($isElementNode(anchorNode)) {
|
|
1192
|
+
return this.#getNodeAfterElementNode(anchorNode, offset)
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
return this.#findNextSiblingUp(anchorNode)
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1165
1198
|
get nodeBeforeCursor() {
|
|
1166
1199
|
const { anchorNode, offset } = this.#getCollapsedSelectionData();
|
|
1167
1200
|
if (!anchorNode) return null
|
|
@@ -1177,6 +1210,21 @@ class Selection {
|
|
|
1177
1210
|
return this.#findPreviousSiblingUp(anchorNode)
|
|
1178
1211
|
}
|
|
1179
1212
|
|
|
1213
|
+
get topLevelNodeBeforeCursor() {
|
|
1214
|
+
const { anchorNode, offset } = this.#getCollapsedSelectionData();
|
|
1215
|
+
if (!anchorNode) return null
|
|
1216
|
+
|
|
1217
|
+
if ($isTextNode(anchorNode)) {
|
|
1218
|
+
return this.#getPreviousNodeFromTextStart(anchorNode)
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
if ($isElementNode(anchorNode)) {
|
|
1222
|
+
return this.#getNodeBeforeElementNode(anchorNode, offset)
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
return this.#findPreviousSiblingUp(anchorNode)
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1180
1228
|
get #contents() {
|
|
1181
1229
|
return this.editorElement.contents
|
|
1182
1230
|
}
|
|
@@ -1197,9 +1245,9 @@ class Selection {
|
|
|
1197
1245
|
|
|
1198
1246
|
#processSelectionChangeCommands() {
|
|
1199
1247
|
this.editor.registerCommand(KEY_ARROW_LEFT_COMMAND, this.#selectPreviousNode.bind(this), COMMAND_PRIORITY_LOW);
|
|
1200
|
-
this.editor.registerCommand(KEY_ARROW_UP_COMMAND, this.#selectPreviousNode.bind(this), COMMAND_PRIORITY_LOW);
|
|
1201
1248
|
this.editor.registerCommand(KEY_ARROW_RIGHT_COMMAND, this.#selectNextNode.bind(this), COMMAND_PRIORITY_LOW);
|
|
1202
|
-
this.editor.registerCommand(
|
|
1249
|
+
this.editor.registerCommand(KEY_ARROW_UP_COMMAND, this.#selectPreviousTopLevelNode.bind(this), COMMAND_PRIORITY_LOW);
|
|
1250
|
+
this.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, this.#selectNextTopLevelNode.bind(this), COMMAND_PRIORITY_LOW);
|
|
1203
1251
|
|
|
1204
1252
|
this.editor.registerCommand(KEY_DELETE_COMMAND, this.#deleteSelectedOrNext.bind(this), COMMAND_PRIORITY_LOW);
|
|
1205
1253
|
this.editor.registerCommand(KEY_BACKSPACE_COMMAND, this.#deletePreviousOrNext.bind(this), COMMAND_PRIORITY_LOW);
|
|
@@ -1230,6 +1278,93 @@ class Selection {
|
|
|
1230
1278
|
});
|
|
1231
1279
|
}
|
|
1232
1280
|
|
|
1281
|
+
// In Safari, when the only node in the document is an attachment, it won't let you enter text
|
|
1282
|
+
// before/below it. There is probably a better fix here, but this workaround solves the problem until
|
|
1283
|
+
// we find it.
|
|
1284
|
+
#handleInputWhenDecoratorNodesSelected() {
|
|
1285
|
+
this.editor.getRootElement().addEventListener("keydown", (event) => {
|
|
1286
|
+
if (isPrintableCharacter(event)) {
|
|
1287
|
+
this.editor.update(() => {
|
|
1288
|
+
const selection = $getSelection();
|
|
1289
|
+
|
|
1290
|
+
if ($isRangeSelection(selection) && selection.isCollapsed()) {
|
|
1291
|
+
const anchorNode = selection.anchor.getNode();
|
|
1292
|
+
const offset = selection.anchor.offset;
|
|
1293
|
+
|
|
1294
|
+
const nodeBefore = this.#getNodeBeforePosition(anchorNode, offset);
|
|
1295
|
+
const nodeAfter = this.#getNodeAfterPosition(anchorNode, offset);
|
|
1296
|
+
|
|
1297
|
+
if (nodeBefore instanceof DecoratorNode && !nodeBefore.isInline()) {
|
|
1298
|
+
event.preventDefault();
|
|
1299
|
+
this.#contents.createParagraphAfterNode(nodeBefore, event.key);
|
|
1300
|
+
return
|
|
1301
|
+
} else if (nodeAfter instanceof DecoratorNode && !nodeAfter.isInline()) {
|
|
1302
|
+
event.preventDefault();
|
|
1303
|
+
this.#contents.createParagraphBeforeNode(nodeAfter, event.key);
|
|
1304
|
+
return
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
}, true);
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
#getNodeBeforePosition(node, offset) {
|
|
1313
|
+
if ($isTextNode(node) && offset === 0) {
|
|
1314
|
+
return node.getPreviousSibling()
|
|
1315
|
+
}
|
|
1316
|
+
if ($isElementNode(node) && offset > 0) {
|
|
1317
|
+
return node.getChildAtIndex(offset - 1)
|
|
1318
|
+
}
|
|
1319
|
+
return null
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
#getNodeAfterPosition(node, offset) {
|
|
1323
|
+
if ($isTextNode(node) && offset === node.getTextContentSize()) {
|
|
1324
|
+
return node.getNextSibling()
|
|
1325
|
+
}
|
|
1326
|
+
if ($isElementNode(node)) {
|
|
1327
|
+
return node.getChildAtIndex(offset)
|
|
1328
|
+
}
|
|
1329
|
+
return null
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
#containEditorFocus() {
|
|
1333
|
+
// Workaround for a bizarre Chrome bug where the cursor abandons the editor to focus on not-focusable elements
|
|
1334
|
+
// above when navigating UP/DOWN when Lexical shows its fake cursor on custom decorator nodes.
|
|
1335
|
+
this.editorContentElement.addEventListener("keydown", (event) => {
|
|
1336
|
+
if (event.key === "ArrowUp") {
|
|
1337
|
+
const lexicalCursor = this.editor.getRootElement().querySelector('[data-lexical-cursor]');
|
|
1338
|
+
|
|
1339
|
+
if (lexicalCursor) {
|
|
1340
|
+
let currentElement = lexicalCursor.previousElementSibling;
|
|
1341
|
+
while (currentElement && currentElement.hasAttribute('data-lexical-cursor')) {
|
|
1342
|
+
currentElement = currentElement.previousElementSibling;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
if (!currentElement) {
|
|
1346
|
+
event.preventDefault();
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
if (event.key === "ArrowDown") {
|
|
1352
|
+
const lexicalCursor = this.editor.getRootElement().querySelector('[data-lexical-cursor]');
|
|
1353
|
+
|
|
1354
|
+
if (lexicalCursor) {
|
|
1355
|
+
let currentElement = lexicalCursor.nextElementSibling;
|
|
1356
|
+
while (currentElement && currentElement.hasAttribute('data-lexical-cursor')) {
|
|
1357
|
+
currentElement = currentElement.nextElementSibling;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
if (!currentElement) {
|
|
1361
|
+
event.preventDefault();
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
}, true);
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1233
1368
|
#syncSelectedClasses() {
|
|
1234
1369
|
this.#clearPreviouslyHighlightedItems();
|
|
1235
1370
|
this.#highlightNewItems();
|
|
@@ -1272,6 +1407,22 @@ class Selection {
|
|
|
1272
1407
|
}
|
|
1273
1408
|
}
|
|
1274
1409
|
|
|
1410
|
+
async #selectPreviousTopLevelNode() {
|
|
1411
|
+
if (this.current) {
|
|
1412
|
+
await this.#withCurrentNode((currentNode) => currentNode.selectPrevious());
|
|
1413
|
+
} else {
|
|
1414
|
+
this.#selectInLexical(this.topLevelNodeBeforeCursor);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
async #selectNextTopLevelNode() {
|
|
1419
|
+
if (this.current) {
|
|
1420
|
+
await this.#withCurrentNode((currentNode) => currentNode.selectNext(0, 0));
|
|
1421
|
+
} else {
|
|
1422
|
+
this.#selectInLexical(this.topLevelNodeAfterCursor);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1275
1426
|
async #withCurrentNode(fn) {
|
|
1276
1427
|
await nextFrame();
|
|
1277
1428
|
if (this.current) {
|
|
@@ -1520,6 +1671,105 @@ class Selection {
|
|
|
1520
1671
|
}
|
|
1521
1672
|
}
|
|
1522
1673
|
|
|
1674
|
+
class CustomActionTextAttachmentNode extends DecoratorNode {
|
|
1675
|
+
static getType() {
|
|
1676
|
+
return "custom_action_text_attachment"
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
static clone(node) {
|
|
1680
|
+
return new CustomActionTextAttachmentNode({ ...node }, node.__key)
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
static importJSON(serializedNode) {
|
|
1684
|
+
return new CustomActionTextAttachmentNode({ ...serializedNode })
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
static importDOM() {
|
|
1688
|
+
return {
|
|
1689
|
+
"action-text-attachment": (attachment) => {
|
|
1690
|
+
const content = attachment.getAttribute("content");
|
|
1691
|
+
if (!attachment.getAttribute("content")) {
|
|
1692
|
+
return null
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
return {
|
|
1696
|
+
conversion: () => {
|
|
1697
|
+
// Preserve initial space if present since Lexical removes it
|
|
1698
|
+
const nodes = [];
|
|
1699
|
+
const previousSibling = attachment.previousSibling;
|
|
1700
|
+
if (previousSibling && previousSibling.nodeType === Node.TEXT_NODE && /\s$/.test(previousSibling.textContent)) {
|
|
1701
|
+
nodes.push($createTextNode(" "));
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
nodes.push(new CustomActionTextAttachmentNode({
|
|
1705
|
+
sgid: attachment.getAttribute("sgid"),
|
|
1706
|
+
innerHtml: JSON.parse(content),
|
|
1707
|
+
contentType: attachment.getAttribute("content-type")
|
|
1708
|
+
}));
|
|
1709
|
+
|
|
1710
|
+
nodes.push($createTextNode(" "));
|
|
1711
|
+
|
|
1712
|
+
return { node: nodes }
|
|
1713
|
+
},
|
|
1714
|
+
priority: 2
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
constructor({ sgid, contentType, innerHtml }, key) {
|
|
1721
|
+
super(key);
|
|
1722
|
+
|
|
1723
|
+
this.sgid = sgid;
|
|
1724
|
+
this.contentType = contentType || "application/vnd.actiontext.unknown";
|
|
1725
|
+
this.innerHtml = innerHtml;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
createDOM() {
|
|
1729
|
+
const figure = createElement("action-text-attachment", { "content-type": this.contentType, "data-lexxy-decorator": true });
|
|
1730
|
+
|
|
1731
|
+
figure.addEventListener("click", (event) => {
|
|
1732
|
+
dispatchCustomEvent(figure, "lexxy:internal:select-node", { key: this.getKey() });
|
|
1733
|
+
});
|
|
1734
|
+
|
|
1735
|
+
figure.insertAdjacentHTML("beforeend", this.innerHtml);
|
|
1736
|
+
|
|
1737
|
+
return figure
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
updateDOM() {
|
|
1741
|
+
return true
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
isInline() {
|
|
1745
|
+
return true
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
exportDOM() {
|
|
1749
|
+
const attachment = createElement("action-text-attachment", {
|
|
1750
|
+
sgid: this.sgid,
|
|
1751
|
+
content: JSON.stringify(this.innerHtml),
|
|
1752
|
+
"content-type": this.contentType
|
|
1753
|
+
});
|
|
1754
|
+
|
|
1755
|
+
return { element: attachment }
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
exportJSON() {
|
|
1759
|
+
return {
|
|
1760
|
+
type: "custom_action_text_attachment",
|
|
1761
|
+
version: 1,
|
|
1762
|
+
sgid: this.sgid,
|
|
1763
|
+
contentType: this.contentType,
|
|
1764
|
+
innerHtml: this.innerHtml
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
decorate() {
|
|
1769
|
+
return null
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1523
1773
|
class Contents {
|
|
1524
1774
|
constructor(editorElement) {
|
|
1525
1775
|
this.editorElement = editorElement;
|
|
@@ -1638,6 +1888,24 @@ class Contents {
|
|
|
1638
1888
|
});
|
|
1639
1889
|
}
|
|
1640
1890
|
|
|
1891
|
+
createLink(url) {
|
|
1892
|
+
let linkNodeKey = null;
|
|
1893
|
+
|
|
1894
|
+
this.editor.update(() => {
|
|
1895
|
+
const textNode = $createTextNode(url);
|
|
1896
|
+
const linkNode = $createLinkNode(url);
|
|
1897
|
+
linkNode.append(textNode);
|
|
1898
|
+
|
|
1899
|
+
const selection = $getSelection();
|
|
1900
|
+
if ($isRangeSelection(selection)) {
|
|
1901
|
+
selection.insertNodes([linkNode]);
|
|
1902
|
+
linkNodeKey = linkNode.getKey();
|
|
1903
|
+
}
|
|
1904
|
+
});
|
|
1905
|
+
|
|
1906
|
+
return linkNodeKey
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1641
1909
|
createLinkWithSelectedText(url) {
|
|
1642
1910
|
if (!this.hasSelectedText()) return
|
|
1643
1911
|
|
|
@@ -1709,6 +1977,30 @@ class Contents {
|
|
|
1709
1977
|
});
|
|
1710
1978
|
}
|
|
1711
1979
|
|
|
1980
|
+
createParagraphAfterNode(node, text) {
|
|
1981
|
+
const newParagraph = $createParagraphNode();
|
|
1982
|
+
node.insertAfter(newParagraph);
|
|
1983
|
+
newParagraph.selectStart();
|
|
1984
|
+
|
|
1985
|
+
// Insert the typed text
|
|
1986
|
+
if (text) {
|
|
1987
|
+
newParagraph.append($createTextNode(text));
|
|
1988
|
+
newParagraph.select(1, 1); // Place cursor after the text
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
createParagraphBeforeNode(node, text) {
|
|
1993
|
+
const newParagraph = $createParagraphNode();
|
|
1994
|
+
node.insertBefore(newParagraph);
|
|
1995
|
+
newParagraph.selectStart();
|
|
1996
|
+
|
|
1997
|
+
// Insert the typed text
|
|
1998
|
+
if (text) {
|
|
1999
|
+
newParagraph.append($createTextNode(text));
|
|
2000
|
+
newParagraph.select(1, 1); // Place cursor after the text
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
|
|
1712
2004
|
uploadFile(file) {
|
|
1713
2005
|
if (!this.editorElement.supportsAttachments) {
|
|
1714
2006
|
console.warn("This editor does not supports attachments (it's configured with [attachments=false])");
|
|
@@ -1743,26 +2035,76 @@ class Contents {
|
|
|
1743
2035
|
deleteSelectedNodes() {
|
|
1744
2036
|
this.editor.update(() => {
|
|
1745
2037
|
if ($isNodeSelection(this.#selection.current)) {
|
|
1746
|
-
|
|
1747
|
-
|
|
2038
|
+
const nodesToRemove = this.#selection.current.getNodes();
|
|
2039
|
+
if (nodesToRemove.length === 0) return
|
|
2040
|
+
|
|
2041
|
+
// Use splice() instead of node.remove() for proper removal and
|
|
2042
|
+
// reconciliation. Would have issues with removing unintended decorator nodes
|
|
2043
|
+
// with node.remove()
|
|
2044
|
+
nodesToRemove.forEach((node) => {
|
|
1748
2045
|
const parent = node.getParent();
|
|
2046
|
+
if (!$isElementNode(parent)) return
|
|
1749
2047
|
|
|
1750
|
-
|
|
2048
|
+
const children = parent.getChildren();
|
|
2049
|
+
const index = children.indexOf(node);
|
|
1751
2050
|
|
|
1752
|
-
if (
|
|
1753
|
-
parent.
|
|
2051
|
+
if (index >= 0) {
|
|
2052
|
+
parent.splice(index, 1, []);
|
|
1754
2053
|
}
|
|
1755
|
-
|
|
1756
|
-
nodesWereRemoved = true;
|
|
1757
2054
|
});
|
|
1758
2055
|
|
|
1759
|
-
if
|
|
1760
|
-
|
|
1761
|
-
|
|
2056
|
+
// Check if root is empty after all removals
|
|
2057
|
+
const root = $getRoot();
|
|
2058
|
+
if (root.getChildrenSize() === 0) {
|
|
2059
|
+
root.append($createParagraphNode());
|
|
2060
|
+
}
|
|
1762
2061
|
|
|
1763
|
-
|
|
2062
|
+
this.#selection.clear();
|
|
2063
|
+
this.editor.focus();
|
|
2064
|
+
|
|
2065
|
+
return true
|
|
2066
|
+
}
|
|
2067
|
+
});
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
replaceNodeWithHTML(nodeKey, html, options = {}) {
|
|
2071
|
+
this.editor.update(() => {
|
|
2072
|
+
const node = $getNodeByKey(nodeKey);
|
|
2073
|
+
if (!node) return
|
|
2074
|
+
|
|
2075
|
+
const selection = $getSelection();
|
|
2076
|
+
let wasSelected = false;
|
|
2077
|
+
|
|
2078
|
+
if ($isRangeSelection(selection)) {
|
|
2079
|
+
const selectedNodes = selection.getNodes();
|
|
2080
|
+
wasSelected = selectedNodes.includes(node) || selectedNodes.some(n => n.getParent() === node);
|
|
2081
|
+
|
|
2082
|
+
if (wasSelected) {
|
|
2083
|
+
$setSelection(null);
|
|
1764
2084
|
}
|
|
1765
2085
|
}
|
|
2086
|
+
|
|
2087
|
+
const replacementNode = options.attachment ? this.#createCustomAttachmentNodeWithHtml(html, options.attachment) : this.#createHtmlNodeWith(html);
|
|
2088
|
+
node.replace(replacementNode);
|
|
2089
|
+
|
|
2090
|
+
if (wasSelected) {
|
|
2091
|
+
replacementNode.selectEnd();
|
|
2092
|
+
}
|
|
2093
|
+
});
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
insertHTMLBelowNode(nodeKey, html, options = {}) {
|
|
2097
|
+
this.editor.update(() => {
|
|
2098
|
+
const node = $getNodeByKey(nodeKey);
|
|
2099
|
+
if (!node) return
|
|
2100
|
+
|
|
2101
|
+
let previousNode = node;
|
|
2102
|
+
try {
|
|
2103
|
+
previousNode = node.getTopLevelElementOrThrow();
|
|
2104
|
+
} catch {}
|
|
2105
|
+
|
|
2106
|
+
const newNode = options.attachment ? this.#createCustomAttachmentNodeWithHtml(html, options.attachment) : this.#createHtmlNodeWith(html);
|
|
2107
|
+
previousNode.insertAfter(newNode);
|
|
1766
2108
|
});
|
|
1767
2109
|
}
|
|
1768
2110
|
|
|
@@ -2008,6 +2350,21 @@ class Contents {
|
|
|
2008
2350
|
}
|
|
2009
2351
|
}
|
|
2010
2352
|
|
|
2353
|
+
#createCustomAttachmentNodeWithHtml(html, options = {}) {
|
|
2354
|
+
const attachmentConfig = typeof options === 'object' ? options : {};
|
|
2355
|
+
|
|
2356
|
+
return new CustomActionTextAttachmentNode({
|
|
2357
|
+
sgid: attachmentConfig.sgid || null,
|
|
2358
|
+
contentType: "text/html",
|
|
2359
|
+
innerHtml: html
|
|
2360
|
+
})
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
#createHtmlNodeWith(html) {
|
|
2364
|
+
const htmlNodes = $generateNodesFromDOM(this.editor, parseHtml(html));
|
|
2365
|
+
return htmlNodes[0] || $createParagraphNode()
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2011
2368
|
#shouldUploadFile(file) {
|
|
2012
2369
|
return dispatch(this.editorElement, 'lexxy:file-accept', { file }, true)
|
|
2013
2370
|
}
|
|
@@ -2063,12 +2420,27 @@ class Clipboard {
|
|
|
2063
2420
|
item.getAsString((text) => {
|
|
2064
2421
|
if (isUrl(text) && this.contents.hasSelectedText()) {
|
|
2065
2422
|
this.contents.createLinkWithSelectedText(text);
|
|
2423
|
+
} else if (isUrl(text)) {
|
|
2424
|
+
const nodeKey = this.contents.createLink(text);
|
|
2425
|
+
this.#dispatchLinkInsertEvent(nodeKey, { url: text });
|
|
2066
2426
|
} else {
|
|
2067
2427
|
this.#pasteMarkdown(text);
|
|
2068
2428
|
}
|
|
2069
2429
|
});
|
|
2070
2430
|
}
|
|
2071
2431
|
|
|
2432
|
+
#dispatchLinkInsertEvent(nodeKey, payload) {
|
|
2433
|
+
const linkManipulationMethods = {
|
|
2434
|
+
replaceLinkWith: (html, options) => this.contents.replaceNodeWithHTML(nodeKey, html, options),
|
|
2435
|
+
insertBelowLink: (html, options) => this.contents.insertHTMLBelowNode(nodeKey, html, options)
|
|
2436
|
+
};
|
|
2437
|
+
|
|
2438
|
+
dispatch(this.editorElement, "lexxy:insert-link", {
|
|
2439
|
+
...payload,
|
|
2440
|
+
...linkManipulationMethods
|
|
2441
|
+
});
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2072
2444
|
#pasteMarkdown(text) {
|
|
2073
2445
|
const html = marked(text);
|
|
2074
2446
|
this.contents.insertHtml(html);
|
|
@@ -2101,113 +2473,15 @@ class Clipboard {
|
|
|
2101
2473
|
}
|
|
2102
2474
|
}
|
|
2103
2475
|
|
|
2104
|
-
class CustomActionTextAttachmentNode extends DecoratorNode {
|
|
2105
|
-
static getType() {
|
|
2106
|
-
return "custom_action_text_attachment"
|
|
2107
|
-
}
|
|
2108
|
-
|
|
2109
|
-
static clone(node) {
|
|
2110
|
-
return new CustomActionTextAttachmentNode({ ...node }, node.__key)
|
|
2111
|
-
}
|
|
2112
|
-
|
|
2113
|
-
static importJSON(serializedNode) {
|
|
2114
|
-
return new CustomActionTextAttachmentNode({ ...serializedNode })
|
|
2115
|
-
}
|
|
2116
|
-
|
|
2117
|
-
static importDOM() {
|
|
2118
|
-
return {
|
|
2119
|
-
"action-text-attachment": (attachment) => {
|
|
2120
|
-
const content = attachment.getAttribute("content");
|
|
2121
|
-
if (!attachment.getAttribute("content")) {
|
|
2122
|
-
return null
|
|
2123
|
-
}
|
|
2124
|
-
|
|
2125
|
-
return {
|
|
2126
|
-
conversion: () => {
|
|
2127
|
-
// Preserve initial space if present since Lexical removes it
|
|
2128
|
-
const nodes = [];
|
|
2129
|
-
const previousSibling = attachment.previousSibling;
|
|
2130
|
-
if (previousSibling && previousSibling.nodeType === Node.TEXT_NODE && /\s$/.test(previousSibling.textContent)) {
|
|
2131
|
-
nodes.push($createTextNode(" "));
|
|
2132
|
-
}
|
|
2133
|
-
|
|
2134
|
-
nodes.push(new CustomActionTextAttachmentNode({
|
|
2135
|
-
sgid: attachment.getAttribute("sgid"),
|
|
2136
|
-
innerHtml: JSON.parse(content),
|
|
2137
|
-
contentType: attachment.getAttribute("content-type")
|
|
2138
|
-
}));
|
|
2139
|
-
|
|
2140
|
-
nodes.push($createTextNode(" "));
|
|
2141
|
-
|
|
2142
|
-
return { node: nodes }
|
|
2143
|
-
},
|
|
2144
|
-
priority: 2
|
|
2145
|
-
}
|
|
2146
|
-
}
|
|
2147
|
-
}
|
|
2148
|
-
}
|
|
2149
|
-
|
|
2150
|
-
constructor({ sgid, contentType, innerHtml }, key) {
|
|
2151
|
-
super(key);
|
|
2152
|
-
|
|
2153
|
-
this.sgid = sgid;
|
|
2154
|
-
this.contentType = contentType || "application/vnd.actiontext.unknown";
|
|
2155
|
-
this.innerHtml = innerHtml;
|
|
2156
|
-
}
|
|
2157
|
-
|
|
2158
|
-
createDOM() {
|
|
2159
|
-
const figure = createElement("action-text-attachment", { "content-type": this.contentType, "data-lexxy-decorator": true });
|
|
2160
|
-
|
|
2161
|
-
figure.addEventListener("click", (event) => {
|
|
2162
|
-
dispatchCustomEvent(figure, "lexxy:internal:select-node", { key: this.getKey() });
|
|
2163
|
-
});
|
|
2164
|
-
|
|
2165
|
-
figure.insertAdjacentHTML("beforeend", this.innerHtml);
|
|
2166
|
-
|
|
2167
|
-
return figure
|
|
2168
|
-
}
|
|
2169
|
-
|
|
2170
|
-
updateDOM() {
|
|
2171
|
-
return true
|
|
2172
|
-
}
|
|
2173
|
-
|
|
2174
|
-
isInline() {
|
|
2175
|
-
return true
|
|
2176
|
-
}
|
|
2177
|
-
|
|
2178
|
-
exportDOM() {
|
|
2179
|
-
const attachment = createElement("action-text-attachment", {
|
|
2180
|
-
sgid: this.sgid,
|
|
2181
|
-
content: JSON.stringify(this.innerHtml),
|
|
2182
|
-
"content-type": this.contentType
|
|
2183
|
-
});
|
|
2184
|
-
|
|
2185
|
-
return { element: attachment }
|
|
2186
|
-
}
|
|
2187
|
-
|
|
2188
|
-
exportJSON() {
|
|
2189
|
-
return {
|
|
2190
|
-
type: "custom_action_text_attachment",
|
|
2191
|
-
version: 1,
|
|
2192
|
-
sgid: this.sgid,
|
|
2193
|
-
contentType: this.contentType,
|
|
2194
|
-
innerHtml: this.innerHtml
|
|
2195
|
-
}
|
|
2196
|
-
}
|
|
2197
|
-
|
|
2198
|
-
decorate() {
|
|
2199
|
-
return null
|
|
2200
|
-
}
|
|
2201
|
-
}
|
|
2202
|
-
|
|
2203
2476
|
class LexicalEditorElement extends HTMLElement {
|
|
2204
2477
|
static formAssociated = true
|
|
2205
2478
|
static debug = true
|
|
2206
2479
|
static commands = [ "bold", "italic", "" ]
|
|
2207
2480
|
|
|
2208
|
-
static observedAttributes = [ "connected" ]
|
|
2481
|
+
static observedAttributes = [ "connected", "required" ]
|
|
2209
2482
|
|
|
2210
2483
|
#initialValue = ""
|
|
2484
|
+
#validationTextArea = document.createElement("textarea")
|
|
2211
2485
|
|
|
2212
2486
|
constructor() {
|
|
2213
2487
|
super();
|
|
@@ -2240,6 +2514,11 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
2240
2514
|
if (name === "connected" && this.isConnected && oldValue != null && oldValue !== newValue) {
|
|
2241
2515
|
requestAnimationFrame(() => this.#reconnect());
|
|
2242
2516
|
}
|
|
2517
|
+
|
|
2518
|
+
if (name === "required" && this.isConnected) {
|
|
2519
|
+
this.#validationTextArea.required = this.hasAttribute("required");
|
|
2520
|
+
this.#setValidity();
|
|
2521
|
+
}
|
|
2243
2522
|
}
|
|
2244
2523
|
|
|
2245
2524
|
formResetCallback() {
|
|
@@ -2293,7 +2572,7 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
2293
2572
|
$addUpdateTag(SKIP_DOM_SELECTION_TAG);
|
|
2294
2573
|
const root = $getRoot();
|
|
2295
2574
|
root.clear();
|
|
2296
|
-
root.append(...this.#parseHtmlIntoLexicalNodes(html));
|
|
2575
|
+
if (html !== "") root.append(...this.#parseHtmlIntoLexicalNodes(html));
|
|
2297
2576
|
root.select();
|
|
2298
2577
|
|
|
2299
2578
|
this.#toggleEmptyStatus();
|
|
@@ -2406,6 +2685,7 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
2406
2685
|
|
|
2407
2686
|
this.internals.setFormValue(html);
|
|
2408
2687
|
this._internalFormValue = html;
|
|
2688
|
+
this.#validationTextArea.value = this.#isEmpty ? "" : html;
|
|
2409
2689
|
|
|
2410
2690
|
if (changed) {
|
|
2411
2691
|
dispatch(this, "lexxy:change");
|
|
@@ -2434,6 +2714,7 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
2434
2714
|
this.cachedValue = null;
|
|
2435
2715
|
this.#internalFormValue = this.value;
|
|
2436
2716
|
this.#toggleEmptyStatus();
|
|
2717
|
+
this.#setValidity();
|
|
2437
2718
|
}));
|
|
2438
2719
|
}
|
|
2439
2720
|
|
|
@@ -2540,6 +2821,14 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
2540
2821
|
return !this.editorContentElement.textContent.trim() && !containsVisuallyRelevantChildren(this.editorContentElement)
|
|
2541
2822
|
}
|
|
2542
2823
|
|
|
2824
|
+
#setValidity() {
|
|
2825
|
+
if (this.#validationTextArea.validity.valid) {
|
|
2826
|
+
this.internals.setValidity({});
|
|
2827
|
+
} else {
|
|
2828
|
+
this.internals.setValidity(this.#validationTextArea.validity, this.#validationTextArea.validationMessage, this.editorContentElement);
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2543
2832
|
#reset() {
|
|
2544
2833
|
this.#unregisterHandlers();
|
|
2545
2834
|
|