markupeditor 0.9.52 → 0.9.54

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/markup-editor.js +184 -75
  2. package/package.json +1 -1
@@ -621,6 +621,40 @@ class MUError {
621
621
 
622
622
  }
623
623
 
624
+ /**
625
+ * Return the slug for a heading's `text` the way GitHub makes it: lowercase, keep alphabetic
626
+ * characters, combining marks, decimal digits, connector punctuation (which includes `_`) and `-`,
627
+ * and replace blanks with `-`. The slug is cut to 40 characters, counting characters rather than
628
+ * UTF-16 units, just to avoid unwieldy IDs.
629
+ *
630
+ * @param {string} text The text content of a heading
631
+ * @returns {string} The slug, which is empty if nothing in `text` is kept
632
+ */
633
+ function headingSlug(text) {
634
+ const slug = text.toLowerCase()
635
+ .replace(/[^\p{Alphabetic}\p{M}\p{Nd}\p{Pc} -]/gu, '')
636
+ .replaceAll(' ', '-');
637
+ return Array.from(slug).slice(0, 40).join('')
638
+ }
639
+
640
+ /**
641
+ * Return the slugs for the headings with the `texts` given in document order, numbering repeats
642
+ * the way GitHub does: the first heading with a slug gets it as-is, and later ones get `-1`, `-2`,
643
+ * and so on. Every heading counts, whether or not anything links to it.
644
+ *
645
+ * @param {string[]} texts The text content of every heading, in document order
646
+ * @returns {string[]} The slug for each of `texts`, in the same order
647
+ */
648
+ function headingSlugs(texts) {
649
+ const seen = new Map();
650
+ return texts.map(text => {
651
+ const slug = headingSlug(text);
652
+ const count = seen.get(slug) ?? 0;
653
+ seen.set(slug, count + 1);
654
+ return count === 0 ? slug : slug + '-' + count.toString()
655
+ })
656
+ }
657
+
624
658
  // ::- Persistent data structure representing an ordered mapping from
625
659
  // strings to values, with some convenient update methods.
626
660
  function OrderedMap(content) {
@@ -35485,18 +35519,31 @@ function _getMarkTypes() {
35485
35519
  */
35486
35520
  function getLinkAttributes() {
35487
35521
  const view = activeView();
35488
- const selection = view.state.selection;
35489
- const selectedNodes = [];
35490
- view.state.doc.nodesBetween(selection.from, selection.to, node => {
35491
- if (node.isText) selectedNodes.push(node);
35492
- });
35493
- const selectedNode = (selectedNodes.length === 1) && selectedNodes[0];
35494
- if (selectedNode) {
35495
- const linkMarks = selectedNode.marks.filter(mark => mark.type === schema.marks.link);
35522
+ const link = linkRangeAt(view.state);
35523
+ if (link) {
35524
+ const text = view.state.doc.textBetween(link.from, link.to);
35525
+ if (text) return {href: link.mark.attrs.href, link: text};
35526
+ } // A linked image carries the link mark on the image node, which has no text to report as the link.
35527
+ const image = _selectedImageNode(view.state);
35528
+ if (image) {
35529
+ const linkMarks = image.marks.filter(mark => mark.type === schema.marks.link);
35496
35530
  if (linkMarks.length === 1) {
35497
- return {href: linkMarks[0].attrs.href, link: selectedNode.text};
35531
+ return {href: linkMarks[0].attrs.href, link: image.attrs.alt ?? ''};
35498
35532
  } } return {};
35499
35533
  }
35534
+ function _selectedImageNode(state) {
35535
+ const {from, to} = state.selection;
35536
+ let image = null;
35537
+ let count = 0;
35538
+ state.doc.nodesBetween(from, to, node => {
35539
+ if (node.type === state.schema.nodes.image) {
35540
+ image = node;
35541
+ count++;
35542
+ }
35543
+ });
35544
+ return (count === 1) ? image : null
35545
+ }
35546
+
35500
35547
  function _getImageAttributes() {
35501
35548
  const view = activeView();
35502
35549
  return getImageAttributes(view.state)
@@ -36129,6 +36176,9 @@ function testCodeLanguageTabInfo() {
36129
36176
  * Insert a link to url. When the selection is collapsed, the url is inserted
36130
36177
  * at the selection point as a link.
36131
36178
  *
36179
+ * When the selection is in an existing link, or contains one, the href of the whole link is
36180
+ * changed to url and its text is left alone.
36181
+ *
36132
36182
  * When done, leave the link selected.
36133
36183
  *
36134
36184
  * @param {string} url The url/href to use for the link
@@ -36143,25 +36193,94 @@ function insertLinkCommand(url) {
36143
36193
  const commandAdapter = (state, dispatch, view) => {
36144
36194
  const selection = state.selection;
36145
36195
  const linkMark = state.schema.marks.link.create({ href: url });
36146
- if (selection.empty) {
36196
+ const transaction = state.tr;
36197
+ if (!addLinkMark(transaction, state, linkMark)) {
36198
+ // A collapsed selection outside any link
36147
36199
  const textNode = state.schema.text(url).mark([linkMark]);
36148
- const transaction = state.tr.replaceSelectionWith(textNode, false);
36200
+ transaction.replaceSelectionWith(textNode, false);
36149
36201
  const linkSelection = TextSelection.create(transaction.doc, selection.from, selection.from + textNode.nodeSize);
36150
36202
  transaction.setSelection(linkSelection);
36151
- dispatch(transaction);
36152
- stateChanged(view);
36153
- } else {
36154
- const toggle = toggleMark(linkMark.type, linkMark.attrs);
36155
- if (toggle) {
36156
- toggle(state, dispatch);
36157
- stateChanged(view);
36158
- }
36159
36203
  }
36204
+ dispatch(transaction);
36205
+ stateChanged(view);
36160
36206
  return true;
36161
36207
  };
36162
36208
  return commandAdapter;
36163
36209
  }
36164
36210
 
36211
+ /**
36212
+ * Add `linkMark` to `transaction`, replacing the href of the link the selection is in if there is one.
36213
+ *
36214
+ * The selection can be anywhere within a link or contain the full link. A link that spans several
36215
+ * text nodes, because part of it has other marks, is changed as a whole.
36216
+ *
36217
+ * @ignore
36218
+ * @param {Transaction} transaction
36219
+ * @param {EditorState} state The state `transaction` was created from
36220
+ * @param {Mark} linkMark
36221
+ * @returns {boolean} False, with `transaction` unchanged, when the selection is collapsed and not in a link
36222
+ */
36223
+ function addLinkMark(transaction, state, linkMark) {
36224
+ const range = linkRangeAt(state);
36225
+ if (range) {
36226
+ transaction.addMark(range.from, range.to, linkMark);
36227
+ const {from, to} = state.selection;
36228
+ if (range.from !== from || range.to !== to) {
36229
+ transaction.setSelection(TextSelection.create(transaction.doc, range.from, range.to));
36230
+ } return true;
36231
+ } if (state.selection.empty) return false;
36232
+ state.selection.ranges.forEach(range => transaction.addMark(range.$from.pos, range.$to.pos, linkMark));
36233
+ return true;
36234
+ }
36235
+
36236
+ /**
36237
+ * The extent of the link the selection is in, or null if it is not in one.
36238
+ *
36239
+ * Every inline node in the selection has to carry the same link, and a collapsed selection has to be inside the
36240
+ * link, not at its edge.
36241
+ *
36242
+ * @ignore
36243
+ * @param {EditorState} state
36244
+ * @returns {{from: number, to: number, mark: Mark}|null}
36245
+ */
36246
+ function linkRangeAt(state) {
36247
+ const linkType = state.schema.marks.link;
36248
+ const {from, to, $from, $to} = state.selection;
36249
+ if (!$from.sameParent($to)) return null;
36250
+ let target = null;
36251
+ let uniform = true;
36252
+ const check = node => {
36253
+ const mark = linkType.isInSet(node.marks);
36254
+ if (!mark || (target && !target.eq(mark))) uniform = false;
36255
+ else target = mark;
36256
+ };
36257
+ if (state.selection.empty) {
36258
+ // A collapsed selection is in a link when the same link is on both sides of it, including between its text nodes
36259
+ if (!$from.nodeBefore || !$from.nodeAfter) return null;
36260
+ check($from.nodeBefore);
36261
+ check($from.nodeAfter);
36262
+ } else {
36263
+ state.doc.nodesBetween(from, to, node => {
36264
+ if (!node.isInline) return true;
36265
+ check(node);
36266
+ return false;
36267
+ });
36268
+ } if (!uniform || !target) return null;
36269
+ // Widen to the contiguous children carrying the same link
36270
+ const base = $from.start();
36271
+ const spans = [];
36272
+ $from.parent.forEach((child, offset) => {
36273
+ spans.push({start: base + offset, end: base + offset + child.nodeSize, linked: !!target.isInSet(child.marks)});
36274
+ });
36275
+ const collapsed = state.selection.empty;
36276
+ let first = spans.findIndex(span => collapsed ? span.end >= from : span.end > from);
36277
+ let last = spans.findLastIndex(span => collapsed ? span.start <= to : span.start < to);
36278
+ if (first < 0 || last < first) return null;
36279
+ while (first > 0 && spans[first - 1].linked) first--;
36280
+ while (last < spans.length - 1 && spans[last + 1].linked) last++;
36281
+ return {from: spans[first].start, to: spans[last].end, mark: target};
36282
+ }
36283
+
36165
36284
  /**
36166
36285
  * Insert a link to the header identified by hTag ('H1'-'H6') and index (its position among
36167
36286
  * headers with that tag), assigning the header a unique id if it doesn't already have one.
@@ -36184,27 +36303,16 @@ function insertInternalLinkCommand(hTag, index) {
36184
36303
  let id = idForHeader(node, state);
36185
36304
  // Same transaction as the mark insertion below, so both land as one undo step.
36186
36305
  let tr = state.tr.setNodeMarkup(pos, undefined, { ...node.attrs, id });
36187
- const selection = state.selection;
36188
36306
  const linkMark = state.schema.marks.link.create({ href: '#' + id });
36189
- if (selection.empty) {
36307
+ if (!addLinkMark(tr, state, linkMark)) {
36190
36308
  // In case of an empty selection, insert the textContent of the header and then use
36191
36309
  // that to link-to the header
36192
36310
  const textNode = state.schema.text(node.textContent, [linkMark]);
36193
36311
  tr.replaceSelectionWith(textNode, false);
36194
- dispatch(tr);
36195
- stateChanged(view);
36196
- return true;
36197
- } else {
36198
- // Capture toggleMark's steps rather than let it dispatch, then replay them onto
36199
- // `tr` (setNodeMarkup doesn't shift positions, so they apply cleanly).
36200
- let captured = null;
36201
- toggleMark(linkMark.type, linkMark.attrs)(state, t => { captured = t; });
36202
- if (!captured) return false;
36203
- for (const step of captured.steps) tr.step(step);
36204
- dispatch(tr);
36205
- stateChanged(view);
36206
- return true;
36207
36312
  }
36313
+ dispatch(tr);
36314
+ stateChanged(view);
36315
+ return true;
36208
36316
  };
36209
36317
  return commandAdapter;
36210
36318
  }
@@ -36228,12 +36336,10 @@ function idForInternalLinkCommand(hTag, index) {
36228
36336
  }
36229
36337
 
36230
36338
  /**
36231
- * Return a unique identifier for the heading `node` by lowercasing its trimmed textContent
36232
- * and replacing blanks with `-`, then appending a number until its unique if required.
36233
- * If the heading `node` has an id, then just return it.
36234
- *
36235
- * Since the `node.textContent` can be arbitrarily large, we limit the id to 40 characters
36236
- * just to avoid unwieldy IDs.
36339
+ * Return a unique identifier for the heading `node`: its slug as `headingSlugs` numbers it by its
36340
+ * position among all the headings in `state.doc`, as GitHub does. If another node already uses that
36341
+ * id, append `-1`, `-2`, and so on until it is unique. If the heading `node` has an id, then just
36342
+ * return it.
36237
36343
  *
36238
36344
  * @ignore
36239
36345
  * @param {Node} node A ProseMirror Node that is of heading type
@@ -36242,15 +36348,20 @@ function idForInternalLinkCommand(hTag, index) {
36242
36348
  */
36243
36349
  function idForHeader(node, state) {
36244
36350
  if (node.attrs.id) return node.attrs.id
36245
- let id = node.textContent.toLowerCase().substring(0, 40);
36246
- id = id.replaceAll(' ', '-');
36247
- let {node: idNode} = nodeWithId(id, state);
36351
+ const texts = [];
36352
+ let position = -1;
36353
+ state.doc.descendants(child => {
36354
+ if (child.type !== state.schema.nodes.heading) return true
36355
+ if (child === node) position = texts.length;
36356
+ texts.push(child.textContent);
36357
+ return false
36358
+ });
36359
+ const base = (position >= 0) ? headingSlugs(texts)[position] : headingSlug(node.textContent);
36360
+ let id = base;
36248
36361
  let index = 0;
36249
- while (idNode) {
36362
+ while (nodeWithId(id, state).node) {
36250
36363
  index++;
36251
- id = id + index.toString();
36252
- let {node} = nodeWithId(id, state);
36253
- idNode = node;
36364
+ id = base + '-' + index.toString();
36254
36365
  }
36255
36366
  return id
36256
36367
  }
@@ -36289,20 +36400,25 @@ function headerMatching(hTag, index, state) {
36289
36400
 
36290
36401
  /**
36291
36402
  * Return a JSON-stringified array describing every non-empty header in the document, in document
36292
- * order within each level. Each entry is `{hTag, index, text, id}`, where `hTag` and `index` identify
36403
+ * order within each level. Each entry is `{hTag, index, text, id, slug}`, where `hTag` and `index` identify
36293
36404
  * the header the same way `insertInternalLink(hTag, index)` and `idForInternalLinkCommand(hTag, index)`
36294
- * do, and `id` is the header's current id attribute (null if it hasn't been assigned one yet).
36405
+ * do, `id` is the header's current id attribute (null if it hasn't been assigned one yet), and `slug`
36406
+ * is the id a link to the header would give it, numbered by position among all the headers.
36295
36407
  *
36296
36408
  * An empty header is omitted -- it can't form a usable id, so there's nothing to link to -- but
36297
36409
  * `index` still reflects its true position among ALL headers at that level (empty ones included),
36298
36410
  * matching what `headerMatching` expects; it's only the reported entries that skip empty headers,
36299
36411
  * not the numbering.
36300
36412
  *
36301
- * @returns {string} JSON-stringified array of `{hTag, index, text, id}`
36413
+ * @returns {string} JSON-stringified array of `{hTag, index, text, id, slug}`
36302
36414
  */
36303
36415
  function getHeaders() {
36304
36416
  const view = activeView();
36305
36417
  const headersByLevel = headers(view.state);
36418
+ // Slugs are numbered by position among all the headings in the document
36419
+ const slugs = new Map();
36420
+ const all = Object.values(headersByLevel).flat().sort((a, b) => a.pos - b.pos);
36421
+ headingSlugs(all.map(h => h.node.textContent)).forEach((slug, i) => slugs.set(all[i].node, slug));
36306
36422
  let result = [];
36307
36423
  for (let level = 1; level < 7; level++) {
36308
36424
  let hNodes = headersByLevel[level];
@@ -36310,7 +36426,7 @@ function getHeaders() {
36310
36426
  hNodes.forEach((h, index) => {
36311
36427
  const text = h.node.textContent;
36312
36428
  if (text.trim().length === 0) return
36313
- result.push({hTag: 'H' + level.toString(), index: index, text: text, id: h.node.attrs.id ?? null});
36429
+ result.push({hTag: 'H' + level.toString(), index: index, text: text, id: h.node.attrs.id ?? null, slug: slugs.get(h.node)});
36314
36430
  });
36315
36431
  }
36316
36432
  }
@@ -36384,29 +36500,14 @@ function deleteLinkCommand() {
36384
36500
  }
36385
36501
 
36386
36502
  function selectFullLink(view) {
36387
- const linkType = view.state.schema.marks.link;
36388
- const selection = view.state.selection;
36389
-
36390
- // Make sure the selection is in a single text node with a linkType Mark
36391
- const nodePos = [];
36392
- view.state.doc.nodesBetween(selection.from, selection.to, (node, pos) => {
36393
- if (node.isText) {
36394
- nodePos.push({node: node, pos: pos});
36395
- return false;
36396
- } return true;
36397
- });
36398
- if (nodePos.length !== 1) return;
36399
- const selectedNode = nodePos[0].node;
36400
- const selectedPos = nodePos[0].pos;
36401
- const linkMarks = selectedNode && selectedNode.marks.filter(mark => mark.type === linkType);
36402
- if (linkMarks.length !== 1) return;
36403
-
36404
- // Select the entire text of selectedNode
36405
- const anchor = selectedPos;
36406
- const head = anchor + selectedNode.nodeSize;
36407
- const linkSelection = TextSelection.create(view.state.doc, anchor, head);
36408
- const transaction = view.state.tr.setSelection(linkSelection);
36409
- view.dispatch(transaction);
36503
+ // A node selection (a linked image, for example) is left alone
36504
+ if (view.state.selection instanceof NodeSelection) return;
36505
+ const range = linkRangeAt(view.state);
36506
+ if (!range) return;
36507
+ const {from, to} = view.state.selection;
36508
+ if (range.from === from && range.to === to) return;
36509
+ const linkSelection = TextSelection.create(view.state.doc, range.from, range.to);
36510
+ view.dispatch(view.state.tr.setSelection(linkSelection));
36410
36511
  }
36411
36512
 
36412
36513
  /********************************************************************************
@@ -43333,7 +43434,13 @@ class Searcher {
43333
43434
  * Create a new SearchQuery and highlight all the matches in the document.
43334
43435
  */
43335
43436
  _buildQuery() {
43336
- this._searchQuery = new SearchQuery({search: this._searchString, caseSensitive: this._caseSensitive});
43437
+ // The search string has straight quotes and apostrophes. Match them against the straight and
43438
+ // curly forms in the document text, without changing the document.
43439
+ const pattern = this._searchString
43440
+ .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
43441
+ .replaceAll("'", "['‘’]")
43442
+ .replaceAll('"', '["“”]');
43443
+ this._searchQuery = new SearchQuery({search: pattern, regexp: true, caseSensitive: this._caseSensitive});
43337
43444
  }
43338
43445
 
43339
43446
  }
@@ -43589,6 +43696,8 @@ const MU = {
43589
43696
  getLocalImages,
43590
43697
  getHTML,
43591
43698
  getHeaders,
43699
+ headingSlug,
43700
+ headingSlugs,
43592
43701
  getHeight,
43593
43702
  getImageAttributes,
43594
43703
  getLinkAttributes,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "markupeditor",
3
- "version": "0.9.52",
3
+ "version": "0.9.54",
4
4
  "description": "A web component and API for WYSIWYG HTML editing.",
5
5
  "keywords": [
6
6
  "wysiwyg",