@37signals/lexxy 0.7.3-beta → 0.7.4-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.
Files changed (2) hide show
  1. package/dist/lexxy.esm.js +1213 -1161
  2. package/package.json +1 -1
package/dist/lexxy.esm.js CHANGED
@@ -10,8 +10,8 @@ import 'prismjs/components/prism-json';
10
10
  import 'prismjs/components/prism-diff';
11
11
  import DOMPurify from 'dompurify';
12
12
  import { getStyleObjectFromCSS, getCSSFromStyleObject, $getSelectionStyleValueForProperty, $patchStyleText } from '@lexical/selection';
13
- import { $isTextNode, TextNode, $isRangeSelection, SKIP_DOM_SELECTION_TAG, SELECTION_CHANGE_COMMAND, COMMAND_PRIORITY_HIGH, $getSelection, DecoratorNode, $getNodeByKey, HISTORY_MERGE_TAG, FORMAT_TEXT_COMMAND, $createTextNode, $isRootOrShadowRoot, UNDO_COMMAND, REDO_COMMAND, PASTE_COMMAND, COMMAND_PRIORITY_LOW, KEY_TAB_COMMAND, COMMAND_PRIORITY_NORMAL, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $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, $createNodeSelection, $setSelection, $createParagraphNode, KEY_ENTER_COMMAND, $isParagraphNode, $insertNodes, $createLineBreakNode, PASTE_TAG, createCommand, createState, defineExtension, $setState, $getState, $hasUpdateTag, CLEAR_HISTORY_COMMAND, $addUpdateTag, KEY_DOWN_COMMAND, KEY_SPACE_COMMAND } from 'lexical';
14
- import { $isListNode, $isListItemNode, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, ListNode, $getListDepth, $createListNode, ListItemNode, registerList } from '@lexical/list';
13
+ import { HISTORY_MERGE_TAG, SKIP_DOM_SELECTION_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $isTextNode, TextNode, $isRangeSelection, $getSelection, DecoratorNode, $getEditor, FORMAT_TEXT_COMMAND, $createTextNode, $isRootOrShadowRoot, UNDO_COMMAND, REDO_COMMAND, PASTE_COMMAND, COMMAND_PRIORITY_LOW, KEY_TAB_COMMAND, COMMAND_PRIORITY_NORMAL, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $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, $getNodeByKey, $createNodeSelection, $setSelection, $createParagraphNode, KEY_ENTER_COMMAND, COMMAND_PRIORITY_HIGH, $isParagraphNode, $insertNodes, $createLineBreakNode, PASTE_TAG, createCommand, createState, defineExtension, $setState, $getState, $hasUpdateTag, CLEAR_HISTORY_COMMAND, $addUpdateTag, KEY_SPACE_COMMAND, KEY_DOWN_COMMAND } from 'lexical';
14
+ import { $isListItemNode, $isListNode, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, ListNode, $getListDepth, $createListNode, ListItemNode, registerList } from '@lexical/list';
15
15
  import { $isQuoteNode, $isHeadingNode, $createQuoteNode, $createHeadingNode, RichTextExtension, QuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
16
16
  import { $isCodeNode, CodeNode, normalizeCodeLang, CodeHighlightNode, registerCodeHighlighting, CODE_LANGUAGE_FRIENDLY_NAME_MAP } from '@lexical/code';
17
17
  import { $isLinkNode, $createAutoLinkNode, $toggleLink, $createLinkNode, LinkNode, AutoLinkNode } from '@lexical/link';
@@ -156,6 +156,8 @@ function getNonce() {
156
156
  return element?.content
157
157
  }
158
158
 
159
+ const SILENT_UPDATE_TAGS = [ HISTORY_MERGE_TAG, SKIP_DOM_SELECTION_TAG, SKIP_SCROLL_INTO_VIEW_TAG ];
160
+
159
161
  function getNearestListItemNode(node) {
160
162
  let current = node;
161
163
  while (current !== null) {
@@ -465,7 +467,7 @@ class LexicalToolbarElement extends HTMLElement {
465
467
 
466
468
  this.editor.update(() => {
467
469
  this.editor.dispatchCommand(command, payload);
468
- }, { tag: isKeyboard ? SKIP_DOM_SELECTION_TAG : undefined } );
470
+ }, { tag: isKeyboard ? SKIP_DOM_SELECTION_TAG : undefined });
469
471
  }
470
472
 
471
473
  #bindHotkeys() {
@@ -532,13 +534,12 @@ class LexicalToolbarElement extends HTMLElement {
532
534
  }
533
535
 
534
536
  #monitorSelectionChanges() {
535
- this.editor.registerCommand(
536
- SELECTION_CHANGE_COMMAND,
537
- () => {
538
- this.#closeDropdowns();
537
+ this.editor.registerUpdateListener(() => {
538
+ this.editor.getEditorState().read(() => {
539
539
  this.#updateButtonStates();
540
- return false
541
- }, COMMAND_PRIORITY_HIGH);
540
+ this.#closeDropdowns();
541
+ });
542
+ });
542
543
  }
543
544
 
544
545
  #monitorHistoryChanges() {
@@ -802,8 +803,6 @@ class LexicalToolbarElement extends HTMLElement {
802
803
  }
803
804
  }
804
805
 
805
- customElements.define("lexxy-toolbar", LexicalToolbarElement);
806
-
807
806
  var theme = {
808
807
  text: {
809
808
  bold: "lexxy-content__bold",
@@ -969,6 +968,8 @@ class ActionTextAttachmentNode extends DecoratorNode {
969
968
  this.fileSize = fileSize;
970
969
  this.width = width;
971
970
  this.height = height;
971
+
972
+ this.editor = $getEditor();
972
973
  }
973
974
 
974
975
  createDOM() {
@@ -989,8 +990,13 @@ class ActionTextAttachmentNode extends DecoratorNode {
989
990
  return figure
990
991
  }
991
992
 
992
- updateDOM() {
993
- return true
993
+ updateDOM(_prevNode, dom) {
994
+ const caption = dom.querySelector("figcaption textarea");
995
+ if (caption && this.caption) {
996
+ caption.value = this.caption;
997
+ }
998
+
999
+ return false
994
1000
  }
995
1001
 
996
1002
  getTextContent() {
@@ -1098,8 +1104,8 @@ class ActionTextAttachmentNode extends DecoratorNode {
1098
1104
  });
1099
1105
 
1100
1106
  input.addEventListener("focusin", () => input.placeholder = "Add caption...");
1101
- input.addEventListener("blur", this.#handleCaptionInputBlurred.bind(this));
1102
- input.addEventListener("keydown", this.#handleCaptionInputKeydown.bind(this));
1107
+ input.addEventListener("blur", (event) => this.#handleCaptionInputBlurred(event));
1108
+ input.addEventListener("keydown", (event) => this.#handleCaptionInputKeydown(event));
1103
1109
 
1104
1110
  caption.appendChild(input);
1105
1111
 
@@ -1107,21 +1113,24 @@ class ActionTextAttachmentNode extends DecoratorNode {
1107
1113
  }
1108
1114
 
1109
1115
  #handleCaptionInputBlurred(event) {
1110
- const input = event.target;
1111
-
1112
- input.placeholder = this.fileName;
1113
- this.#updateCaptionValueFromInput(input);
1116
+ this.#updateCaptionValueFromInput(event.target);
1114
1117
  }
1115
1118
 
1116
1119
  #updateCaptionValueFromInput(input) {
1117
- dispatchCustomEvent(input, "lexxy:internal:invalidate-node", { key: this.getKey(), values: { caption: input.value } });
1120
+ input.placeholder = this.fileName;
1121
+ this.editor.update(() => {
1122
+ this.getWritable().caption = input.value;
1123
+ });
1118
1124
  }
1119
1125
 
1120
1126
  #handleCaptionInputKeydown(event) {
1121
1127
  if (event.key === "Enter") {
1122
1128
  this.#updateCaptionValueFromInput(event.target);
1123
- dispatchCustomEvent(event.target, "lexxy:internal:move-to-next-line");
1124
1129
  event.preventDefault();
1130
+
1131
+ this.editor.update(() => {
1132
+ this.selectNext();
1133
+ }, { tag: HISTORY_MERGE_TAG });
1125
1134
  }
1126
1135
  event.stopPropagation();
1127
1136
  }
@@ -1162,56 +1171,83 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
1162
1171
  }
1163
1172
 
1164
1173
  constructor(node, key) {
1165
- const { file, uploadUrl, blobUrlTemplate, editor, progress } = node;
1174
+ const { file, uploadUrl, blobUrlTemplate, progress, width, height, uploadError } = node;
1166
1175
  super({ ...node, contentType: file.type }, key);
1167
1176
  this.file = file;
1168
1177
  this.uploadUrl = uploadUrl;
1169
1178
  this.blobUrlTemplate = blobUrlTemplate;
1170
- this.src = null;
1171
- this.editor = editor;
1172
- this.progress = progress || 0;
1179
+ this.progress = progress ?? null;
1180
+ this.width = width;
1181
+ this.height = height;
1182
+ this.uploadError = uploadError;
1173
1183
  }
1174
1184
 
1175
1185
  createDOM() {
1186
+ if (this.uploadError) return this.#createDOMForError()
1187
+
1188
+ // This side-effect is trigged on DOM load to fire only once and avoid multiple
1189
+ // uploads through cloning. The upload is guarded from restarting in case the
1190
+ // node is reloaded from saved state such as from history.
1191
+ this.#startUploadIfNeeded();
1192
+
1176
1193
  const figure = this.createAttachmentFigure();
1177
1194
 
1178
1195
  if (this.isPreviewableAttachment) {
1179
- figure.appendChild(this.#createDOMForImage());
1196
+ const img = figure.appendChild(this.#createDOMForImage());
1197
+
1198
+ // load file locally to set dimensions and prevent vertical shifting
1199
+ loadFileIntoImage(this.file, img).then(img => this.#setDimensionsFromImage(img));
1180
1200
  } else {
1181
1201
  figure.appendChild(this.#createDOMForFile());
1182
1202
  }
1183
1203
 
1184
1204
  figure.appendChild(this.#createCaption());
1205
+ figure.appendChild(this.#createProgressBar());
1185
1206
 
1186
- const progressBar = createElement("progress", { value: this.progress, max: 100 });
1187
- figure.appendChild(progressBar);
1207
+ return figure
1208
+ }
1188
1209
 
1189
- // We wait for images to download so that we can pass the dimensions down to the attachment. We do this
1190
- // so that we can render images in edit mode with the dimensions set, which prevent vertical layout shifts.
1191
- this.#loadFigure(figure).then(() => this.#startUpload(progressBar, figure));
1210
+ updateDOM(prevNode, dom) {
1211
+ if (this.uploadError !== prevNode.uploadError) return true
1192
1212
 
1193
- return figure
1213
+ if (prevNode.progress !== this.progress) {
1214
+ const progress = dom.querySelector("progress");
1215
+ progress.value = this.progress ?? 0;
1216
+ }
1217
+
1218
+ return false
1194
1219
  }
1195
1220
 
1196
1221
  exportDOM() {
1197
1222
  const img = document.createElement("img");
1198
- if (this.src) {
1199
- img.src = this.src;
1200
- }
1201
1223
  return { element: img }
1202
1224
  }
1203
1225
 
1204
1226
  exportJSON() {
1205
1227
  return {
1228
+ ...super.exportJSON(),
1206
1229
  type: "action_text_attachment_upload",
1207
1230
  version: 1,
1208
- progress: this.progress,
1209
1231
  uploadUrl: this.uploadUrl,
1210
1232
  blobUrlTemplate: this.blobUrlTemplate,
1211
- ...super.exportJSON()
1233
+ progress: this.progress,
1234
+ width: this.width,
1235
+ height: this.height,
1236
+ uploadError: this.uploadError
1212
1237
  }
1213
1238
  }
1214
1239
 
1240
+ get #uploadStarted() {
1241
+ return this.progress !== null
1242
+ }
1243
+
1244
+ #createDOMForError() {
1245
+ const figure = this.createAttachmentFigure();
1246
+ figure.classList.add("attachment--error");
1247
+ figure.appendChild(createElement("div", { innerText: `Error uploading ${this.file?.name ?? "file"}` }));
1248
+ return figure
1249
+ }
1250
+
1215
1251
  #createDOMForImage() {
1216
1252
  return createElement("img")
1217
1253
  }
@@ -1237,94 +1273,126 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
1237
1273
  return figcaption
1238
1274
  }
1239
1275
 
1240
- #loadFigure(figure) {
1241
- const image = figure.querySelector("img");
1242
- if (!image) {
1243
- return Promise.resolve()
1244
- } else {
1245
- return loadFileIntoImage(this.file, image)
1246
- }
1276
+ #createProgressBar() {
1277
+ return createElement("progress", { value: this.progress ?? 0, max: 100 })
1278
+ }
1279
+
1280
+ #setDimensionsFromImage({ width, height }) {
1281
+ if (this.#hasDimensions) return
1282
+
1283
+ this.editor.update(() => {
1284
+ const writable = this.getWritable();
1285
+ writable.width = width;
1286
+ writable.height = height;
1287
+ }, { tag: SILENT_UPDATE_TAGS });
1288
+ }
1289
+
1290
+ get #hasDimensions() {
1291
+ return Boolean(this.width && this.height)
1247
1292
  }
1248
1293
 
1249
- async #startUpload(progressBar, figure) {
1294
+ async #startUploadIfNeeded() {
1295
+ if (this.#uploadStarted) return
1296
+
1297
+ this.#setUploadStarted();
1298
+
1250
1299
  const { DirectUpload } = await import('@rails/activestorage');
1251
- const shouldAuthenticateUploads = Lexxy.global.get("authenticatedUploads");
1252
1300
 
1253
1301
  const upload = new DirectUpload(this.file, this.uploadUrl, this);
1302
+ upload.delegate = this.#createUploadDelegate();
1303
+ upload.create((error, blob) => {
1304
+ if (error) {
1305
+ this.#handleUploadError(error);
1306
+ } else {
1307
+ this.#showUploadedAttachment(blob);
1308
+ }
1309
+ });
1310
+ }
1254
1311
 
1255
- upload.delegate = {
1312
+ #createUploadDelegate() {
1313
+ const shouldAuthenticateUploads = Lexxy.global.get("authenticatedUploads");
1314
+
1315
+ return {
1256
1316
  directUploadWillCreateBlobWithXHR: (request) => {
1257
1317
  if (shouldAuthenticateUploads) request.withCredentials = true;
1258
1318
  },
1259
1319
  directUploadWillStoreFileWithXHR: (request) => {
1260
1320
  if (shouldAuthenticateUploads) request.withCredentials = true;
1261
1321
 
1262
- request.upload.addEventListener("progress", (event) => {
1263
- this.editor.update(() => {
1264
- progressBar.value = Math.round(event.loaded / event.total * 100);
1265
- });
1266
- });
1322
+ const uploadProgressHandler = (event) => this.#handleUploadProgress(event);
1323
+ request.upload.addEventListener("progress", uploadProgressHandler);
1267
1324
  }
1268
- };
1325
+ }
1326
+ }
1269
1327
 
1270
- upload.create((error, blob) => {
1271
- if (error) {
1272
- this.#handleUploadError(figure);
1273
- } else {
1274
- this.#loadFigurePreviewFromBlob(blob, figure).then(() => {
1275
- this.#showUploadedAttachment(figure, blob);
1276
- });
1277
- }
1278
- });
1328
+ #setUploadStarted() {
1329
+ this.#setProgress(1);
1279
1330
  }
1280
1331
 
1281
- #handleUploadError(figure) {
1282
- figure.innerHTML = "";
1283
- figure.classList.add("attachment--error");
1284
- figure.appendChild(createElement("div", { innerText: `Error uploading ${this.file?.name ?? "image"}` }));
1332
+ #handleUploadProgress(event) {
1333
+ this.#setProgress(Math.round(event.loaded / event.total * 100));
1285
1334
  }
1286
1335
 
1287
- async #showUploadedAttachment(figure, blob) {
1336
+ #setProgress(progress) {
1288
1337
  this.editor.update(() => {
1289
- const image = figure.querySelector("img");
1290
-
1291
- const src = this.blobUrlTemplate
1292
- .replace(":signed_id", blob.signed_id)
1293
- .replace(":filename", encodeURIComponent(blob.filename));
1294
- const latest = $getNodeByKey(this.getKey());
1295
- if (latest) {
1296
- latest.replace(new ActionTextAttachmentNode({
1297
- tagName: this.tagName,
1298
- sgid: blob.attachable_sgid,
1299
- src: blob.previewable ? blob.url : src,
1300
- altText: blob.filename,
1301
- contentType: blob.content_type,
1302
- fileName: blob.filename,
1303
- fileSize: blob.byte_size,
1304
- width: image?.naturalWidth,
1305
- previewable: blob.previewable,
1306
- height: image?.naturalHeight
1307
- }));
1308
- }
1309
- }, { tag: HISTORY_MERGE_TAG });
1338
+ this.getWritable().progress = progress;
1339
+ }, { tag: SILENT_UPDATE_TAGS });
1310
1340
  }
1311
1341
 
1312
- async #loadFigurePreviewFromBlob(blob, figure) {
1313
- if (blob.previewable) {
1314
- return new Promise((resolve) => {
1315
- this.editor.update(() => {
1316
- const image = this.#createDOMForImage();
1317
- image.addEventListener("load", () => {
1318
- resolve();
1319
- });
1320
- image.src = blob.url;
1321
- figure.insertBefore(image, figure.firstChild);
1322
- });
1323
- })
1324
- } else {
1325
- return Promise.resolve()
1342
+ #handleUploadError(error) {
1343
+ console.warn(`Upload error for ${this.file?.name ?? "file"}: ${error}`);
1344
+ this.editor.update(() => {
1345
+ this.getWritable().uploadError = true;
1346
+ }, { tag: SILENT_UPDATE_TAGS });
1347
+ }
1348
+
1349
+ async #showUploadedAttachment(blob) {
1350
+ this.editor.update(() => {
1351
+ this.replace(this.#toActionTextAttachmentNodeWith(blob));
1352
+ }, { tag: SILENT_UPDATE_TAGS });
1353
+ }
1354
+
1355
+ #toActionTextAttachmentNodeWith(blob) {
1356
+ const conversion = new AttachmentNodeConversion(this, blob);
1357
+ return conversion.toAttachmentNode()
1358
+ }
1359
+ }
1360
+
1361
+ class AttachmentNodeConversion {
1362
+ constructor(uploadNode, blob) {
1363
+ this.uploadNode = uploadNode;
1364
+ this.blob = blob;
1365
+ }
1366
+
1367
+ toAttachmentNode() {
1368
+ return new ActionTextAttachmentNode({
1369
+ ...this.uploadNode,
1370
+ ...this.#propertiesFromBlob,
1371
+ src: this.#src
1372
+ })
1373
+ }
1374
+
1375
+ get #propertiesFromBlob() {
1376
+ const { blob } = this;
1377
+ return {
1378
+ sgid: blob.attachable_sgid,
1379
+ altText: blob.filename,
1380
+ contentType: blob.content_type,
1381
+ fileName: blob.filename,
1382
+ fileSize: blob.byte_size,
1383
+ previewable: blob.previewable,
1326
1384
  }
1327
1385
  }
1386
+
1387
+ get #src() {
1388
+ return this.blob.previewable ? this.blob.url : this.#blobSrc
1389
+ }
1390
+
1391
+ get #blobSrc() {
1392
+ return this.uploadNode.blobUrlTemplate
1393
+ .replace(":signed_id", this.blob.signed_id)
1394
+ .replace(":filename", encodeURIComponent(this.blob.filename))
1395
+ }
1328
1396
  }
1329
1397
 
1330
1398
  class HorizontalDividerNode extends DecoratorNode {
@@ -3114,7 +3182,7 @@ class Contents {
3114
3182
  const blobUrlTemplate = this.editorElement.blobUrlTemplate;
3115
3183
 
3116
3184
  this.editor.update(() => {
3117
- const uploadedImageNode = new ActionTextAttachmentUploadNode({ file: file, uploadUrl: uploadUrl, blobUrlTemplate: blobUrlTemplate, editor: this.editor });
3185
+ const uploadedImageNode = new ActionTextAttachmentUploadNode({ file: file, uploadUrl: uploadUrl, blobUrlTemplate: blobUrlTemplate });
3118
3186
  this.insertAtCursor(uploadedImageNode);
3119
3187
  }, { tag: HISTORY_MERGE_TAG });
3120
3188
  }
@@ -3731,7 +3799,7 @@ class Extensions {
3731
3799
 
3732
3800
  initializeToolbars() {
3733
3801
  if (this.#lexxyToolbar) {
3734
- this.enabledExtensions.forEach(ext => ext.initializeToobar(this.#lexxyToolbar));
3802
+ this.enabledExtensions.forEach(ext => ext.initializeToolbar(this.#lexxyToolbar));
3735
3803
  }
3736
3804
  }
3737
3805
 
@@ -4225,7 +4293,6 @@ class LexicalEditorElement extends HTMLElement {
4225
4293
  #initialize() {
4226
4294
  this.#synchronizeWithChanges();
4227
4295
  this.#registerComponents();
4228
- this.#listenForInvalidatedNodes();
4229
4296
  this.#handleEnter();
4230
4297
  this.#registerFocusEvents();
4231
4298
  this.#attachDebugHooks();
@@ -4238,11 +4305,11 @@ class LexicalEditorElement extends HTMLElement {
4238
4305
  this.editorContentElement ||= this.#createEditorContentElement();
4239
4306
 
4240
4307
  const editor = buildEditorFromExtensions({
4241
- name: "lexxy/core",
4242
- namespace: "Lexxy",
4243
- theme: theme,
4244
- nodes: this.#lexicalNodes
4245
- },
4308
+ name: "lexxy/core",
4309
+ namespace: "Lexxy",
4310
+ theme: theme,
4311
+ nodes: this.#lexicalNodes
4312
+ },
4246
4313
  ...this.#lexicalExtensions
4247
4314
  );
4248
4315
 
@@ -4252,7 +4319,7 @@ class LexicalEditorElement extends HTMLElement {
4252
4319
  }
4253
4320
 
4254
4321
  get #lexicalExtensions() {
4255
- const extensions = [ ];
4322
+ const extensions = [];
4256
4323
  const richTextExtensions = [
4257
4324
  this.highlighter.lexicalExtension,
4258
4325
  TrixContentExtension,
@@ -4405,21 +4472,6 @@ class LexicalEditorElement extends HTMLElement {
4405
4472
  this.append(this.codeLanguagePicker);
4406
4473
  }
4407
4474
 
4408
- #listenForInvalidatedNodes() {
4409
- this.editor.getRootElement().addEventListener("lexxy:internal:invalidate-node", (event) => {
4410
- const { key, values } = event.detail;
4411
-
4412
- this.editor.update(() => {
4413
- const node = $getNodeByKey(key);
4414
-
4415
- if (node instanceof ActionTextAttachmentNode) {
4416
- const updatedNode = node.getWritable();
4417
- Object.assign(updatedNode, values);
4418
- }
4419
- });
4420
- });
4421
- }
4422
-
4423
4475
  #handleEnter() {
4424
4476
  // We can't prevent these externally using regular keydown because Lexical handles it first.
4425
4477
  this.editor.registerCommand(
@@ -4482,14 +4534,7 @@ class LexicalEditorElement extends HTMLElement {
4482
4534
 
4483
4535
 
4484
4536
  #attachDebugHooks() {
4485
- if (!LexicalEditorElement.debug) return
4486
-
4487
- this.#addUnregisterHandler(this.editor.registerUpdateListener(({ editorState }) => {
4488
- editorState.read(() => {
4489
- console.debug("HTML: ", this.value, "String:", this.toString());
4490
- console.debug("empty", this.isEmpty, "blank", this.isBlank);
4491
- });
4492
- }));
4537
+ return
4493
4538
  }
4494
4539
 
4495
4540
  #attachToolbar() {
@@ -4569,8 +4614,6 @@ class LexicalEditorElement extends HTMLElement {
4569
4614
  }
4570
4615
  }
4571
4616
 
4572
- customElements.define("lexxy-editor", LexicalEditorElement);
4573
-
4574
4617
  class ToolbarDropdown extends HTMLElement {
4575
4618
  connectedCallback() {
4576
4619
  this.container = this.closest("details");
@@ -4696,8 +4739,6 @@ class LinkDropdown extends ToolbarDropdown {
4696
4739
  }
4697
4740
  }
4698
4741
 
4699
- customElements.define("lexxy-link-dropdown", LinkDropdown);
4700
-
4701
4742
  const APPLY_HIGHLIGHT_SELECTOR = "button.lexxy-highlight-button";
4702
4743
  const REMOVE_HIGHLIGHT_SELECTOR = "[data-command='removeHighlight']";
4703
4744
 
@@ -4805,1425 +4846,1433 @@ class HighlightDropdown extends ToolbarDropdown {
4805
4846
  }
4806
4847
  }
4807
4848
 
4808
- customElements.define("lexxy-highlight-dropdown", HighlightDropdown);
4809
-
4810
- class TableController {
4811
- constructor(editorElement) {
4812
- this.editor = editorElement.editor;
4813
- this.contents = editorElement.contents;
4814
- this.selection = editorElement.selection;
4815
-
4816
- this.currentTableNodeKey = null;
4817
- this.currentCellKey = null;
4849
+ class BaseSource {
4850
+ // Template method to override
4851
+ async buildListItems(filter = "") {
4852
+ return Promise.resolve([])
4853
+ }
4818
4854
 
4819
- this.#registerKeyHandlers();
4855
+ // Template method to override
4856
+ promptItemFor(listItem) {
4857
+ return null
4820
4858
  }
4821
4859
 
4822
- destroy() {
4823
- this.currentTableNodeKey = null;
4824
- this.currentCellKey = null;
4860
+ // Protected
4825
4861
 
4826
- this.#unregisterKeyHandlers();
4862
+ buildListItemElementFor(promptItemElement) {
4863
+ const template = promptItemElement.querySelector("template[type='menu']");
4864
+ const fragment = template.content.cloneNode(true);
4865
+ const listItemElement = createElement("li", { role: "option", id: generateDomId("prompt-item"), tabindex: "0" });
4866
+ listItemElement.classList.add("lexxy-prompt-menu__item");
4867
+ listItemElement.appendChild(fragment);
4868
+ return listItemElement
4827
4869
  }
4828
4870
 
4829
- get currentCell() {
4830
- if (!this.currentCellKey) return null
4871
+ async loadPromptItemsFromUrl(url) {
4872
+ try {
4873
+ const response = await fetch(url);
4874
+ const html = await response.text();
4875
+ const promptItems = parseHtml(html).querySelectorAll("lexxy-prompt-item");
4876
+ return Promise.resolve(Array.from(promptItems))
4877
+ } catch (error) {
4878
+ return Promise.reject(error)
4879
+ }
4880
+ }
4881
+ }
4831
4882
 
4832
- return this.editor.getEditorState().read(() => {
4833
- const cell = $getNodeByKey(this.currentCellKey);
4834
- return (cell instanceof TableCellNode) ? cell : null
4835
- })
4883
+ class LocalFilterSource extends BaseSource {
4884
+ async buildListItems(filter = "") {
4885
+ const promptItems = await this.fetchPromptItems();
4886
+ return this.#buildListItemsFromPromptItems(promptItems, filter)
4836
4887
  }
4837
4888
 
4838
- get currentTableNode() {
4839
- if (!this.currentTableNodeKey) return null
4889
+ // Template method to override
4890
+ async fetchPromptItems(filter) {
4891
+ return Promise.resolve([])
4892
+ }
4840
4893
 
4841
- return this.editor.getEditorState().read(() => {
4842
- const tableNode = $getNodeByKey(this.currentTableNodeKey);
4843
- return (tableNode instanceof TableNode) ? tableNode : null
4844
- })
4894
+ promptItemFor(listItem) {
4895
+ return this.promptItemByListItem.get(listItem)
4845
4896
  }
4846
4897
 
4847
- get currentRowCells() {
4848
- const currentRowIndex = this.currentRowIndex;
4898
+ #buildListItemsFromPromptItems(promptItems, filter) {
4899
+ const listItems = [];
4900
+ this.promptItemByListItem = new WeakMap();
4901
+ promptItems.forEach((promptItem) => {
4902
+ const searchableText = promptItem.getAttribute("search");
4849
4903
 
4850
- const rows = this.tableRows;
4851
- if (!rows) return null
4904
+ if (!filter || filterMatches(searchableText, filter)) {
4905
+ const listItem = this.buildListItemElementFor(promptItem);
4906
+ this.promptItemByListItem.set(listItem, promptItem);
4907
+ listItems.push(listItem);
4908
+ }
4909
+ });
4852
4910
 
4853
- return this.editor.getEditorState().read(() => {
4854
- return rows[currentRowIndex]?.getChildren() ?? null
4855
- }) ?? null
4911
+ return listItems
4856
4912
  }
4913
+ }
4857
4914
 
4858
- get currentRowIndex() {
4859
- const currentCell = this.currentCell;
4860
- if (!currentCell) return 0
4915
+ class InlinePromptSource extends LocalFilterSource {
4916
+ constructor(inlinePromptItems) {
4917
+ super();
4918
+ this.inlinePromptItemElements = Array.from(inlinePromptItems);
4919
+ }
4861
4920
 
4862
- return this.editor.getEditorState().read(() => {
4863
- return $getTableRowIndexFromTableCellNode(currentCell)
4864
- }) ?? 0
4921
+ async fetchPromptItems() {
4922
+ return Promise.resolve(this.inlinePromptItemElements)
4865
4923
  }
4924
+ }
4866
4925
 
4867
- get currentColumnCells() {
4868
- const columnIndex = this.currentColumnIndex;
4926
+ class DeferredPromptSource extends LocalFilterSource {
4927
+ constructor(url) {
4928
+ super();
4929
+ this.url = url;
4869
4930
 
4870
- const rows = this.tableRows;
4871
- if (!rows) return null
4931
+ this.fetchPromptItems();
4932
+ }
4872
4933
 
4873
- return this.editor.getEditorState().read(() => {
4874
- return rows.map(row => row.getChildAtIndex(columnIndex))
4875
- }) ?? null
4934
+ async fetchPromptItems() {
4935
+ this.promptItems ??= await this.loadPromptItemsFromUrl(this.url);
4936
+
4937
+ return Promise.resolve(this.promptItems)
4876
4938
  }
4939
+ }
4877
4940
 
4878
- get currentColumnIndex() {
4879
- const currentCell = this.currentCell;
4880
- if (!currentCell) return 0
4941
+ const DEBOUNCE_INTERVAL = 200;
4881
4942
 
4882
- return this.editor.getEditorState().read(() => {
4883
- return $getTableColumnIndexFromTableCellNode(currentCell)
4884
- }) ?? 0
4943
+ class RemoteFilterSource extends BaseSource {
4944
+ constructor(url) {
4945
+ super();
4946
+
4947
+ this.baseURL = url;
4948
+ this.loadAndFilterListItems = debounceAsync(this.fetchFilteredListItems.bind(this), DEBOUNCE_INTERVAL);
4885
4949
  }
4886
4950
 
4887
- get tableRows() {
4888
- return this.editor.getEditorState().read(() => {
4889
- return this.currentTableNode?.getChildren()
4890
- }) ?? null
4951
+ async buildListItems(filter = "") {
4952
+ return await this.loadAndFilterListItems(filter)
4891
4953
  }
4892
4954
 
4893
- updateSelectedTable() {
4894
- let cellNode = null;
4895
- let tableNode = null;
4896
-
4897
- this.editor.getEditorState().read(() => {
4898
- const selection = $getSelection();
4899
- if (!selection || !this.selection.isTableCellSelected) return
4900
-
4901
- const node = selection.getNodes()[0];
4955
+ promptItemFor(listItem) {
4956
+ return this.promptItemByListItem.get(listItem)
4957
+ }
4902
4958
 
4903
- cellNode = $findCellNode(node);
4904
- tableNode = $findTableNode(node);
4905
- });
4959
+ async fetchFilteredListItems(filter) {
4960
+ const promptItems = await this.loadPromptItemsFromUrl(this.#urlFor(filter));
4961
+ return this.#buildListItemsFromPromptItems(promptItems)
4962
+ }
4906
4963
 
4907
- this.currentCellKey = cellNode?.getKey() ?? null;
4908
- this.currentTableNodeKey = tableNode?.getKey() ?? null;
4964
+ #urlFor(filter) {
4965
+ const url = new URL(this.baseURL, window.location.origin);
4966
+ url.searchParams.append("filter", filter);
4967
+ return url.toString()
4909
4968
  }
4910
4969
 
4911
- executeTableCommand(command, customIndex = null) {
4912
- if (command.action === "delete" && command.childType === "table") {
4913
- this.#deleteTable();
4914
- return
4915
- }
4970
+ #buildListItemsFromPromptItems(promptItems) {
4971
+ const listItems = [];
4972
+ this.promptItemByListItem = new WeakMap();
4916
4973
 
4917
- if (command.action === "toggle") {
4918
- this.#executeToggleStyle(command);
4919
- return
4974
+ for (const promptItem of promptItems) {
4975
+ const listItem = this.buildListItemElementFor(promptItem);
4976
+ this.promptItemByListItem.set(listItem, promptItem);
4977
+ listItems.push(listItem);
4920
4978
  }
4921
4979
 
4922
- this.#executeCommand(command, customIndex);
4980
+ return listItems
4923
4981
  }
4982
+ }
4924
4983
 
4925
- #executeCommand(command, customIndex = null) {
4926
- this.#selectCellAtSelection();
4927
- this.editor.dispatchCommand(this.#commandName(command));
4928
- this.#selectNextBestCell(command, customIndex);
4929
- }
4984
+ const NOTHING_FOUND_DEFAULT_MESSAGE = "Nothing found";
4930
4985
 
4931
- #executeToggleStyle(command) {
4932
- const childType = command.childType;
4986
+ class LexicalPromptElement extends HTMLElement {
4987
+ constructor() {
4988
+ super();
4989
+ this.keyListeners = [];
4990
+ }
4933
4991
 
4934
- let cells = null;
4935
- let headerState = null;
4992
+ static observedAttributes = [ "connected" ]
4936
4993
 
4937
- if (childType === "row") {
4938
- cells = this.currentRowCells;
4939
- headerState = TableCellHeaderStates.ROW;
4940
- } else if (childType === "column") {
4941
- cells = this.currentColumnCells;
4942
- headerState = TableCellHeaderStates.COLUMN;
4943
- }
4994
+ connectedCallback() {
4995
+ this.source = this.#createSource();
4944
4996
 
4945
- if (!cells || cells.length === 0) return
4997
+ this.#addTriggerListener();
4998
+ this.toggleAttribute("connected", true);
4999
+ }
4946
5000
 
4947
- this.editor.update(() => {
4948
- const firstCell = $getTableCellNodeFromLexicalNode(cells[0]);
4949
- if (!firstCell) return
5001
+ disconnectedCallback() {
5002
+ this.source = null;
5003
+ this.popoverElement = null;
5004
+ }
4950
5005
 
4951
- const currentStyle = firstCell.getHeaderStyles();
4952
- const newStyle = currentStyle ^ headerState;
4953
5006
 
4954
- cells.forEach(cell => {
4955
- this.#setHeaderStyle(cell, newStyle, headerState);
4956
- });
4957
- });
5007
+ attributeChangedCallback(name, oldValue, newValue) {
5008
+ if (name === "connected" && this.isConnected && oldValue != null && oldValue !== newValue) {
5009
+ requestAnimationFrame(() => this.#reconnect());
5010
+ }
4958
5011
  }
4959
5012
 
4960
- #deleteTable() {
4961
- this.#selectCellAtSelection();
4962
- this.editor.dispatchCommand("deleteTable");
5013
+ get name() {
5014
+ return this.getAttribute("name")
4963
5015
  }
4964
5016
 
4965
- #selectCellAtSelection() {
4966
- this.editor.update(() => {
4967
- const selection = $getSelection();
4968
- if (!selection) return
5017
+ get trigger() {
5018
+ return this.getAttribute("trigger")
5019
+ }
4969
5020
 
4970
- const node = selection.getNodes()[0];
5021
+ get supportsSpaceInSearches() {
5022
+ return this.hasAttribute("supports-space-in-searches")
5023
+ }
4971
5024
 
4972
- $findCellNode(node)?.selectEnd();
4973
- });
5025
+ get open() {
5026
+ return this.popoverElement?.classList?.contains("lexxy-prompt-menu--visible")
4974
5027
  }
4975
5028
 
4976
- #commandName(command) {
4977
- const { action, childType, direction } = command;
5029
+ get closed() {
5030
+ return !this.open
5031
+ }
4978
5032
 
4979
- const childTypeSuffix = upcaseFirst(childType);
4980
- const directionSuffix = action == "insert" ? upcaseFirst(direction) : "";
4981
- return `${action}Table${childTypeSuffix}${directionSuffix}`
5033
+ get #doesSpaceSelect() {
5034
+ return !this.supportsSpaceInSearches
4982
5035
  }
4983
5036
 
4984
- #setHeaderStyle(cell, newStyle, headerState) {
4985
- const tableCellNode = $getTableCellNodeFromLexicalNode(cell);
4986
- tableCellNode?.setHeaderStyles(newStyle, headerState);
5037
+ #createSource() {
5038
+ const src = this.getAttribute("src");
5039
+ if (src) {
5040
+ if (this.hasAttribute("remote-filtering")) {
5041
+ return new RemoteFilterSource(src)
5042
+ } else {
5043
+ return new DeferredPromptSource(src)
5044
+ }
5045
+ } else {
5046
+ return new InlinePromptSource(this.querySelectorAll("lexxy-prompt-item"))
5047
+ }
4987
5048
  }
4988
5049
 
4989
- async #selectCellAtIndex(rowIndex, columnIndex) {
4990
- // We wait for next frame, otherwise table operations might not have completed yet.
4991
- await nextFrame();
5050
+ #addTriggerListener() {
5051
+ const unregister = this.#editor.registerUpdateListener(({ editorState }) => {
5052
+ editorState.read(() => {
5053
+ const { node, offset } = this.#selection.selectedNodeWithOffset();
5054
+ if (!node) return
4992
5055
 
4993
- if (!this.currentTableNode) return
5056
+ if ($isTextNode(node)) {
5057
+ const fullText = node.getTextContent();
5058
+ const triggerLength = this.trigger.length;
4994
5059
 
4995
- const rows = this.tableRows;
4996
- if (!rows) return
5060
+ // Check if we have enough characters for the trigger
5061
+ if (offset >= triggerLength) {
5062
+ const textBeforeCursor = fullText.slice(offset - triggerLength, offset);
4997
5063
 
4998
- const row = rows[rowIndex];
4999
- if (!row) return
5064
+ // Check if trigger is at the start of the text node (new line case) or preceded by space or newline
5065
+ if (textBeforeCursor === this.trigger) {
5066
+ const isAtStart = offset === triggerLength;
5000
5067
 
5001
- this.editor.update(() => {
5002
- const cell = $getTableCellNodeFromLexicalNode(row.getChildAtIndex(columnIndex));
5003
- cell?.selectEnd();
5068
+ const charBeforeTrigger = offset > triggerLength ? fullText[offset - triggerLength - 1] : null;
5069
+ const isPrecededBySpaceOrNewline = charBeforeTrigger === " " || charBeforeTrigger === "\n";
5070
+
5071
+ if (isAtStart || isPrecededBySpaceOrNewline) {
5072
+ unregister();
5073
+ this.#showPopover();
5074
+ }
5075
+ }
5076
+ }
5077
+ }
5078
+ });
5004
5079
  });
5005
5080
  }
5006
5081
 
5007
- #selectNextBestCell(command, customIndex = null) {
5008
- const { childType, direction } = command;
5082
+ #addCursorPositionListener() {
5083
+ this.cursorPositionListener = this.#editor.registerUpdateListener(() => {
5084
+ if (this.closed) return
5009
5085
 
5010
- let rowIndex = this.currentRowIndex;
5011
- let columnIndex = customIndex !== null ? customIndex : this.currentColumnIndex;
5086
+ this.#editor.read(() => {
5087
+ const { node, offset } = this.#selection.selectedNodeWithOffset();
5088
+ if (!node) return
5012
5089
 
5013
- const deleteOffset = command.action === "delete" ? -1 : 0;
5014
- const offset = direction === "after" ? 1 : deleteOffset;
5090
+ if ($isTextNode(node) && offset > 0) {
5091
+ const fullText = node.getTextContent();
5092
+ const textBeforeCursor = fullText.slice(0, offset);
5093
+ const lastTriggerIndex = textBeforeCursor.lastIndexOf(this.trigger);
5094
+ const triggerEndIndex = lastTriggerIndex + this.trigger.length - 1;
5015
5095
 
5016
- if (childType === "row") {
5017
- rowIndex += offset;
5018
- } else if (childType === "column") {
5019
- columnIndex += offset;
5020
- }
5096
+ // If trigger is not found, or cursor is at or before the trigger end position, hide popover
5097
+ if (lastTriggerIndex === -1 || offset <= triggerEndIndex) {
5098
+ this.#hidePopover();
5099
+ }
5100
+ } else {
5101
+ // Cursor is not in a text node or at offset 0, hide popover
5102
+ this.#hidePopover();
5103
+ }
5104
+ });
5105
+ });
5106
+ }
5021
5107
 
5022
- this.#selectCellAtIndex(rowIndex, columnIndex);
5108
+ #removeCursorPositionListener() {
5109
+ if (this.cursorPositionListener) {
5110
+ this.cursorPositionListener();
5111
+ this.cursorPositionListener = null;
5112
+ }
5023
5113
  }
5024
5114
 
5025
- #selectNextRow() {
5026
- const rows = this.tableRows;
5027
- if (!rows) return
5115
+ get #editor() {
5116
+ return this.#editorElement.editor
5117
+ }
5028
5118
 
5029
- const nextRow = rows.at(this.currentRowIndex + 1);
5030
- if (!nextRow) return
5119
+ get #editorElement() {
5120
+ return this.closest("lexxy-editor")
5121
+ }
5031
5122
 
5032
- this.editor.update(() => {
5033
- nextRow.getChildAtIndex(this.currentColumnIndex)?.selectEnd();
5034
- });
5123
+ get #selection() {
5124
+ return this.#editorElement.selection
5035
5125
  }
5036
5126
 
5037
- #selectPreviousCell() {
5038
- const cell = this.currentCell;
5039
- if (!cell) return
5127
+ async #showPopover() {
5128
+ this.popoverElement ??= await this.#buildPopover();
5129
+ this.#resetPopoverPosition();
5130
+ await this.#filterOptions();
5131
+ this.popoverElement.classList.toggle("lexxy-prompt-menu--visible", true);
5132
+ this.#selectFirstOption();
5040
5133
 
5041
- this.editor.update(() => {
5042
- cell.selectPrevious();
5043
- });
5044
- }
5134
+ this.#editorElement.addEventListener("keydown", this.#handleKeydownOnPopover);
5135
+ this.#editorElement.addEventListener("lexxy:change", this.#filterOptions);
5045
5136
 
5046
- #insertRowAndSelectFirstCell() {
5047
- this.executeTableCommand({ action: "insert", childType: "row", direction: "after" }, 0);
5137
+ this.#registerKeyListeners();
5138
+ this.#addCursorPositionListener();
5048
5139
  }
5049
5140
 
5050
- #deleteRowAndSelectLastCell() {
5051
- this.executeTableCommand({ action: "delete", childType: "row" }, -1);
5052
- }
5141
+ #registerKeyListeners() {
5142
+ // We can't use a regular keydown for Enter as Lexical handles it first
5143
+ this.keyListeners.push(this.#editor.registerCommand(KEY_ENTER_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_HIGH));
5144
+ this.keyListeners.push(this.#editor.registerCommand(KEY_TAB_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_HIGH));
5053
5145
 
5054
- #deleteRowAndSelectNextNode() {
5055
- const tableNode = this.currentTableNode;
5056
- this.executeTableCommand({ action: "delete", childType: "row" });
5146
+ if (this.#doesSpaceSelect) {
5147
+ this.keyListeners.push(this.#editor.registerCommand(KEY_SPACE_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_HIGH));
5148
+ }
5057
5149
 
5058
- this.editor.update(() => {
5059
- const next = tableNode?.getNextSibling();
5060
- if ($isParagraphNode(next)) {
5061
- next.selectStart();
5062
- } else {
5063
- const newParagraph = $createParagraphNode();
5064
- this.currentTableNode.insertAfter(newParagraph);
5065
- newParagraph.selectStart();
5066
- }
5067
- });
5150
+ // Register arrow keys with HIGH priority to prevent Lexical's selection handlers from running
5151
+ this.keyListeners.push(this.#editor.registerCommand(KEY_ARROW_UP_COMMAND, this.#handleArrowUp.bind(this), COMMAND_PRIORITY_HIGH));
5152
+ this.keyListeners.push(this.#editor.registerCommand(KEY_ARROW_DOWN_COMMAND, this.#handleArrowDown.bind(this), COMMAND_PRIORITY_HIGH));
5068
5153
  }
5069
5154
 
5070
- #isCurrentCellEmpty() {
5071
- if (!this.currentTableNode) return false
5072
-
5073
- const cell = this.currentCell;
5074
- if (!cell) return false
5075
-
5076
- return cell.getTextContent().trim() === ""
5155
+ #handleArrowUp(event) {
5156
+ this.#moveSelectionUp();
5157
+ event.preventDefault();
5158
+ return true
5077
5159
  }
5078
5160
 
5079
- #isCurrentRowLast() {
5080
- if (!this.currentTableNode) return false
5081
-
5082
- const rows = this.tableRows;
5083
- if (!rows) return false
5084
-
5085
- return rows.length === this.currentRowIndex + 1
5161
+ #handleArrowDown(event) {
5162
+ this.#moveSelectionDown();
5163
+ event.preventDefault();
5164
+ return true
5086
5165
  }
5087
5166
 
5088
- #isCurrentRowEmpty() {
5089
- if (!this.currentTableNode) return false
5090
-
5091
- const cells = this.currentRowCells;
5092
- if (!cells) return false
5167
+ #selectFirstOption() {
5168
+ const firstOption = this.#listItemElements[0];
5093
5169
 
5094
- return cells.every(cell => cell.getTextContent().trim() === "")
5170
+ if (firstOption) {
5171
+ this.#selectOption(firstOption);
5172
+ }
5095
5173
  }
5096
5174
 
5097
- #isFirstCellInRow() {
5098
- if (!this.currentTableNode) return false
5099
-
5100
- const cells = this.currentRowCells;
5101
- if (!cells) return false
5102
-
5103
- return cells.indexOf(this.currentCell) === 0
5175
+ get #listItemElements() {
5176
+ return Array.from(this.popoverElement.querySelectorAll(".lexxy-prompt-menu__item"))
5104
5177
  }
5105
5178
 
5106
- #registerKeyHandlers() {
5107
- // We can't prevent these externally using regular keydown because Lexical handles it first.
5108
- this.unregisterBackspaceKeyHandler = this.editor.registerCommand(KEY_BACKSPACE_COMMAND, (event) => this.#handleBackspaceKey(event), COMMAND_PRIORITY_HIGH);
5109
- this.unregisterEnterKeyHandler = this.editor.registerCommand(KEY_ENTER_COMMAND, (event) => this.#handleEnterKey(event), COMMAND_PRIORITY_HIGH);
5110
- }
5179
+ #selectOption(listItem) {
5180
+ this.#clearSelection();
5181
+ listItem.toggleAttribute("aria-selected", true);
5182
+ listItem.scrollIntoView({ block: "nearest", behavior: "smooth" });
5183
+ listItem.focus();
5111
5184
 
5112
- #unregisterKeyHandlers() {
5113
- this.unregisterBackspaceKeyHandler?.();
5114
- this.unregisterEnterKeyHandler?.();
5185
+ // Preserve selection to prevent cursor jump
5186
+ this.#selection.preservingSelection(() => {
5187
+ this.#editorElement.focus();
5188
+ });
5115
5189
 
5116
- this.unregisterBackspaceKeyHandler = null;
5117
- this.unregisterEnterKeyHandler = null;
5190
+ this.#editorContentElement.setAttribute("aria-controls", this.popoverElement.id);
5191
+ this.#editorContentElement.setAttribute("aria-activedescendant", listItem.id);
5192
+ this.#editorContentElement.setAttribute("aria-haspopup", "listbox");
5118
5193
  }
5119
5194
 
5120
- #handleBackspaceKey(event) {
5121
- if (!this.currentTableNode) return false
5195
+ #clearSelection() {
5196
+ this.#listItemElements.forEach((item) => { item.toggleAttribute("aria-selected", false); });
5197
+ this.#editorContentElement.removeAttribute("aria-controls");
5198
+ this.#editorContentElement.removeAttribute("aria-activedescendant");
5199
+ this.#editorContentElement.removeAttribute("aria-haspopup");
5200
+ }
5122
5201
 
5123
- if (this.#isCurrentRowEmpty() && this.#isFirstCellInRow()) {
5124
- event.preventDefault();
5125
- this.#deleteRowAndSelectLastCell();
5126
- return true
5127
- }
5202
+ #positionPopover() {
5203
+ const { x, y, fontSize } = this.#selection.cursorPosition;
5204
+ const editorRect = this.#editorElement.getBoundingClientRect();
5205
+ const contentRect = this.#editorContentElement.getBoundingClientRect();
5206
+ const verticalOffset = contentRect.top - editorRect.top;
5128
5207
 
5129
- if (this.#isCurrentCellEmpty() && !this.#isFirstCellInRow()) {
5130
- event.preventDefault();
5131
- this.#selectPreviousCell();
5132
- return true
5208
+ if (!this.popoverElement.hasAttribute("data-anchored")) {
5209
+ this.popoverElement.style.left = `${x}px`;
5210
+ this.popoverElement.toggleAttribute("data-anchored", true);
5133
5211
  }
5134
5212
 
5135
- return false
5136
- }
5137
-
5138
- #handleEnterKey(event) {
5139
- if ((event.ctrlKey || event.metaKey) || event.shiftKey || !this.currentTableNode) return false
5140
-
5141
- if (this.selection.isInsideList || this.selection.isInsideCodeBlock) return false
5213
+ this.popoverElement.style.top = `${y + verticalOffset}px`;
5214
+ this.popoverElement.style.bottom = "auto";
5142
5215
 
5143
- event.preventDefault();
5216
+ const popoverRect = this.popoverElement.getBoundingClientRect();
5217
+ const isClippedAtBottom = popoverRect.bottom > window.innerHeight;
5144
5218
 
5145
- if (this.#isCurrentRowLast() && this.#isCurrentRowEmpty()) {
5146
- this.#deleteRowAndSelectNextNode();
5147
- } else if (this.#isCurrentRowLast()) {
5148
- this.#insertRowAndSelectFirstCell();
5149
- } else {
5150
- this.#selectNextRow();
5219
+ if (isClippedAtBottom || this.popoverElement.hasAttribute("data-clipped-at-bottom")) {
5220
+ this.popoverElement.style.top = `${y + verticalOffset - popoverRect.height - fontSize}px`;
5221
+ this.popoverElement.style.bottom = "auto";
5222
+ this.popoverElement.toggleAttribute("data-clipped-at-bottom", true);
5151
5223
  }
5152
-
5153
- return true
5154
5224
  }
5155
- }
5156
-
5157
- var TableIcons = {
5158
- "insert-row-before":
5159
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
5160
- <path fill-rule="evenodd" clip-rule="evenodd" d="M7.86804e-07 15C8.29055e-07 15.8284 0.671574 16.5 1.5 16.5H15L15.1533 16.4922C15.8593 16.4205 16.4205 15.8593 16.4922 15.1533L16.5 15V4.5L16.4922 4.34668C16.4154 3.59028 15.7767 3 15 3H13.5L13.5 4.5H15V9H1.5L1.5 4.5L3 4.5V3H1.5C0.671574 3 1.20956e-06 3.67157 1.24577e-06 4.5L7.86804e-07 15ZM15 10.5V15H1.5L1.5 10.5H15Z"/>
5161
- <path d="M4.5 4.5H7.5V7.5H9V4.5H12L12 3L9 3V6.55671e-08L7.5 0V3L4.5 3V4.5Z"/>
5162
- </svg>`,
5163
-
5164
- "insert-row-after":
5165
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
5166
- <path fill-rule="evenodd" clip-rule="evenodd" d="M7.86804e-07 13.5C7.50592e-07 14.3284 0.671574 15 1.5 15H3V13.5H1.5L1.5 9L15 9V13.5H13.5V15H15C15.7767 15 16.4154 14.4097 16.4922 13.6533L16.5 13.5V3L16.4922 2.84668C16.4205 2.14069 15.8593 1.57949 15.1533 1.50781L15 1.5L1.5 1.5C0.671574 1.5 1.28803e-06 2.17157 1.24577e-06 3L7.86804e-07 13.5ZM15 3V7.5L1.5 7.5L1.5 3L15 3Z"/>
5167
- <path d="M7.5 15V18H9V15H12V13.5H9V10.5H7.5V13.5H4.5V15H7.5Z"/>
5168
- </svg>`,
5169
-
5170
- "delete-row":
5171
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
5172
- <path d="M16.4922 12.1533C16.4154 12.9097 15.7767 13.5 15 13.5L12 13.5V12H15V6L1.5 6L1.5 12H4.5V13.5H1.5C0.723337 13.5 0.0846104 12.9097 0.00781328 12.1533L7.86804e-07 12L1.04907e-06 6C1.17362e-06 5.22334 0.590278 4.58461 1.34668 4.50781L1.5 4.5L15 4.5C15.8284 4.5 16.5 5.17157 16.5 6V12L16.4922 12.1533Z"/>
5173
- <path d="M10.3711 15.9316L8.25 13.8096L6.12793 15.9316L5.06738 14.8711L7.18945 12.75L5.06738 10.6289L6.12793 9.56836L8.25 11.6895L10.3711 9.56836L11.4316 10.6289L9.31055 12.75L11.4316 14.8711L10.3711 15.9316Z"/>
5174
- </svg>`,
5175
-
5176
- "toggle-row":
5177
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
5178
- <path fill-rule="evenodd" clip-rule="evenodd" d="M0.00781328 13.6533C0.0846108 14.4097 0.723337 15 1.5 15L15 15L15.1533 14.9922C15.8593 14.9205 16.4205 14.3593 16.4922 13.6533L16.5 13.5V4.5L16.4922 4.34668C16.4205 3.64069 15.8593 3.07949 15.1533 3.00781L15 3L1.5 3C0.671574 3 1.24863e-06 3.67157 1.18021e-06 4.5L7.86804e-07 13.5L0.00781328 13.6533ZM15 9V13.5L1.5 13.5L1.5 9L15 9Z"/>
5179
- </svg>`,
5180
-
5181
- "insert-column-before":
5182
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
5183
- <path fill-rule="evenodd" clip-rule="evenodd" d="M4.5 0C3.67157 0 3 0.671573 3 1.5V3H4.5V1.5H9V15H4.5V13.5H3V15C3 15.7767 3.59028 16.4154 4.34668 16.4922L4.5 16.5H15L15.1533 16.4922C15.8593 16.4205 16.4205 15.8593 16.4922 15.1533L16.5 15V1.5C16.5 0.671573 15.8284 6.03989e-09 15 0H4.5ZM15 15H10.5V1.5H15V15Z"/>
5184
- <path d="M3 7.5H0V9H3V12H4.5V9H7.5V7.5H4.5V4.5H3V7.5Z"/>
5185
- </svg>`,
5186
-
5187
- "insert-column-after":
5188
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
5189
- <path fill-rule="evenodd" clip-rule="evenodd" d="M13.5 0C14.3284 0 15 0.671573 15 1.5V3H13.5V1.5H9V15H13.5V13.5H15V15C15 15.7767 14.4097 16.4154 13.6533 16.4922L13.5 16.5H3L2.84668 16.4922C2.14069 16.4205 1.57949 15.8593 1.50781 15.1533L1.5 15V1.5C1.5 0.671573 2.17157 6.03989e-09 3 0H13.5ZM3 15H7.5V1.5H3V15Z"/>
5190
- <path d="M15 7.5H18V9H15V12H13.5V9H10.5V7.5H13.5V4.5H15V7.5Z"/>
5191
- </svg>`,
5192
5225
 
5193
- "delete-column":
5194
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
5195
- <path d="M12.1533 0.0078125C12.9097 0.0846097 13.5 0.723336 13.5 1.5V4.5H12V1.5H6V15H12V12H13.5V15C13.5 15.7767 12.9097 16.4154 12.1533 16.4922L12 16.5H6C5.22334 16.5 4.58461 15.9097 4.50781 15.1533L4.5 15V1.5C4.5 0.671573 5.17157 2.41596e-08 6 0H12L12.1533 0.0078125Z"/>
5196
- <path d="M15.9316 6.12891L13.8105 8.24902L15.9326 10.3711L14.8711 11.4316L12.75 9.31055L10.6289 11.4316L9.56738 10.3711L11.6885 8.24902L9.56836 6.12891L10.6289 5.06836L12.75 7.18848L14.8711 5.06836L15.9316 6.12891Z"/>
5197
- </svg>`,
5226
+ #resetPopoverPosition() {
5227
+ this.popoverElement.removeAttribute("data-clipped-at-bottom");
5228
+ this.popoverElement.removeAttribute("data-anchored");
5229
+ }
5198
5230
 
5199
- "toggle-column":
5200
- `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
5201
- <path fill-rule="evenodd" clip-rule="evenodd" d="M13.6533 17.9922C14.4097 17.9154 15 17.2767 15 16.5L15 3L14.9922 2.84668C14.9205 2.14069 14.3593 1.57949 13.6533 1.50781L13.5 1.5L4.5 1.5L4.34668 1.50781C3.59028 1.58461 3 2.22334 3 3L3 16.5C3 17.2767 3.59028 17.9154 4.34668 17.9922L4.5 18L13.5 18L13.6533 17.9922ZM9 3L13.5 3L13.5 16.5L9 16.5L9 3Z" />
5202
- </svg>`,
5231
+ async #hidePopover() {
5232
+ this.#clearSelection();
5233
+ this.popoverElement.classList.toggle("lexxy-prompt-menu--visible", false);
5234
+ this.#editorElement.removeEventListener("lexxy:change", this.#filterOptions);
5235
+ this.#editorElement.removeEventListener("keydown", this.#handleKeydownOnPopover);
5203
5236
 
5204
- "delete-table":
5205
- `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
5206
- <path d="M18.2129 19.2305C18.0925 20.7933 16.7892 22 15.2217 22H7.77832C6.21084 22 4.90753 20.7933 4.78711 19.2305L4 9H19L18.2129 19.2305Z"/><path d="M13 2C14.1046 2 15 2.89543 15 4H19C19.5523 4 20 4.44772 20 5V6C20 6.55228 19.5523 7 19 7H4C3.44772 7 3 6.55228 3 6V5C3 4.44772 3.44772 4 4 4H8C8 2.89543 8.89543 2 10 2H13Z"/>
5207
- </svg>`
5208
- };
5237
+ this.#unregisterKeyListeners();
5238
+ this.#removeCursorPositionListener();
5209
5239
 
5210
- class TableTools extends HTMLElement {
5211
- connectedCallback() {
5212
- this.tableController = new TableController(this.#editorElement);
5240
+ await nextFrame();
5241
+ this.#addTriggerListener();
5242
+ }
5213
5243
 
5214
- this.#setUpButtons();
5215
- this.#monitorForTableSelection();
5216
- this.#registerKeyboardShortcuts();
5244
+ #unregisterKeyListeners() {
5245
+ this.keyListeners.forEach((unregister) => unregister());
5246
+ this.keyListeners = [];
5217
5247
  }
5218
5248
 
5219
- disconnectedCallback() {
5220
- this.#unregisterKeyboardShortcuts();
5249
+ #filterOptions = async () => {
5250
+ if (this.initialPrompt) {
5251
+ this.initialPrompt = false;
5252
+ return
5253
+ }
5221
5254
 
5222
- this.unregisterUpdateListener?.();
5223
- this.unregisterUpdateListener = null;
5255
+ if (this.#editorContents.containsTextBackUntil(this.trigger)) {
5256
+ await this.#showFilteredOptions();
5257
+ await nextFrame();
5258
+ this.#positionPopover();
5259
+ } else {
5260
+ this.#hidePopover();
5261
+ }
5262
+ }
5224
5263
 
5225
- this.removeEventListener("keydown", this.#handleToolsKeydown);
5264
+ async #showFilteredOptions() {
5265
+ const filter = this.#editorContents.textBackUntil(this.trigger);
5266
+ const filteredListItems = await this.source.buildListItems(filter);
5267
+ this.popoverElement.innerHTML = "";
5226
5268
 
5227
- this.tableController?.destroy();
5228
- this.tableController = null;
5269
+ if (filteredListItems.length > 0) {
5270
+ this.#showResults(filteredListItems);
5271
+ } else {
5272
+ this.#showEmptyResults();
5273
+ }
5274
+ this.#selectFirstOption();
5229
5275
  }
5230
5276
 
5231
- get #editor() {
5232
- return this.#editorElement.editor
5277
+ #showResults(filteredListItems) {
5278
+ this.popoverElement.classList.remove("lexxy-prompt-menu--empty");
5279
+ this.popoverElement.append(...filteredListItems);
5233
5280
  }
5234
5281
 
5235
- get #editorElement() {
5236
- return this.closest("lexxy-editor")
5282
+ #showEmptyResults() {
5283
+ this.popoverElement.classList.add("lexxy-prompt-menu--empty");
5284
+ const el = createElement("li", { innerHTML: this.#emptyResultsMessage });
5285
+ el.classList.add("lexxy-prompt-menu__item--empty");
5286
+ this.popoverElement.append(el);
5237
5287
  }
5238
5288
 
5239
- get #tableToolsButtons() {
5240
- return Array.from(this.querySelectorAll("button, details > summary"))
5289
+ get #emptyResultsMessage() {
5290
+ return this.getAttribute("empty-results") || NOTHING_FOUND_DEFAULT_MESSAGE
5241
5291
  }
5242
5292
 
5243
- #setUpButtons() {
5244
- this.appendChild(this.#createRowButtonsContainer());
5245
- this.appendChild(this.#createColumnButtonsContainer());
5246
-
5247
- this.appendChild(this.#createDeleteTableButton());
5248
- this.addEventListener("keydown", this.#handleToolsKeydown);
5293
+ #handleKeydownOnPopover = (event) => {
5294
+ if (event.key === "Escape") {
5295
+ this.#hidePopover();
5296
+ this.#editorElement.focus();
5297
+ event.stopPropagation();
5298
+ }
5299
+ // Arrow keys are now handled via Lexical commands with HIGH priority
5249
5300
  }
5250
5301
 
5251
- #createButtonsContainer(childType, setCountProperty, moreMenu) {
5252
- const container = createElement("div", { className: `lexxy-table-control lexxy-table-control--${childType}` });
5253
-
5254
- const plusButton = this.#createButton(`Add ${childType}`, { action: "insert", childType, direction: "after" }, "+");
5255
- const minusButton = this.#createButton(`Remove ${childType}`, { action: "delete", childType }, "−");
5256
-
5257
- const dropdown = createElement("details", { className: "lexxy-table-control__more-menu" });
5258
- dropdown.setAttribute("name", "lexxy-dropdown");
5259
- dropdown.tabIndex = -1;
5260
-
5261
- const count = createElement("summary", {}, `_ ${childType}s`);
5262
- setCountProperty(count);
5263
- dropdown.appendChild(count);
5264
-
5265
- dropdown.appendChild(moreMenu);
5266
-
5267
- container.appendChild(minusButton);
5268
- container.appendChild(dropdown);
5269
- container.appendChild(plusButton);
5270
-
5271
- return container
5302
+ #moveSelectionDown() {
5303
+ const nextIndex = this.#selectedIndex + 1;
5304
+ if (nextIndex < this.#listItemElements.length) this.#selectOption(this.#listItemElements[nextIndex]);
5272
5305
  }
5273
5306
 
5274
- #createRowButtonsContainer() {
5275
- return this.#createButtonsContainer(
5276
- "row",
5277
- (count) => { this.rowCount = count; },
5278
- this.#createMoreMenuSection("row")
5279
- )
5307
+ #moveSelectionUp() {
5308
+ const previousIndex = this.#selectedIndex - 1;
5309
+ if (previousIndex >= 0) this.#selectOption(this.#listItemElements[previousIndex]);
5280
5310
  }
5281
5311
 
5282
- #createColumnButtonsContainer() {
5283
- return this.#createButtonsContainer(
5284
- "column",
5285
- (count) => { this.columnCount = count; },
5286
- this.#createMoreMenuSection("column")
5287
- )
5312
+ get #selectedIndex() {
5313
+ return this.#listItemElements.findIndex((item) => item.hasAttribute("aria-selected"))
5288
5314
  }
5289
5315
 
5290
- #createMoreMenuSection(childType) {
5291
- const section = createElement("div", { className: "lexxy-table-control__more-menu-details" });
5292
- const addBeforeButton = this.#createButton(`Add ${childType} before`, { action: "insert", childType, direction: "before" });
5293
- const addAfterButton = this.#createButton(`Add ${childType} after`, { action: "insert", childType, direction: "after" });
5294
- const toggleStyleButton = this.#createButton(`Toggle ${childType} style`, { action: "toggle", childType });
5295
- const deleteButton = this.#createButton(`Remove ${childType}`, { action: "delete", childType });
5316
+ get #selectedListItem() {
5317
+ return this.#listItemElements[this.#selectedIndex]
5318
+ }
5296
5319
 
5297
- section.appendChild(addBeforeButton);
5298
- section.appendChild(addAfterButton);
5299
- section.appendChild(toggleStyleButton);
5300
- section.appendChild(deleteButton);
5320
+ #handleSelectedOption(event) {
5321
+ event.preventDefault();
5322
+ event.stopPropagation();
5323
+ this.#optionWasSelected();
5324
+ return true
5325
+ }
5301
5326
 
5302
- return section
5327
+ #optionWasSelected() {
5328
+ this.#replaceTriggerWithSelectedItem();
5329
+ this.#hidePopover();
5330
+ this.#editorElement.focus();
5303
5331
  }
5304
5332
 
5305
- #createDeleteTableButton() {
5306
- const container = createElement("div", { className: "lexxy-table-control" });
5333
+ #replaceTriggerWithSelectedItem() {
5334
+ const promptItem = this.source.promptItemFor(this.#selectedListItem);
5307
5335
 
5308
- const deleteTableButton = this.#createButton("Delete this table?", { action: "delete", childType: "table" });
5309
- deleteTableButton.classList.add("lexxy-table-control__button--delete-table");
5336
+ if (!promptItem) { return }
5310
5337
 
5311
- container.appendChild(deleteTableButton);
5338
+ const templates = Array.from(promptItem.querySelectorAll("template[type='editor']"));
5339
+ const stringToReplace = `${this.trigger}${this.#editorContents.textBackUntil(this.trigger)}`;
5312
5340
 
5313
- this.deleteContainer = container;
5341
+ if (this.hasAttribute("insert-editable-text")) {
5342
+ this.#insertTemplatesAsEditableText(templates, stringToReplace);
5343
+ } else {
5344
+ this.#insertTemplatesAsAttachments(templates, stringToReplace, promptItem.getAttribute("sgid"));
5345
+ }
5346
+ }
5314
5347
 
5315
- return container
5348
+ #insertTemplatesAsEditableText(templates, stringToReplace) {
5349
+ this.#editor.update(() => {
5350
+ const nodes = templates.flatMap(template => this.#buildEditableTextNodes(template));
5351
+ this.#editorContents.replaceTextBackUntil(stringToReplace, nodes);
5352
+ });
5316
5353
  }
5317
5354
 
5318
- #createButton(label, command = {}, icon = this.#icon(command)) {
5319
- const button = createElement("button", {
5320
- className: "lexxy-table-control__button",
5321
- "aria-label": label,
5322
- type: "button"
5355
+ #buildEditableTextNodes(template) {
5356
+ return $generateNodesFromDOM(this.#editor, parseHtml(`${template.innerHTML}`))
5357
+ }
5358
+
5359
+ #insertTemplatesAsAttachments(templates, stringToReplace, fallbackSgid = null) {
5360
+ this.#editor.update(() => {
5361
+ const attachmentNodes = this.#buildAttachmentNodes(templates, fallbackSgid);
5362
+ const spacedAttachmentNodes = attachmentNodes.flatMap(node => [ node, this.#getSpacerTextNode() ]).slice(0, -1);
5363
+ this.#editorContents.replaceTextBackUntil(stringToReplace, spacedAttachmentNodes);
5323
5364
  });
5324
- button.tabIndex = -1;
5325
- button.innerHTML = `${icon} <span>${label}</span>`;
5365
+ }
5366
+
5367
+ #buildAttachmentNodes(templates, fallbackSgid = null) {
5368
+ return templates.map(
5369
+ template => this.#buildAttachmentNode(
5370
+ template.innerHTML,
5371
+ template.getAttribute("content-type") || this.#defaultPromptContentType,
5372
+ template.getAttribute("sgid") || fallbackSgid
5373
+ ))
5374
+ }
5326
5375
 
5327
- button.dataset.action = command.action;
5328
- button.dataset.childType = command.childType;
5329
- button.dataset.direction = command.direction;
5376
+ #getSpacerTextNode() {
5377
+ return $createTextNode(" ")
5378
+ }
5330
5379
 
5331
- button.addEventListener("click", () => this.#executeTableCommand(command));
5380
+ get #defaultPromptContentType() {
5381
+ const attachmentContentTypeNamespace = Lexxy.global.get("attachmentContentTypeNamespace");
5382
+ return `application/vnd.${attachmentContentTypeNamespace}.${this.name}`
5383
+ }
5332
5384
 
5333
- button.addEventListener("mouseover", () => this.#handleCommandButtonHover());
5334
- button.addEventListener("focus", () => this.#handleCommandButtonHover());
5335
- button.addEventListener("mouseout", () => this.#handleCommandButtonHover());
5385
+ #buildAttachmentNode(innerHtml, contentType, sgid) {
5386
+ return new CustomActionTextAttachmentNode({ sgid, contentType, innerHtml })
5387
+ }
5336
5388
 
5337
- return button
5389
+ get #editorContents() {
5390
+ return this.#editorElement.contents
5338
5391
  }
5339
5392
 
5340
- #registerKeyboardShortcuts() {
5341
- this.unregisterKeyboardShortcuts = this.#editor.registerCommand(KEY_DOWN_COMMAND, this.#handleAccessibilityShortcutKey, COMMAND_PRIORITY_HIGH);
5393
+ get #editorContentElement() {
5394
+ return this.#editorElement.editorContentElement
5342
5395
  }
5343
5396
 
5344
- #unregisterKeyboardShortcuts() {
5345
- this.unregisterKeyboardShortcuts?.();
5346
- this.unregisterKeyboardShortcuts = null;
5397
+ async #buildPopover() {
5398
+ const popoverContainer = createElement("ul", { role: "listbox", id: generateDomId("prompt-popover") }); // Avoiding [popover] due to not being able to position at an arbitrary X, Y position.
5399
+ popoverContainer.classList.add("lexxy-prompt-menu");
5400
+ popoverContainer.style.position = "absolute";
5401
+ popoverContainer.setAttribute("nonce", getNonce());
5402
+ popoverContainer.append(...await this.source.buildListItems());
5403
+ popoverContainer.addEventListener("click", this.#handlePopoverClick);
5404
+ this.#editorElement.appendChild(popoverContainer);
5405
+ return popoverContainer
5347
5406
  }
5348
5407
 
5349
- #handleAccessibilityShortcutKey = (event) => {
5350
- if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === "F10") {
5351
- const firstButton = this.querySelector("button, [tabindex]:not([tabindex='-1'])");
5352
- firstButton?.focus();
5408
+ #handlePopoverClick = (event) => {
5409
+ const listItem = event.target.closest(".lexxy-prompt-menu__item");
5410
+ if (listItem) {
5411
+ this.#selectOption(listItem);
5412
+ this.#optionWasSelected();
5353
5413
  }
5354
5414
  }
5355
5415
 
5356
- #handleToolsKeydown = (event) => {
5357
- if (event.key === "Escape") {
5358
- this.#handleEscapeKey();
5359
- } else {
5360
- handleRollingTabIndex(this.#tableToolsButtons, event);
5361
- }
5416
+ #reconnect() {
5417
+ this.disconnectedCallback();
5418
+ this.connectedCallback();
5362
5419
  }
5420
+ }
5363
5421
 
5364
- #handleEscapeKey() {
5365
- const cell = this.tableController.currentCell;
5366
- if (!cell) return
5422
+ class CodeLanguagePicker extends HTMLElement {
5423
+ connectedCallback() {
5424
+ this.editorElement = this.closest("lexxy-editor");
5425
+ this.editor = this.editorElement.editor;
5367
5426
 
5368
- this.#editor.update(() => {
5369
- cell.select();
5370
- this.#editor.focus();
5427
+ this.#attachLanguagePicker();
5428
+ this.#monitorForCodeBlockSelection();
5429
+ }
5430
+
5431
+ #attachLanguagePicker() {
5432
+ this.languagePickerElement = this.#createLanguagePicker();
5433
+
5434
+ this.languagePickerElement.addEventListener("change", () => {
5435
+ this.#updateCodeBlockLanguage(this.languagePickerElement.value);
5371
5436
  });
5372
5437
 
5373
- this.#update();
5438
+ this.languagePickerElement.setAttribute("nonce", getNonce());
5439
+ this.appendChild(this.languagePickerElement);
5374
5440
  }
5375
5441
 
5376
- async #handleCommandButtonHover() {
5377
- await nextFrame();
5442
+ #createLanguagePicker() {
5443
+ const selectElement = createElement("select", { className: "lexxy-code-language-picker", "aria-label": "Pick a language…", name: "lexxy-code-language" });
5378
5444
 
5379
- this.#clearCellStyles();
5445
+ for (const [ value, label ] of Object.entries(this.#languages)) {
5446
+ const option = document.createElement("option");
5447
+ option.value = value;
5448
+ option.textContent = label;
5449
+ selectElement.appendChild(option);
5450
+ }
5380
5451
 
5381
- const activeElement = this.querySelector("button:hover, button:focus");
5382
- if (!activeElement) return
5452
+ return selectElement
5453
+ }
5383
5454
 
5384
- const command = {
5385
- action: activeElement.dataset.action,
5386
- childType: activeElement.dataset.childType,
5387
- direction: activeElement.dataset.direction
5388
- };
5455
+ get #languages() {
5456
+ const languages = { ...CODE_LANGUAGE_FRIENDLY_NAME_MAP };
5389
5457
 
5390
- let cellsToHighlight = null;
5458
+ if (!languages.ruby) languages.ruby = "Ruby";
5459
+ if (!languages.php) languages.php = "PHP";
5460
+ if (!languages.go) languages.go = "Go";
5461
+ if (!languages.bash) languages.bash = "Bash";
5462
+ if (!languages.json) languages.json = "JSON";
5463
+ if (!languages.diff) languages.diff = "Diff";
5391
5464
 
5392
- switch (command.childType) {
5393
- case "row":
5394
- cellsToHighlight = this.tableController.currentRowCells;
5395
- break
5396
- case "column":
5397
- cellsToHighlight = this.tableController.currentColumnCells;
5398
- break
5399
- case "table":
5400
- cellsToHighlight = this.tableController.tableRows;
5401
- break
5402
- }
5465
+ const sortedEntries = Object.entries(languages)
5466
+ .sort(([ , a ], [ , b ]) => a.localeCompare(b));
5403
5467
 
5404
- if (!cellsToHighlight) return
5468
+ // Place the "plain" entry first, then the rest of language sorted alphabetically
5469
+ const plainIndex = sortedEntries.findIndex(([ key ]) => key === "plain");
5470
+ const plainEntry = sortedEntries.splice(plainIndex, 1)[0];
5471
+ return Object.fromEntries([ plainEntry, ...sortedEntries ])
5472
+ }
5405
5473
 
5406
- cellsToHighlight.forEach(cell => {
5407
- const cellElement = this.#editor.getElementByKey(cell.getKey());
5408
- if (!cellElement) return
5474
+ #updateCodeBlockLanguage(language) {
5475
+ this.editor.update(() => {
5476
+ const codeNode = this.#getCurrentCodeNode();
5409
5477
 
5410
- cellElement.classList.toggle(theme.tableCellHighlight, true);
5411
- Object.assign(cellElement.dataset, command);
5478
+ if (codeNode) {
5479
+ codeNode.setLanguage(language);
5480
+ }
5412
5481
  });
5413
5482
  }
5414
5483
 
5415
- #monitorForTableSelection() {
5416
- this.unregisterUpdateListener = this.#editor.registerUpdateListener(() => {
5417
- this.tableController.updateSelectedTable();
5484
+ #monitorForCodeBlockSelection() {
5485
+ this.editor.registerUpdateListener(() => {
5486
+ this.editor.getEditorState().read(() => {
5487
+ const codeNode = this.#getCurrentCodeNode();
5418
5488
 
5419
- const tableNode = this.tableController.currentTableNode;
5420
- if (tableNode) {
5421
- this.#show();
5422
- } else {
5423
- this.#hide();
5424
- }
5489
+ if (codeNode) {
5490
+ this.#codeNodeWasSelected(codeNode);
5491
+ } else {
5492
+ this.#hideLanguagePicker();
5493
+ }
5494
+ });
5425
5495
  });
5426
5496
  }
5427
5497
 
5428
- #executeTableCommand(command) {
5429
- this.tableController.executeTableCommand(command);
5430
- this.#update();
5431
- }
5498
+ #getCurrentCodeNode() {
5499
+ const selection = $getSelection();
5432
5500
 
5433
- #show() {
5434
- this.style.display = "flex";
5435
- this.#update();
5436
- }
5501
+ if (!$isRangeSelection(selection)) {
5502
+ return null
5503
+ }
5437
5504
 
5438
- #hide() {
5439
- this.style.display = "none";
5440
- this.#clearCellStyles();
5441
- }
5505
+ const anchorNode = selection.anchor.getNode();
5506
+ const parentNode = anchorNode.getParent();
5442
5507
 
5443
- #update() {
5444
- this.#updateButtonsPosition();
5445
- this.#updateRowColumnCount();
5446
- this.#closeMoreMenu();
5447
- this.#handleCommandButtonHover();
5508
+ if ($isCodeNode(anchorNode)) {
5509
+ return anchorNode
5510
+ } else if ($isCodeNode(parentNode)) {
5511
+ return parentNode
5512
+ }
5513
+
5514
+ return null
5448
5515
  }
5449
5516
 
5450
- #closeMoreMenu() {
5451
- this.querySelector("details[open]")?.removeAttribute("open");
5517
+ #codeNodeWasSelected(codeNode) {
5518
+ const language = codeNode.getLanguage();
5519
+
5520
+ this.#updateLanguagePickerWith(language);
5521
+ this.#showLanguagePicker();
5522
+ this.#positionLanguagePicker(codeNode);
5452
5523
  }
5453
5524
 
5454
- #updateButtonsPosition() {
5455
- const tableNode = this.tableController.currentTableNode;
5456
- if (!tableNode) return
5525
+ #updateLanguagePickerWith(language) {
5526
+ if (this.languagePickerElement && language) {
5527
+ const normalizedLanguage = normalizeCodeLang(language);
5528
+ this.languagePickerElement.value = normalizedLanguage;
5529
+ }
5530
+ }
5457
5531
 
5458
- const tableElement = this.#editor.getElementByKey(tableNode.getKey());
5459
- if (!tableElement) return
5532
+ #positionLanguagePicker(codeNode) {
5533
+ const codeElement = this.editor.getElementByKey(codeNode.getKey());
5534
+ if (!codeElement) return
5460
5535
 
5461
- const tableRect = tableElement.getBoundingClientRect();
5462
- const editorRect = this.#editorElement.getBoundingClientRect();
5536
+ const codeRect = codeElement.getBoundingClientRect();
5537
+ const editorRect = this.editorElement.getBoundingClientRect();
5538
+ const relativeTop = codeRect.top - editorRect.top;
5539
+ const relativeRight = editorRect.right - codeRect.right;
5463
5540
 
5464
- const relativeTop = tableRect.top - editorRect.top;
5465
- const relativeCenter = (tableRect.left + tableRect.right) / 2 - editorRect.left;
5466
5541
  this.style.top = `${relativeTop}px`;
5467
- this.style.left = `${relativeCenter}px`;
5542
+ this.style.right = `${relativeRight}px`;
5468
5543
  }
5469
5544
 
5470
- #updateRowColumnCount() {
5471
- const tableNode = this.tableController.currentTableNode;
5472
- if (!tableNode) return
5545
+ #showLanguagePicker() {
5546
+ this.hidden = false;
5547
+ }
5473
5548
 
5474
- const tableElement = $getElementForTableNode(this.#editor, tableNode);
5475
- if (!tableElement) return
5549
+ #hideLanguagePicker() {
5550
+ this.hidden = true;
5551
+ }
5552
+ }
5476
5553
 
5477
- const rowCount = tableElement.rows;
5478
- const columnCount = tableElement.columns;
5554
+ class TableController {
5555
+ constructor(editorElement) {
5556
+ this.editor = editorElement.editor;
5557
+ this.contents = editorElement.contents;
5558
+ this.selection = editorElement.selection;
5479
5559
 
5480
- this.rowCount.textContent = `${rowCount} row${rowCount === 1 ? "" : "s"}`;
5481
- this.columnCount.textContent = `${columnCount} column${columnCount === 1 ? "" : "s"}`;
5482
- }
5560
+ this.currentTableNodeKey = null;
5561
+ this.currentCellKey = null;
5483
5562
 
5484
- #setTableCellFocus() {
5485
- const cell = this.tableController.currentCell;
5486
- if (!cell) return
5563
+ this.#registerKeyHandlers();
5564
+ }
5487
5565
 
5488
- const cellElement = this.#editor.getElementByKey(cell.getKey());
5489
- if (!cellElement) return
5566
+ destroy() {
5567
+ this.currentTableNodeKey = null;
5568
+ this.currentCellKey = null;
5490
5569
 
5491
- cellElement.classList.add(theme.tableCellFocus);
5570
+ this.#unregisterKeyHandlers();
5492
5571
  }
5493
5572
 
5494
- #clearCellStyles() {
5495
- this.#editorElement.querySelectorAll(`.${theme.tableCellFocus}`)?.forEach(cell => {
5496
- cell.classList.remove(theme.tableCellFocus);
5497
- });
5498
-
5499
- this.#editorElement.querySelectorAll(`.${theme.tableCellHighlight}`)?.forEach(cell => {
5500
- cell.classList.remove(theme.tableCellHighlight);
5501
- cell.removeAttribute("data-action");
5502
- cell.removeAttribute("data-child-type");
5503
- cell.removeAttribute("data-direction");
5504
- });
5573
+ get currentCell() {
5574
+ if (!this.currentCellKey) return null
5505
5575
 
5506
- this.#setTableCellFocus();
5576
+ return this.editor.getEditorState().read(() => {
5577
+ const cell = $getNodeByKey(this.currentCellKey);
5578
+ return (cell instanceof TableCellNode) ? cell : null
5579
+ })
5507
5580
  }
5508
5581
 
5509
- #icon(command) {
5510
- const { action, childType } = command;
5511
- const direction = (action == "insert" ? command.direction : null);
5512
- const iconId = [ action, childType, direction ].filter(Boolean).join("-");
5513
- return TableIcons[iconId]
5582
+ get currentTableNode() {
5583
+ if (!this.currentTableNodeKey) return null
5584
+
5585
+ return this.editor.getEditorState().read(() => {
5586
+ const tableNode = $getNodeByKey(this.currentTableNodeKey);
5587
+ return (tableNode instanceof TableNode) ? tableNode : null
5588
+ })
5514
5589
  }
5515
- }
5516
5590
 
5517
- customElements.define("lexxy-table-tools", TableTools);
5591
+ get currentRowCells() {
5592
+ const currentRowIndex = this.currentRowIndex;
5518
5593
 
5519
- class BaseSource {
5520
- // Template method to override
5521
- async buildListItems(filter = "") {
5522
- return Promise.resolve([])
5523
- }
5594
+ const rows = this.tableRows;
5595
+ if (!rows) return null
5524
5596
 
5525
- // Template method to override
5526
- promptItemFor(listItem) {
5527
- return null
5597
+ return this.editor.getEditorState().read(() => {
5598
+ return rows[currentRowIndex]?.getChildren() ?? null
5599
+ }) ?? null
5528
5600
  }
5529
5601
 
5530
- // Protected
5602
+ get currentRowIndex() {
5603
+ const currentCell = this.currentCell;
5604
+ if (!currentCell) return 0
5531
5605
 
5532
- buildListItemElementFor(promptItemElement) {
5533
- const template = promptItemElement.querySelector("template[type='menu']");
5534
- const fragment = template.content.cloneNode(true);
5535
- const listItemElement = createElement("li", { role: "option", id: generateDomId("prompt-item"), tabindex: "0" });
5536
- listItemElement.classList.add("lexxy-prompt-menu__item");
5537
- listItemElement.appendChild(fragment);
5538
- return listItemElement
5606
+ return this.editor.getEditorState().read(() => {
5607
+ return $getTableRowIndexFromTableCellNode(currentCell)
5608
+ }) ?? 0
5539
5609
  }
5540
5610
 
5541
- async loadPromptItemsFromUrl(url) {
5542
- try {
5543
- const response = await fetch(url);
5544
- const html = await response.text();
5545
- const promptItems = parseHtml(html).querySelectorAll("lexxy-prompt-item");
5546
- return Promise.resolve(Array.from(promptItems))
5547
- } catch (error) {
5548
- return Promise.reject(error)
5549
- }
5550
- }
5551
- }
5611
+ get currentColumnCells() {
5612
+ const columnIndex = this.currentColumnIndex;
5552
5613
 
5553
- class LocalFilterSource extends BaseSource {
5554
- async buildListItems(filter = "") {
5555
- const promptItems = await this.fetchPromptItems();
5556
- return this.#buildListItemsFromPromptItems(promptItems, filter)
5614
+ const rows = this.tableRows;
5615
+ if (!rows) return null
5616
+
5617
+ return this.editor.getEditorState().read(() => {
5618
+ return rows.map(row => row.getChildAtIndex(columnIndex))
5619
+ }) ?? null
5557
5620
  }
5558
5621
 
5559
- // Template method to override
5560
- async fetchPromptItems(filter) {
5561
- return Promise.resolve([])
5622
+ get currentColumnIndex() {
5623
+ const currentCell = this.currentCell;
5624
+ if (!currentCell) return 0
5625
+
5626
+ return this.editor.getEditorState().read(() => {
5627
+ return $getTableColumnIndexFromTableCellNode(currentCell)
5628
+ }) ?? 0
5562
5629
  }
5563
5630
 
5564
- promptItemFor(listItem) {
5565
- return this.promptItemByListItem.get(listItem)
5631
+ get tableRows() {
5632
+ return this.editor.getEditorState().read(() => {
5633
+ return this.currentTableNode?.getChildren()
5634
+ }) ?? null
5566
5635
  }
5567
5636
 
5568
- #buildListItemsFromPromptItems(promptItems, filter) {
5569
- const listItems = [];
5570
- this.promptItemByListItem = new WeakMap();
5571
- promptItems.forEach((promptItem) => {
5572
- const searchableText = promptItem.getAttribute("search");
5637
+ updateSelectedTable() {
5638
+ let cellNode = null;
5639
+ let tableNode = null;
5573
5640
 
5574
- if (!filter || filterMatches(searchableText, filter)) {
5575
- const listItem = this.buildListItemElementFor(promptItem);
5576
- this.promptItemByListItem.set(listItem, promptItem);
5577
- listItems.push(listItem);
5578
- }
5641
+ this.editor.getEditorState().read(() => {
5642
+ const selection = $getSelection();
5643
+ if (!selection || !this.selection.isTableCellSelected) return
5644
+
5645
+ const node = selection.getNodes()[0];
5646
+
5647
+ cellNode = $findCellNode(node);
5648
+ tableNode = $findTableNode(node);
5579
5649
  });
5580
5650
 
5581
- return listItems
5651
+ this.currentCellKey = cellNode?.getKey() ?? null;
5652
+ this.currentTableNodeKey = tableNode?.getKey() ?? null;
5582
5653
  }
5583
- }
5584
5654
 
5585
- class InlinePromptSource extends LocalFilterSource {
5586
- constructor(inlinePromptItems) {
5587
- super();
5588
- this.inlinePromptItemElements = Array.from(inlinePromptItems);
5655
+ executeTableCommand(command, customIndex = null) {
5656
+ if (command.action === "delete" && command.childType === "table") {
5657
+ this.#deleteTable();
5658
+ return
5659
+ }
5660
+
5661
+ if (command.action === "toggle") {
5662
+ this.#executeToggleStyle(command);
5663
+ return
5664
+ }
5665
+
5666
+ this.#executeCommand(command, customIndex);
5589
5667
  }
5590
5668
 
5591
- async fetchPromptItems() {
5592
- return Promise.resolve(this.inlinePromptItemElements)
5669
+ #executeCommand(command, customIndex = null) {
5670
+ this.#selectCellAtSelection();
5671
+ this.editor.dispatchCommand(this.#commandName(command));
5672
+ this.#selectNextBestCell(command, customIndex);
5593
5673
  }
5594
- }
5595
5674
 
5596
- class DeferredPromptSource extends LocalFilterSource {
5597
- constructor(url) {
5598
- super();
5599
- this.url = url;
5675
+ #executeToggleStyle(command) {
5676
+ const childType = command.childType;
5600
5677
 
5601
- this.fetchPromptItems();
5602
- }
5678
+ let cells = null;
5679
+ let headerState = null;
5603
5680
 
5604
- async fetchPromptItems() {
5605
- this.promptItems ??= await this.loadPromptItemsFromUrl(this.url);
5681
+ if (childType === "row") {
5682
+ cells = this.currentRowCells;
5683
+ headerState = TableCellHeaderStates.ROW;
5684
+ } else if (childType === "column") {
5685
+ cells = this.currentColumnCells;
5686
+ headerState = TableCellHeaderStates.COLUMN;
5687
+ }
5606
5688
 
5607
- return Promise.resolve(this.promptItems)
5608
- }
5609
- }
5689
+ if (!cells || cells.length === 0) return
5610
5690
 
5611
- const DEBOUNCE_INTERVAL = 200;
5691
+ this.editor.update(() => {
5692
+ const firstCell = $getTableCellNodeFromLexicalNode(cells[0]);
5693
+ if (!firstCell) return
5612
5694
 
5613
- class RemoteFilterSource extends BaseSource {
5614
- constructor(url) {
5615
- super();
5695
+ const currentStyle = firstCell.getHeaderStyles();
5696
+ const newStyle = currentStyle ^ headerState;
5616
5697
 
5617
- this.baseURL = url;
5618
- this.loadAndFilterListItems = debounceAsync(this.fetchFilteredListItems.bind(this), DEBOUNCE_INTERVAL);
5698
+ cells.forEach(cell => {
5699
+ this.#setHeaderStyle(cell, newStyle, headerState);
5700
+ });
5701
+ });
5619
5702
  }
5620
5703
 
5621
- async buildListItems(filter = "") {
5622
- return await this.loadAndFilterListItems(filter)
5704
+ #deleteTable() {
5705
+ this.#selectCellAtSelection();
5706
+ this.editor.dispatchCommand("deleteTable");
5623
5707
  }
5624
5708
 
5625
- promptItemFor(listItem) {
5626
- return this.promptItemByListItem.get(listItem)
5627
- }
5709
+ #selectCellAtSelection() {
5710
+ this.editor.update(() => {
5711
+ const selection = $getSelection();
5712
+ if (!selection) return
5628
5713
 
5629
- async fetchFilteredListItems(filter) {
5630
- const promptItems = await this.loadPromptItemsFromUrl(this.#urlFor(filter));
5631
- return this.#buildListItemsFromPromptItems(promptItems)
5632
- }
5714
+ const node = selection.getNodes()[0];
5633
5715
 
5634
- #urlFor(filter) {
5635
- const url = new URL(this.baseURL, window.location.origin);
5636
- url.searchParams.append("filter", filter);
5637
- return url.toString()
5716
+ $findCellNode(node)?.selectEnd();
5717
+ });
5638
5718
  }
5639
5719
 
5640
- #buildListItemsFromPromptItems(promptItems) {
5641
- const listItems = [];
5642
- this.promptItemByListItem = new WeakMap();
5643
-
5644
- for (const promptItem of promptItems) {
5645
- const listItem = this.buildListItemElementFor(promptItem);
5646
- this.promptItemByListItem.set(listItem, promptItem);
5647
- listItems.push(listItem);
5648
- }
5720
+ #commandName(command) {
5721
+ const { action, childType, direction } = command;
5649
5722
 
5650
- return listItems
5723
+ const childTypeSuffix = upcaseFirst(childType);
5724
+ const directionSuffix = action == "insert" ? upcaseFirst(direction) : "";
5725
+ return `${action}Table${childTypeSuffix}${directionSuffix}`
5651
5726
  }
5652
- }
5653
-
5654
- const NOTHING_FOUND_DEFAULT_MESSAGE = "Nothing found";
5655
5727
 
5656
- class LexicalPromptElement extends HTMLElement {
5657
- constructor() {
5658
- super();
5659
- this.keyListeners = [];
5728
+ #setHeaderStyle(cell, newStyle, headerState) {
5729
+ const tableCellNode = $getTableCellNodeFromLexicalNode(cell);
5730
+ tableCellNode?.setHeaderStyles(newStyle, headerState);
5660
5731
  }
5661
5732
 
5662
- static observedAttributes = [ "connected" ]
5733
+ async #selectCellAtIndex(rowIndex, columnIndex) {
5734
+ // We wait for next frame, otherwise table operations might not have completed yet.
5735
+ await nextFrame();
5663
5736
 
5664
- connectedCallback() {
5665
- this.source = this.#createSource();
5737
+ if (!this.currentTableNode) return
5666
5738
 
5667
- this.#addTriggerListener();
5668
- this.toggleAttribute("connected", true);
5669
- }
5739
+ const rows = this.tableRows;
5740
+ if (!rows) return
5670
5741
 
5671
- disconnectedCallback() {
5672
- this.source = null;
5673
- this.popoverElement = null;
5742
+ const row = rows[rowIndex];
5743
+ if (!row) return
5744
+
5745
+ this.editor.update(() => {
5746
+ const cell = $getTableCellNodeFromLexicalNode(row.getChildAtIndex(columnIndex));
5747
+ cell?.selectEnd();
5748
+ });
5674
5749
  }
5675
5750
 
5751
+ #selectNextBestCell(command, customIndex = null) {
5752
+ const { childType, direction } = command;
5676
5753
 
5677
- attributeChangedCallback(name, oldValue, newValue) {
5678
- if (name === "connected" && this.isConnected && oldValue != null && oldValue !== newValue) {
5679
- requestAnimationFrame(() => this.#reconnect());
5754
+ let rowIndex = this.currentRowIndex;
5755
+ let columnIndex = customIndex !== null ? customIndex : this.currentColumnIndex;
5756
+
5757
+ const deleteOffset = command.action === "delete" ? -1 : 0;
5758
+ const offset = direction === "after" ? 1 : deleteOffset;
5759
+
5760
+ if (childType === "row") {
5761
+ rowIndex += offset;
5762
+ } else if (childType === "column") {
5763
+ columnIndex += offset;
5680
5764
  }
5681
- }
5682
5765
 
5683
- get name() {
5684
- return this.getAttribute("name")
5766
+ this.#selectCellAtIndex(rowIndex, columnIndex);
5685
5767
  }
5686
5768
 
5687
- get trigger() {
5688
- return this.getAttribute("trigger")
5689
- }
5769
+ #selectNextRow() {
5770
+ const rows = this.tableRows;
5771
+ if (!rows) return
5690
5772
 
5691
- get supportsSpaceInSearches() {
5692
- return this.hasAttribute("supports-space-in-searches")
5773
+ const nextRow = rows.at(this.currentRowIndex + 1);
5774
+ if (!nextRow) return
5775
+
5776
+ this.editor.update(() => {
5777
+ nextRow.getChildAtIndex(this.currentColumnIndex)?.selectEnd();
5778
+ });
5693
5779
  }
5694
5780
 
5695
- get open() {
5696
- return this.popoverElement?.classList?.contains("lexxy-prompt-menu--visible")
5781
+ #selectPreviousCell() {
5782
+ const cell = this.currentCell;
5783
+ if (!cell) return
5784
+
5785
+ this.editor.update(() => {
5786
+ cell.selectPrevious();
5787
+ });
5697
5788
  }
5698
5789
 
5699
- get closed() {
5700
- return !this.open
5790
+ #insertRowAndSelectFirstCell() {
5791
+ this.executeTableCommand({ action: "insert", childType: "row", direction: "after" }, 0);
5701
5792
  }
5702
5793
 
5703
- get #doesSpaceSelect() {
5704
- return !this.supportsSpaceInSearches
5794
+ #deleteRowAndSelectLastCell() {
5795
+ this.executeTableCommand({ action: "delete", childType: "row" }, -1);
5705
5796
  }
5706
5797
 
5707
- #createSource() {
5708
- const src = this.getAttribute("src");
5709
- if (src) {
5710
- if (this.hasAttribute("remote-filtering")) {
5711
- return new RemoteFilterSource(src)
5798
+ #deleteRowAndSelectNextNode() {
5799
+ const tableNode = this.currentTableNode;
5800
+ this.executeTableCommand({ action: "delete", childType: "row" });
5801
+
5802
+ this.editor.update(() => {
5803
+ const next = tableNode?.getNextSibling();
5804
+ if ($isParagraphNode(next)) {
5805
+ next.selectStart();
5712
5806
  } else {
5713
- return new DeferredPromptSource(src)
5807
+ const newParagraph = $createParagraphNode();
5808
+ this.currentTableNode.insertAfter(newParagraph);
5809
+ newParagraph.selectStart();
5714
5810
  }
5715
- } else {
5716
- return new InlinePromptSource(this.querySelectorAll("lexxy-prompt-item"))
5717
- }
5811
+ });
5718
5812
  }
5719
5813
 
5720
- #addTriggerListener() {
5721
- const unregister = this.#editor.registerUpdateListener(({ editorState }) => {
5722
- editorState.read(() => {
5723
- const { node, offset } = this.#selection.selectedNodeWithOffset();
5724
- if (!node) return
5814
+ #isCurrentCellEmpty() {
5815
+ if (!this.currentTableNode) return false
5725
5816
 
5726
- if ($isTextNode(node)) {
5727
- const fullText = node.getTextContent();
5728
- const triggerLength = this.trigger.length;
5817
+ const cell = this.currentCell;
5818
+ if (!cell) return false
5729
5819
 
5730
- // Check if we have enough characters for the trigger
5731
- if (offset >= triggerLength) {
5732
- const textBeforeCursor = fullText.slice(offset - triggerLength, offset);
5820
+ return cell.getTextContent().trim() === ""
5821
+ }
5733
5822
 
5734
- // Check if trigger is at the start of the text node (new line case) or preceded by space or newline
5735
- if (textBeforeCursor === this.trigger) {
5736
- const isAtStart = offset === triggerLength;
5823
+ #isCurrentRowLast() {
5824
+ if (!this.currentTableNode) return false
5737
5825
 
5738
- const charBeforeTrigger = offset > triggerLength ? fullText[offset - triggerLength - 1] : null;
5739
- const isPrecededBySpaceOrNewline = charBeforeTrigger === " " || charBeforeTrigger === "\n";
5826
+ const rows = this.tableRows;
5827
+ if (!rows) return false
5740
5828
 
5741
- if (isAtStart || isPrecededBySpaceOrNewline) {
5742
- unregister();
5743
- this.#showPopover();
5744
- }
5745
- }
5746
- }
5747
- }
5748
- });
5749
- });
5829
+ return rows.length === this.currentRowIndex + 1
5750
5830
  }
5751
5831
 
5752
- #addCursorPositionListener() {
5753
- this.cursorPositionListener = this.#editor.registerUpdateListener(() => {
5754
- if (this.closed) return
5755
-
5756
- this.#editor.read(() => {
5757
- const { node, offset } = this.#selection.selectedNodeWithOffset();
5758
- if (!node) return
5832
+ #isCurrentRowEmpty() {
5833
+ if (!this.currentTableNode) return false
5759
5834
 
5760
- if ($isTextNode(node) && offset > 0) {
5761
- const fullText = node.getTextContent();
5762
- const textBeforeCursor = fullText.slice(0, offset);
5763
- const lastTriggerIndex = textBeforeCursor.lastIndexOf(this.trigger);
5764
- const triggerEndIndex = lastTriggerIndex + this.trigger.length - 1;
5835
+ const cells = this.currentRowCells;
5836
+ if (!cells) return false
5765
5837
 
5766
- // If trigger is not found, or cursor is at or before the trigger end position, hide popover
5767
- if (lastTriggerIndex === -1 || offset <= triggerEndIndex) {
5768
- this.#hidePopover();
5769
- }
5770
- } else {
5771
- // Cursor is not in a text node or at offset 0, hide popover
5772
- this.#hidePopover();
5773
- }
5774
- });
5775
- });
5838
+ return cells.every(cell => cell.getTextContent().trim() === "")
5776
5839
  }
5777
5840
 
5778
- #removeCursorPositionListener() {
5779
- if (this.cursorPositionListener) {
5780
- this.cursorPositionListener();
5781
- this.cursorPositionListener = null;
5782
- }
5783
- }
5841
+ #isFirstCellInRow() {
5842
+ if (!this.currentTableNode) return false
5784
5843
 
5785
- get #editor() {
5786
- return this.#editorElement.editor
5787
- }
5844
+ const cells = this.currentRowCells;
5845
+ if (!cells) return false
5788
5846
 
5789
- get #editorElement() {
5790
- return this.closest("lexxy-editor")
5847
+ return cells.indexOf(this.currentCell) === 0
5791
5848
  }
5792
5849
 
5793
- get #selection() {
5794
- return this.#editorElement.selection
5850
+ #registerKeyHandlers() {
5851
+ // We can't prevent these externally using regular keydown because Lexical handles it first.
5852
+ this.unregisterBackspaceKeyHandler = this.editor.registerCommand(KEY_BACKSPACE_COMMAND, (event) => this.#handleBackspaceKey(event), COMMAND_PRIORITY_HIGH);
5853
+ this.unregisterEnterKeyHandler = this.editor.registerCommand(KEY_ENTER_COMMAND, (event) => this.#handleEnterKey(event), COMMAND_PRIORITY_HIGH);
5795
5854
  }
5796
5855
 
5797
- async #showPopover() {
5798
- this.popoverElement ??= await this.#buildPopover();
5799
- this.#resetPopoverPosition();
5800
- await this.#filterOptions();
5801
- this.popoverElement.classList.toggle("lexxy-prompt-menu--visible", true);
5802
- this.#selectFirstOption();
5803
-
5804
- this.#editorElement.addEventListener("keydown", this.#handleKeydownOnPopover);
5805
- this.#editorElement.addEventListener("lexxy:change", this.#filterOptions);
5856
+ #unregisterKeyHandlers() {
5857
+ this.unregisterBackspaceKeyHandler?.();
5858
+ this.unregisterEnterKeyHandler?.();
5806
5859
 
5807
- this.#registerKeyListeners();
5808
- this.#addCursorPositionListener();
5860
+ this.unregisterBackspaceKeyHandler = null;
5861
+ this.unregisterEnterKeyHandler = null;
5809
5862
  }
5810
5863
 
5811
- #registerKeyListeners() {
5812
- // We can't use a regular keydown for Enter as Lexical handles it first
5813
- this.keyListeners.push(this.#editor.registerCommand(KEY_ENTER_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_HIGH));
5814
- this.keyListeners.push(this.#editor.registerCommand(KEY_TAB_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_HIGH));
5864
+ #handleBackspaceKey(event) {
5865
+ if (!this.currentTableNode) return false
5815
5866
 
5816
- if (this.#doesSpaceSelect) {
5817
- this.keyListeners.push(this.#editor.registerCommand(KEY_SPACE_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_HIGH));
5867
+ if (this.#isCurrentRowEmpty() && this.#isFirstCellInRow()) {
5868
+ event.preventDefault();
5869
+ this.#deleteRowAndSelectLastCell();
5870
+ return true
5818
5871
  }
5819
5872
 
5820
- // Register arrow keys with HIGH priority to prevent Lexical's selection handlers from running
5821
- this.keyListeners.push(this.#editor.registerCommand(KEY_ARROW_UP_COMMAND, this.#handleArrowUp.bind(this), COMMAND_PRIORITY_HIGH));
5822
- this.keyListeners.push(this.#editor.registerCommand(KEY_ARROW_DOWN_COMMAND, this.#handleArrowDown.bind(this), COMMAND_PRIORITY_HIGH));
5823
- }
5873
+ if (this.#isCurrentCellEmpty() && !this.#isFirstCellInRow()) {
5874
+ event.preventDefault();
5875
+ this.#selectPreviousCell();
5876
+ return true
5877
+ }
5824
5878
 
5825
- #handleArrowUp(event) {
5826
- this.#moveSelectionUp();
5827
- event.preventDefault();
5828
- return true
5879
+ return false
5829
5880
  }
5830
5881
 
5831
- #handleArrowDown(event) {
5832
- this.#moveSelectionDown();
5882
+ #handleEnterKey(event) {
5883
+ if ((event.ctrlKey || event.metaKey) || event.shiftKey || !this.currentTableNode) return false
5884
+
5885
+ if (this.selection.isInsideList || this.selection.isInsideCodeBlock) return false
5886
+
5833
5887
  event.preventDefault();
5888
+
5889
+ if (this.#isCurrentRowLast() && this.#isCurrentRowEmpty()) {
5890
+ this.#deleteRowAndSelectNextNode();
5891
+ } else if (this.#isCurrentRowLast()) {
5892
+ this.#insertRowAndSelectFirstCell();
5893
+ } else {
5894
+ this.#selectNextRow();
5895
+ }
5896
+
5834
5897
  return true
5835
5898
  }
5899
+ }
5836
5900
 
5837
- #selectFirstOption() {
5838
- const firstOption = this.#listItemElements[0];
5901
+ var TableIcons = {
5902
+ "insert-row-before":
5903
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
5904
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M7.86804e-07 15C8.29055e-07 15.8284 0.671574 16.5 1.5 16.5H15L15.1533 16.4922C15.8593 16.4205 16.4205 15.8593 16.4922 15.1533L16.5 15V4.5L16.4922 4.34668C16.4154 3.59028 15.7767 3 15 3H13.5L13.5 4.5H15V9H1.5L1.5 4.5L3 4.5V3H1.5C0.671574 3 1.20956e-06 3.67157 1.24577e-06 4.5L7.86804e-07 15ZM15 10.5V15H1.5L1.5 10.5H15Z"/>
5905
+ <path d="M4.5 4.5H7.5V7.5H9V4.5H12L12 3L9 3V6.55671e-08L7.5 0V3L4.5 3V4.5Z"/>
5906
+ </svg>`,
5839
5907
 
5840
- if (firstOption) {
5841
- this.#selectOption(firstOption);
5842
- }
5843
- }
5908
+ "insert-row-after":
5909
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
5910
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M7.86804e-07 13.5C7.50592e-07 14.3284 0.671574 15 1.5 15H3V13.5H1.5L1.5 9L15 9V13.5H13.5V15H15C15.7767 15 16.4154 14.4097 16.4922 13.6533L16.5 13.5V3L16.4922 2.84668C16.4205 2.14069 15.8593 1.57949 15.1533 1.50781L15 1.5L1.5 1.5C0.671574 1.5 1.28803e-06 2.17157 1.24577e-06 3L7.86804e-07 13.5ZM15 3V7.5L1.5 7.5L1.5 3L15 3Z"/>
5911
+ <path d="M7.5 15V18H9V15H12V13.5H9V10.5H7.5V13.5H4.5V15H7.5Z"/>
5912
+ </svg>`,
5913
+
5914
+ "delete-row":
5915
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
5916
+ <path d="M16.4922 12.1533C16.4154 12.9097 15.7767 13.5 15 13.5L12 13.5V12H15V6L1.5 6L1.5 12H4.5V13.5H1.5C0.723337 13.5 0.0846104 12.9097 0.00781328 12.1533L7.86804e-07 12L1.04907e-06 6C1.17362e-06 5.22334 0.590278 4.58461 1.34668 4.50781L1.5 4.5L15 4.5C15.8284 4.5 16.5 5.17157 16.5 6V12L16.4922 12.1533Z"/>
5917
+ <path d="M10.3711 15.9316L8.25 13.8096L6.12793 15.9316L5.06738 14.8711L7.18945 12.75L5.06738 10.6289L6.12793 9.56836L8.25 11.6895L10.3711 9.56836L11.4316 10.6289L9.31055 12.75L11.4316 14.8711L10.3711 15.9316Z"/>
5918
+ </svg>`,
5919
+
5920
+ "toggle-row":
5921
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
5922
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M0.00781328 13.6533C0.0846108 14.4097 0.723337 15 1.5 15L15 15L15.1533 14.9922C15.8593 14.9205 16.4205 14.3593 16.4922 13.6533L16.5 13.5V4.5L16.4922 4.34668C16.4205 3.64069 15.8593 3.07949 15.1533 3.00781L15 3L1.5 3C0.671574 3 1.24863e-06 3.67157 1.18021e-06 4.5L7.86804e-07 13.5L0.00781328 13.6533ZM15 9V13.5L1.5 13.5L1.5 9L15 9Z"/>
5923
+ </svg>`,
5924
+
5925
+ "insert-column-before":
5926
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
5927
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M4.5 0C3.67157 0 3 0.671573 3 1.5V3H4.5V1.5H9V15H4.5V13.5H3V15C3 15.7767 3.59028 16.4154 4.34668 16.4922L4.5 16.5H15L15.1533 16.4922C15.8593 16.4205 16.4205 15.8593 16.4922 15.1533L16.5 15V1.5C16.5 0.671573 15.8284 6.03989e-09 15 0H4.5ZM15 15H10.5V1.5H15V15Z"/>
5928
+ <path d="M3 7.5H0V9H3V12H4.5V9H7.5V7.5H4.5V4.5H3V7.5Z"/>
5929
+ </svg>`,
5930
+
5931
+ "insert-column-after":
5932
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
5933
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M13.5 0C14.3284 0 15 0.671573 15 1.5V3H13.5V1.5H9V15H13.5V13.5H15V15C15 15.7767 14.4097 16.4154 13.6533 16.4922L13.5 16.5H3L2.84668 16.4922C2.14069 16.4205 1.57949 15.8593 1.50781 15.1533L1.5 15V1.5C1.5 0.671573 2.17157 6.03989e-09 3 0H13.5ZM3 15H7.5V1.5H3V15Z"/>
5934
+ <path d="M15 7.5H18V9H15V12H13.5V9H10.5V7.5H13.5V4.5H15V7.5Z"/>
5935
+ </svg>`,
5844
5936
 
5845
- get #listItemElements() {
5846
- return Array.from(this.popoverElement.querySelectorAll(".lexxy-prompt-menu__item"))
5847
- }
5937
+ "delete-column":
5938
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
5939
+ <path d="M12.1533 0.0078125C12.9097 0.0846097 13.5 0.723336 13.5 1.5V4.5H12V1.5H6V15H12V12H13.5V15C13.5 15.7767 12.9097 16.4154 12.1533 16.4922L12 16.5H6C5.22334 16.5 4.58461 15.9097 4.50781 15.1533L4.5 15V1.5C4.5 0.671573 5.17157 2.41596e-08 6 0H12L12.1533 0.0078125Z"/>
5940
+ <path d="M15.9316 6.12891L13.8105 8.24902L15.9326 10.3711L14.8711 11.4316L12.75 9.31055L10.6289 11.4316L9.56738 10.3711L11.6885 8.24902L9.56836 6.12891L10.6289 5.06836L12.75 7.18848L14.8711 5.06836L15.9316 6.12891Z"/>
5941
+ </svg>`,
5848
5942
 
5849
- #selectOption(listItem) {
5850
- this.#clearSelection();
5851
- listItem.toggleAttribute("aria-selected", true);
5852
- listItem.scrollIntoView({ block: "nearest", behavior: "smooth" });
5853
- listItem.focus();
5943
+ "toggle-column":
5944
+ `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
5945
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M13.6533 17.9922C14.4097 17.9154 15 17.2767 15 16.5L15 3L14.9922 2.84668C14.9205 2.14069 14.3593 1.57949 13.6533 1.50781L13.5 1.5L4.5 1.5L4.34668 1.50781C3.59028 1.58461 3 2.22334 3 3L3 16.5C3 17.2767 3.59028 17.9154 4.34668 17.9922L4.5 18L13.5 18L13.6533 17.9922ZM9 3L13.5 3L13.5 16.5L9 16.5L9 3Z" />
5946
+ </svg>`,
5854
5947
 
5855
- // Preserve selection to prevent cursor jump
5856
- this.#selection.preservingSelection(() => {
5857
- this.#editorElement.focus();
5858
- });
5948
+ "delete-table":
5949
+ `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
5950
+ <path d="M18.2129 19.2305C18.0925 20.7933 16.7892 22 15.2217 22H7.77832C6.21084 22 4.90753 20.7933 4.78711 19.2305L4 9H19L18.2129 19.2305Z"/><path d="M13 2C14.1046 2 15 2.89543 15 4H19C19.5523 4 20 4.44772 20 5V6C20 6.55228 19.5523 7 19 7H4C3.44772 7 3 6.55228 3 6V5C3 4.44772 3.44772 4 4 4H8C8 2.89543 8.89543 2 10 2H13Z"/>
5951
+ </svg>`
5952
+ };
5859
5953
 
5860
- this.#editorContentElement.setAttribute("aria-controls", this.popoverElement.id);
5861
- this.#editorContentElement.setAttribute("aria-activedescendant", listItem.id);
5862
- this.#editorContentElement.setAttribute("aria-haspopup", "listbox");
5863
- }
5954
+ class TableTools extends HTMLElement {
5955
+ connectedCallback() {
5956
+ this.tableController = new TableController(this.#editorElement);
5864
5957
 
5865
- #clearSelection() {
5866
- this.#listItemElements.forEach((item) => { item.toggleAttribute("aria-selected", false); });
5867
- this.#editorContentElement.removeAttribute("aria-controls");
5868
- this.#editorContentElement.removeAttribute("aria-activedescendant");
5869
- this.#editorContentElement.removeAttribute("aria-haspopup");
5958
+ this.#setUpButtons();
5959
+ this.#monitorForTableSelection();
5960
+ this.#registerKeyboardShortcuts();
5870
5961
  }
5871
5962
 
5872
- #positionPopover() {
5873
- const { x, y, fontSize } = this.#selection.cursorPosition;
5874
- const editorRect = this.#editorElement.getBoundingClientRect();
5875
- const contentRect = this.#editorContentElement.getBoundingClientRect();
5876
- const verticalOffset = contentRect.top - editorRect.top;
5877
-
5878
- if (!this.popoverElement.hasAttribute("data-anchored")) {
5879
- this.popoverElement.style.left = `${x}px`;
5880
- this.popoverElement.toggleAttribute("data-anchored", true);
5881
- }
5963
+ disconnectedCallback() {
5964
+ this.#unregisterKeyboardShortcuts();
5882
5965
 
5883
- this.popoverElement.style.top = `${y + verticalOffset}px`;
5884
- this.popoverElement.style.bottom = "auto";
5966
+ this.unregisterUpdateListener?.();
5967
+ this.unregisterUpdateListener = null;
5885
5968
 
5886
- const popoverRect = this.popoverElement.getBoundingClientRect();
5887
- const isClippedAtBottom = popoverRect.bottom > window.innerHeight;
5969
+ this.removeEventListener("keydown", this.#handleToolsKeydown);
5888
5970
 
5889
- if (isClippedAtBottom || this.popoverElement.hasAttribute("data-clipped-at-bottom")) {
5890
- this.popoverElement.style.top = `${y + verticalOffset - popoverRect.height - fontSize}px`;
5891
- this.popoverElement.style.bottom = "auto";
5892
- this.popoverElement.toggleAttribute("data-clipped-at-bottom", true);
5893
- }
5971
+ this.tableController?.destroy();
5972
+ this.tableController = null;
5894
5973
  }
5895
5974
 
5896
- #resetPopoverPosition() {
5897
- this.popoverElement.removeAttribute("data-clipped-at-bottom");
5898
- this.popoverElement.removeAttribute("data-anchored");
5975
+ get #editor() {
5976
+ return this.#editorElement.editor
5899
5977
  }
5900
5978
 
5901
- async #hidePopover() {
5902
- this.#clearSelection();
5903
- this.popoverElement.classList.toggle("lexxy-prompt-menu--visible", false);
5904
- this.#editorElement.removeEventListener("lexxy:change", this.#filterOptions);
5905
- this.#editorElement.removeEventListener("keydown", this.#handleKeydownOnPopover);
5906
-
5907
- this.#unregisterKeyListeners();
5908
- this.#removeCursorPositionListener();
5909
-
5910
- await nextFrame();
5911
- this.#addTriggerListener();
5979
+ get #editorElement() {
5980
+ return this.closest("lexxy-editor")
5912
5981
  }
5913
5982
 
5914
- #unregisterKeyListeners() {
5915
- this.keyListeners.forEach((unregister) => unregister());
5916
- this.keyListeners = [];
5983
+ get #tableToolsButtons() {
5984
+ return Array.from(this.querySelectorAll("button, details > summary"))
5917
5985
  }
5918
5986
 
5919
- #filterOptions = async () => {
5920
- if (this.initialPrompt) {
5921
- this.initialPrompt = false;
5922
- return
5923
- }
5987
+ #setUpButtons() {
5988
+ this.appendChild(this.#createRowButtonsContainer());
5989
+ this.appendChild(this.#createColumnButtonsContainer());
5924
5990
 
5925
- if (this.#editorContents.containsTextBackUntil(this.trigger)) {
5926
- await this.#showFilteredOptions();
5927
- await nextFrame();
5928
- this.#positionPopover();
5929
- } else {
5930
- this.#hidePopover();
5931
- }
5991
+ this.appendChild(this.#createDeleteTableButton());
5992
+ this.addEventListener("keydown", this.#handleToolsKeydown);
5932
5993
  }
5933
5994
 
5934
- async #showFilteredOptions() {
5935
- const filter = this.#editorContents.textBackUntil(this.trigger);
5936
- const filteredListItems = await this.source.buildListItems(filter);
5937
- this.popoverElement.innerHTML = "";
5995
+ #createButtonsContainer(childType, setCountProperty, moreMenu) {
5996
+ const container = createElement("div", { className: `lexxy-table-control lexxy-table-control--${childType}` });
5938
5997
 
5939
- if (filteredListItems.length > 0) {
5940
- this.#showResults(filteredListItems);
5941
- } else {
5942
- this.#showEmptyResults();
5943
- }
5944
- this.#selectFirstOption();
5945
- }
5998
+ const plusButton = this.#createButton(`Add ${childType}`, { action: "insert", childType, direction: "after" }, "+");
5999
+ const minusButton = this.#createButton(`Remove ${childType}`, { action: "delete", childType }, "−");
5946
6000
 
5947
- #showResults(filteredListItems) {
5948
- this.popoverElement.classList.remove("lexxy-prompt-menu--empty");
5949
- this.popoverElement.append(...filteredListItems);
5950
- }
6001
+ const dropdown = createElement("details", { className: "lexxy-table-control__more-menu" });
6002
+ dropdown.setAttribute("name", "lexxy-dropdown");
6003
+ dropdown.tabIndex = -1;
5951
6004
 
5952
- #showEmptyResults() {
5953
- this.popoverElement.classList.add("lexxy-prompt-menu--empty");
5954
- const el = createElement("li", { innerHTML: this.#emptyResultsMessage });
5955
- el.classList.add("lexxy-prompt-menu__item--empty");
5956
- this.popoverElement.append(el);
5957
- }
6005
+ const count = createElement("summary", {}, `_ ${childType}s`);
6006
+ setCountProperty(count);
6007
+ dropdown.appendChild(count);
5958
6008
 
5959
- get #emptyResultsMessage() {
5960
- return this.getAttribute("empty-results") || NOTHING_FOUND_DEFAULT_MESSAGE
5961
- }
6009
+ dropdown.appendChild(moreMenu);
5962
6010
 
5963
- #handleKeydownOnPopover = (event) => {
5964
- if (event.key === "Escape") {
5965
- this.#hidePopover();
5966
- this.#editorElement.focus();
5967
- event.stopPropagation();
5968
- }
5969
- // Arrow keys are now handled via Lexical commands with HIGH priority
5970
- }
6011
+ container.appendChild(minusButton);
6012
+ container.appendChild(dropdown);
6013
+ container.appendChild(plusButton);
5971
6014
 
5972
- #moveSelectionDown() {
5973
- const nextIndex = this.#selectedIndex + 1;
5974
- if (nextIndex < this.#listItemElements.length) this.#selectOption(this.#listItemElements[nextIndex]);
6015
+ return container
5975
6016
  }
5976
6017
 
5977
- #moveSelectionUp() {
5978
- const previousIndex = this.#selectedIndex - 1;
5979
- if (previousIndex >= 0) this.#selectOption(this.#listItemElements[previousIndex]);
6018
+ #createRowButtonsContainer() {
6019
+ return this.#createButtonsContainer(
6020
+ "row",
6021
+ (count) => { this.rowCount = count; },
6022
+ this.#createMoreMenuSection("row")
6023
+ )
5980
6024
  }
5981
6025
 
5982
- get #selectedIndex() {
5983
- return this.#listItemElements.findIndex((item) => item.hasAttribute("aria-selected"))
6026
+ #createColumnButtonsContainer() {
6027
+ return this.#createButtonsContainer(
6028
+ "column",
6029
+ (count) => { this.columnCount = count; },
6030
+ this.#createMoreMenuSection("column")
6031
+ )
5984
6032
  }
5985
6033
 
5986
- get #selectedListItem() {
5987
- return this.#listItemElements[this.#selectedIndex]
5988
- }
6034
+ #createMoreMenuSection(childType) {
6035
+ const section = createElement("div", { className: "lexxy-table-control__more-menu-details" });
6036
+ const addBeforeButton = this.#createButton(`Add ${childType} before`, { action: "insert", childType, direction: "before" });
6037
+ const addAfterButton = this.#createButton(`Add ${childType} after`, { action: "insert", childType, direction: "after" });
6038
+ const toggleStyleButton = this.#createButton(`Toggle ${childType} style`, { action: "toggle", childType });
6039
+ const deleteButton = this.#createButton(`Remove ${childType}`, { action: "delete", childType });
5989
6040
 
5990
- #handleSelectedOption(event) {
5991
- event.preventDefault();
5992
- event.stopPropagation();
5993
- this.#optionWasSelected();
5994
- return true
5995
- }
6041
+ section.appendChild(addBeforeButton);
6042
+ section.appendChild(addAfterButton);
6043
+ section.appendChild(toggleStyleButton);
6044
+ section.appendChild(deleteButton);
5996
6045
 
5997
- #optionWasSelected() {
5998
- this.#replaceTriggerWithSelectedItem();
5999
- this.#hidePopover();
6000
- this.#editorElement.focus();
6046
+ return section
6001
6047
  }
6002
6048
 
6003
- #replaceTriggerWithSelectedItem() {
6004
- const promptItem = this.source.promptItemFor(this.#selectedListItem);
6005
-
6006
- if (!promptItem) { return }
6049
+ #createDeleteTableButton() {
6050
+ const container = createElement("div", { className: "lexxy-table-control" });
6007
6051
 
6008
- const templates = Array.from(promptItem.querySelectorAll("template[type='editor']"));
6009
- const stringToReplace = `${this.trigger}${this.#editorContents.textBackUntil(this.trigger)}`;
6052
+ const deleteTableButton = this.#createButton("Delete this table?", { action: "delete", childType: "table" });
6053
+ deleteTableButton.classList.add("lexxy-table-control__button--delete-table");
6010
6054
 
6011
- if (this.hasAttribute("insert-editable-text")) {
6012
- this.#insertTemplatesAsEditableText(templates, stringToReplace);
6013
- } else {
6014
- this.#insertTemplatesAsAttachments(templates, stringToReplace, promptItem.getAttribute("sgid"));
6015
- }
6016
- }
6055
+ container.appendChild(deleteTableButton);
6017
6056
 
6018
- #insertTemplatesAsEditableText(templates, stringToReplace) {
6019
- this.#editor.update(() => {
6020
- const nodes = templates.flatMap(template => this.#buildEditableTextNodes(template));
6021
- this.#editorContents.replaceTextBackUntil(stringToReplace, nodes);
6022
- });
6023
- }
6057
+ this.deleteContainer = container;
6024
6058
 
6025
- #buildEditableTextNodes(template) {
6026
- return $generateNodesFromDOM(this.#editor, parseHtml(`${template.innerHTML}`))
6059
+ return container
6027
6060
  }
6028
6061
 
6029
- #insertTemplatesAsAttachments(templates, stringToReplace, fallbackSgid = null) {
6030
- this.#editor.update(() => {
6031
- const attachmentNodes = this.#buildAttachmentNodes(templates, fallbackSgid);
6032
- const spacedAttachmentNodes = attachmentNodes.flatMap(node => [ node, this.#getSpacerTextNode() ]).slice(0, -1);
6033
- this.#editorContents.replaceTextBackUntil(stringToReplace, spacedAttachmentNodes);
6062
+ #createButton(label, command = {}, icon = this.#icon(command)) {
6063
+ const button = createElement("button", {
6064
+ className: "lexxy-table-control__button",
6065
+ "aria-label": label,
6066
+ type: "button"
6034
6067
  });
6035
- }
6036
-
6037
- #buildAttachmentNodes(templates, fallbackSgid = null) {
6038
- return templates.map(
6039
- template => this.#buildAttachmentNode(
6040
- template.innerHTML,
6041
- template.getAttribute("content-type") || this.#defaultPromptContentType,
6042
- template.getAttribute("sgid") || fallbackSgid
6043
- ))
6044
- }
6045
-
6046
- #getSpacerTextNode() {
6047
- return $createTextNode(" ")
6048
- }
6068
+ button.tabIndex = -1;
6069
+ button.innerHTML = `${icon} <span>${label}</span>`;
6049
6070
 
6050
- get #defaultPromptContentType() {
6051
- const attachmentContentTypeNamespace = Lexxy.global.get("attachmentContentTypeNamespace");
6052
- return `application/vnd.${attachmentContentTypeNamespace}.${this.name}`
6053
- }
6071
+ button.dataset.action = command.action;
6072
+ button.dataset.childType = command.childType;
6073
+ button.dataset.direction = command.direction;
6054
6074
 
6055
- #buildAttachmentNode(innerHtml, contentType, sgid) {
6056
- return new CustomActionTextAttachmentNode({ sgid, contentType, innerHtml })
6057
- }
6075
+ button.addEventListener("click", () => this.#executeTableCommand(command));
6058
6076
 
6059
- get #editorContents() {
6060
- return this.#editorElement.contents
6077
+ button.addEventListener("mouseover", () => this.#handleCommandButtonHover());
6078
+ button.addEventListener("focus", () => this.#handleCommandButtonHover());
6079
+ button.addEventListener("mouseout", () => this.#handleCommandButtonHover());
6080
+
6081
+ return button
6061
6082
  }
6062
6083
 
6063
- get #editorContentElement() {
6064
- return this.#editorElement.editorContentElement
6084
+ #registerKeyboardShortcuts() {
6085
+ this.unregisterKeyboardShortcuts = this.#editor.registerCommand(KEY_DOWN_COMMAND, this.#handleAccessibilityShortcutKey, COMMAND_PRIORITY_HIGH);
6065
6086
  }
6066
6087
 
6067
- async #buildPopover() {
6068
- const popoverContainer = createElement("ul", { role: "listbox", id: generateDomId("prompt-popover") }); // Avoiding [popover] due to not being able to position at an arbitrary X, Y position.
6069
- popoverContainer.classList.add("lexxy-prompt-menu");
6070
- popoverContainer.style.position = "absolute";
6071
- popoverContainer.setAttribute("nonce", getNonce());
6072
- popoverContainer.append(...await this.source.buildListItems());
6073
- popoverContainer.addEventListener("click", this.#handlePopoverClick);
6074
- this.#editorElement.appendChild(popoverContainer);
6075
- return popoverContainer
6088
+ #unregisterKeyboardShortcuts() {
6089
+ this.unregisterKeyboardShortcuts?.();
6090
+ this.unregisterKeyboardShortcuts = null;
6076
6091
  }
6077
6092
 
6078
- #handlePopoverClick = (event) => {
6079
- const listItem = event.target.closest(".lexxy-prompt-menu__item");
6080
- if (listItem) {
6081
- this.#selectOption(listItem);
6082
- this.#optionWasSelected();
6093
+ #handleAccessibilityShortcutKey = (event) => {
6094
+ if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === "F10") {
6095
+ const firstButton = this.querySelector("button, [tabindex]:not([tabindex='-1'])");
6096
+ firstButton?.focus();
6083
6097
  }
6084
6098
  }
6085
6099
 
6086
- #reconnect() {
6087
- this.disconnectedCallback();
6088
- this.connectedCallback();
6100
+ #handleToolsKeydown = (event) => {
6101
+ if (event.key === "Escape") {
6102
+ this.#handleEscapeKey();
6103
+ } else {
6104
+ handleRollingTabIndex(this.#tableToolsButtons, event);
6105
+ }
6089
6106
  }
6090
- }
6091
6107
 
6092
- customElements.define("lexxy-prompt", LexicalPromptElement);
6108
+ #handleEscapeKey() {
6109
+ const cell = this.tableController.currentCell;
6110
+ if (!cell) return
6093
6111
 
6094
- class CodeLanguagePicker extends HTMLElement {
6095
- connectedCallback() {
6096
- this.editorElement = this.closest("lexxy-editor");
6097
- this.editor = this.editorElement.editor;
6112
+ this.#editor.update(() => {
6113
+ cell.select();
6114
+ this.#editor.focus();
6115
+ });
6098
6116
 
6099
- this.#attachLanguagePicker();
6100
- this.#monitorForCodeBlockSelection();
6117
+ this.#update();
6101
6118
  }
6102
6119
 
6103
- #attachLanguagePicker() {
6104
- this.languagePickerElement = this.#createLanguagePicker();
6105
-
6106
- this.languagePickerElement.addEventListener("change", () => {
6107
- this.#updateCodeBlockLanguage(this.languagePickerElement.value);
6108
- });
6120
+ async #handleCommandButtonHover() {
6121
+ await nextFrame();
6109
6122
 
6110
- this.languagePickerElement.setAttribute("nonce", getNonce());
6111
- this.appendChild(this.languagePickerElement);
6112
- }
6123
+ this.#clearCellStyles();
6113
6124
 
6114
- #createLanguagePicker() {
6115
- const selectElement = createElement("select", { className: "lexxy-code-language-picker", "aria-label": "Pick a language…", name: "lexxy-code-language" });
6125
+ const activeElement = this.querySelector("button:hover, button:focus");
6126
+ if (!activeElement) return
6116
6127
 
6117
- for (const [ value, label ] of Object.entries(this.#languages)) {
6118
- const option = document.createElement("option");
6119
- option.value = value;
6120
- option.textContent = label;
6121
- selectElement.appendChild(option);
6122
- }
6128
+ const command = {
6129
+ action: activeElement.dataset.action,
6130
+ childType: activeElement.dataset.childType,
6131
+ direction: activeElement.dataset.direction
6132
+ };
6123
6133
 
6124
- return selectElement
6125
- }
6134
+ let cellsToHighlight = null;
6126
6135
 
6127
- get #languages() {
6128
- const languages = { ...CODE_LANGUAGE_FRIENDLY_NAME_MAP };
6136
+ switch (command.childType) {
6137
+ case "row":
6138
+ cellsToHighlight = this.tableController.currentRowCells;
6139
+ break
6140
+ case "column":
6141
+ cellsToHighlight = this.tableController.currentColumnCells;
6142
+ break
6143
+ case "table":
6144
+ cellsToHighlight = this.tableController.tableRows;
6145
+ break
6146
+ }
6129
6147
 
6130
- if (!languages.ruby) languages.ruby = "Ruby";
6131
- if (!languages.php) languages.php = "PHP";
6132
- if (!languages.go) languages.go = "Go";
6133
- if (!languages.bash) languages.bash = "Bash";
6134
- if (!languages.json) languages.json = "JSON";
6135
- if (!languages.diff) languages.diff = "Diff";
6148
+ if (!cellsToHighlight) return
6136
6149
 
6137
- const sortedEntries = Object.entries(languages)
6138
- .sort(([ , a ], [ , b ]) => a.localeCompare(b));
6150
+ cellsToHighlight.forEach(cell => {
6151
+ const cellElement = this.#editor.getElementByKey(cell.getKey());
6152
+ if (!cellElement) return
6139
6153
 
6140
- // Place the "plain" entry first, then the rest of language sorted alphabetically
6141
- const plainIndex = sortedEntries.findIndex(([ key ]) => key === "plain");
6142
- const plainEntry = sortedEntries.splice(plainIndex, 1)[0];
6143
- return Object.fromEntries([ plainEntry, ...sortedEntries ])
6154
+ cellElement.classList.toggle(theme.tableCellHighlight, true);
6155
+ Object.assign(cellElement.dataset, command);
6156
+ });
6144
6157
  }
6145
6158
 
6146
- #updateCodeBlockLanguage(language) {
6147
- this.editor.update(() => {
6148
- const codeNode = this.#getCurrentCodeNode();
6159
+ #monitorForTableSelection() {
6160
+ this.unregisterUpdateListener = this.#editor.registerUpdateListener(() => {
6161
+ this.tableController.updateSelectedTable();
6149
6162
 
6150
- if (codeNode) {
6151
- codeNode.setLanguage(language);
6163
+ const tableNode = this.tableController.currentTableNode;
6164
+ if (tableNode) {
6165
+ this.#show();
6166
+ } else {
6167
+ this.#hide();
6152
6168
  }
6153
6169
  });
6154
6170
  }
6155
6171
 
6156
- #monitorForCodeBlockSelection() {
6157
- this.editor.registerUpdateListener(() => {
6158
- this.editor.getEditorState().read(() => {
6159
- const codeNode = this.#getCurrentCodeNode();
6172
+ #executeTableCommand(command) {
6173
+ this.tableController.executeTableCommand(command);
6174
+ this.#update();
6175
+ }
6160
6176
 
6161
- if (codeNode) {
6162
- this.#codeNodeWasSelected(codeNode);
6163
- } else {
6164
- this.#hideLanguagePicker();
6165
- }
6166
- });
6167
- });
6177
+ #show() {
6178
+ this.style.display = "flex";
6179
+ this.#update();
6168
6180
  }
6169
6181
 
6170
- #getCurrentCodeNode() {
6171
- const selection = $getSelection();
6182
+ #hide() {
6183
+ this.style.display = "none";
6184
+ this.#clearCellStyles();
6185
+ }
6172
6186
 
6173
- if (!$isRangeSelection(selection)) {
6174
- return null
6175
- }
6187
+ #update() {
6188
+ this.#updateButtonsPosition();
6189
+ this.#updateRowColumnCount();
6190
+ this.#closeMoreMenu();
6191
+ this.#handleCommandButtonHover();
6192
+ }
6176
6193
 
6177
- const anchorNode = selection.anchor.getNode();
6178
- const parentNode = anchorNode.getParent();
6194
+ #closeMoreMenu() {
6195
+ this.querySelector("details[open]")?.removeAttribute("open");
6196
+ }
6179
6197
 
6180
- if ($isCodeNode(anchorNode)) {
6181
- return anchorNode
6182
- } else if ($isCodeNode(parentNode)) {
6183
- return parentNode
6184
- }
6198
+ #updateButtonsPosition() {
6199
+ const tableNode = this.tableController.currentTableNode;
6200
+ if (!tableNode) return
6185
6201
 
6186
- return null
6187
- }
6202
+ const tableElement = this.#editor.getElementByKey(tableNode.getKey());
6203
+ if (!tableElement) return
6188
6204
 
6189
- #codeNodeWasSelected(codeNode) {
6190
- const language = codeNode.getLanguage();
6205
+ const tableRect = tableElement.getBoundingClientRect();
6206
+ const editorRect = this.#editorElement.getBoundingClientRect();
6191
6207
 
6192
- this.#updateLanguagePickerWith(language);
6193
- this.#showLanguagePicker();
6194
- this.#positionLanguagePicker(codeNode);
6208
+ const relativeTop = tableRect.top - editorRect.top;
6209
+ const relativeCenter = (tableRect.left + tableRect.right) / 2 - editorRect.left;
6210
+ this.style.top = `${relativeTop}px`;
6211
+ this.style.left = `${relativeCenter}px`;
6195
6212
  }
6196
6213
 
6197
- #updateLanguagePickerWith(language) {
6198
- if (this.languagePickerElement && language) {
6199
- const normalizedLanguage = normalizeCodeLang(language);
6200
- this.languagePickerElement.value = normalizedLanguage;
6201
- }
6214
+ #updateRowColumnCount() {
6215
+ const tableNode = this.tableController.currentTableNode;
6216
+ if (!tableNode) return
6217
+
6218
+ const tableElement = $getElementForTableNode(this.#editor, tableNode);
6219
+ if (!tableElement) return
6220
+
6221
+ const rowCount = tableElement.rows;
6222
+ const columnCount = tableElement.columns;
6223
+
6224
+ this.rowCount.textContent = `${rowCount} row${rowCount === 1 ? "" : "s"}`;
6225
+ this.columnCount.textContent = `${columnCount} column${columnCount === 1 ? "" : "s"}`;
6202
6226
  }
6203
6227
 
6204
- #positionLanguagePicker(codeNode) {
6205
- const codeElement = this.editor.getElementByKey(codeNode.getKey());
6206
- if (!codeElement) return
6228
+ #setTableCellFocus() {
6229
+ const cell = this.tableController.currentCell;
6230
+ if (!cell) return
6207
6231
 
6208
- const codeRect = codeElement.getBoundingClientRect();
6209
- const editorRect = this.editorElement.getBoundingClientRect();
6210
- const relativeTop = codeRect.top - editorRect.top;
6211
- const relativeRight = editorRect.right - codeRect.right;
6232
+ const cellElement = this.#editor.getElementByKey(cell.getKey());
6233
+ if (!cellElement) return
6212
6234
 
6213
- this.style.top = `${relativeTop}px`;
6214
- this.style.right = `${relativeRight}px`;
6235
+ cellElement.classList.add(theme.tableCellFocus);
6215
6236
  }
6216
6237
 
6217
- #showLanguagePicker() {
6218
- this.hidden = false;
6238
+ #clearCellStyles() {
6239
+ this.#editorElement.querySelectorAll(`.${theme.tableCellFocus}`)?.forEach(cell => {
6240
+ cell.classList.remove(theme.tableCellFocus);
6241
+ });
6242
+
6243
+ this.#editorElement.querySelectorAll(`.${theme.tableCellHighlight}`)?.forEach(cell => {
6244
+ cell.classList.remove(theme.tableCellHighlight);
6245
+ cell.removeAttribute("data-action");
6246
+ cell.removeAttribute("data-child-type");
6247
+ cell.removeAttribute("data-direction");
6248
+ });
6249
+
6250
+ this.#setTableCellFocus();
6219
6251
  }
6220
6252
 
6221
- #hideLanguagePicker() {
6222
- this.hidden = true;
6253
+ #icon(command) {
6254
+ const { action, childType } = command;
6255
+ const direction = (action == "insert" ? command.direction : null);
6256
+ const iconId = [ action, childType, direction ].filter(Boolean).join("-");
6257
+ return TableIcons[iconId]
6223
6258
  }
6224
6259
  }
6225
6260
 
6226
- customElements.define("lexxy-code-language-picker", CodeLanguagePicker);
6261
+ function defineElements() {
6262
+ const elements = {
6263
+ "lexxy-toolbar": LexicalToolbarElement,
6264
+ "lexxy-editor": LexicalEditorElement,
6265
+ "lexxy-link-dropdown": LinkDropdown,
6266
+ "lexxy-highlight-dropdown": HighlightDropdown,
6267
+ "lexxy-prompt": LexicalPromptElement,
6268
+ "lexxy-code-language-picker": CodeLanguagePicker,
6269
+ "lexxy-table-tools": TableTools,
6270
+ };
6271
+
6272
+ Object.entries(elements).forEach(([ name, element ]) => {
6273
+ customElements.define(name, element);
6274
+ });
6275
+ }
6227
6276
 
6228
6277
  class LexxyExtension {
6229
6278
  #editorElement
@@ -6256,4 +6305,7 @@ class LexxyExtension {
6256
6305
 
6257
6306
  const configure = Lexxy.configure;
6258
6307
 
6308
+ // Pushing elements definition to after the current call stack to allow global configuration to take place first
6309
+ setTimeout(defineElements, 0);
6310
+
6259
6311
  export { ActionTextAttachmentNode, ActionTextAttachmentUploadNode, CustomActionTextAttachmentNode, LexxyExtension as Extension, HorizontalDividerNode, configure };