apostrophe 3.33.0 → 3.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/.github/workflows/main.yml +3 -0
  2. package/CHANGELOG.md +31 -0
  3. package/defaults.js +2 -1
  4. package/index.js +2 -1
  5. package/modules/@apostrophecms/admin-bar/index.js +74 -0
  6. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBar.vue +16 -6
  7. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextModeAndSettings.vue +24 -1
  8. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextTitle.vue +15 -0
  9. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextUndoRedo.vue +20 -18
  10. package/modules/@apostrophecms/area/ui/apos/components/AposAreaEditor.vue +22 -1
  11. package/modules/@apostrophecms/area/ui/apos/components/AposAreaExpandedMenu.vue +35 -0
  12. package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +6 -1
  13. package/modules/@apostrophecms/asset/lib/globalIcons.js +41 -17
  14. package/modules/@apostrophecms/command-menu/index.js +375 -0
  15. package/modules/@apostrophecms/command-menu/ui/apos/apps/AposCommandMenu.js +34 -0
  16. package/modules/@apostrophecms/command-menu/ui/apos/components/AposCommandMenuKey.vue +94 -0
  17. package/modules/@apostrophecms/command-menu/ui/apos/components/AposCommandMenuKeyList.vue +106 -0
  18. package/modules/@apostrophecms/command-menu/ui/apos/components/AposCommandMenuShortcut.vue +223 -0
  19. package/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue +116 -0
  20. package/modules/@apostrophecms/doc/index.js +9 -0
  21. package/modules/@apostrophecms/doc-type/index.js +117 -1
  22. package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocContextMenu.vue +7 -0
  23. package/modules/@apostrophecms/i18n/i18n/de.json +446 -0
  24. package/modules/@apostrophecms/i18n/i18n/en.json +27 -0
  25. package/modules/@apostrophecms/i18n/i18n/es.json +19 -0
  26. package/modules/@apostrophecms/i18n/i18n/fr.json +19 -0
  27. package/modules/@apostrophecms/i18n/i18n/pt-BR.json +19 -0
  28. package/modules/@apostrophecms/i18n/i18n/sk.json +19 -0
  29. package/modules/@apostrophecms/image/index.js +7 -0
  30. package/modules/@apostrophecms/image/ui/apos/components/AposMediaManager.vue +2 -0
  31. package/modules/@apostrophecms/image/ui/apos/components/AposMediaUploader.vue +11 -0
  32. package/modules/@apostrophecms/login/index.js +1 -1
  33. package/modules/@apostrophecms/modal/ui/apos/apps/AposModals.js +10 -1
  34. package/modules/@apostrophecms/modal/ui/apos/components/AposDocsManagerToolbar.vue +38 -3
  35. package/modules/@apostrophecms/modal/ui/apos/components/TheAposModals.vue +32 -2
  36. package/modules/@apostrophecms/page/index.js +43 -1
  37. package/modules/@apostrophecms/page/ui/apos/components/AposPagesManager.vue +4 -0
  38. package/modules/@apostrophecms/piece-type/index.js +145 -20
  39. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManager.vue +5 -1
  40. package/modules/@apostrophecms/rich-text-widget/index.js +153 -5
  41. package/modules/@apostrophecms/rich-text-widget/ui/apos/apps/AposRichTextPermalinkResolver.js +28 -0
  42. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +88 -14
  43. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposTiptapAnchor.vue +253 -0
  44. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposTiptapLink.vue +134 -24
  45. package/modules/@apostrophecms/rich-text-widget/ui/apos/tiptap-extensions/Anchor.js +59 -0
  46. package/modules/@apostrophecms/rich-text-widget/ui/apos/tiptap-extensions/Default.js +12 -4
  47. package/modules/@apostrophecms/rich-text-widget/ui/apos/tiptap-extensions/ListItem.js +6 -0
  48. package/modules/@apostrophecms/schema/lib/addFieldTypes.js +17 -0
  49. package/modules/@apostrophecms/schema/ui/apos/components/AposInputRelationship.vue +4 -2
  50. package/modules/@apostrophecms/search/index.js +27 -28
  51. package/modules/@apostrophecms/template/views/outerLayoutBase.html +1 -0
  52. package/modules/@apostrophecms/ui/ui/apos/components/AposCheckbox.vue +1 -1
  53. package/modules/@apostrophecms/user/index.js +24 -8
  54. package/modules/@apostrophecms/util/index.js +13 -0
  55. package/package.json +2 -2
  56. package/test/command-menu.js +877 -0
  57. package/test/concurrent-array-relationships.js +0 -1
  58. package/test/users.js +21 -0
  59. package/test/utils/commands.js +204 -0
@@ -2,6 +2,7 @@
2
2
  // editor does not use a modal; instead you edit in context on the page.
3
3
 
4
4
  const sanitizeHtml = require('sanitize-html');
5
+ const cheerio = require('cheerio');
5
6
 
6
7
  module.exports = {
7
8
  extend: '@apostrophecms/widget-type',
@@ -13,6 +14,13 @@ module.exports = {
13
14
  placeholderText: 'apostrophe:richTextPlaceholder',
14
15
  defaultData: { content: '' },
15
16
  className: false,
17
+ linkWithType: [ '@apostrophecms/any-page-type' ],
18
+ // For permalinks
19
+ project: {
20
+ title: 1,
21
+ _url: 1,
22
+ aposDocId: 1
23
+ },
16
24
  minimumDefaultOptions: {
17
25
  toolbar: [
18
26
  'styles',
@@ -20,6 +28,7 @@ module.exports = {
20
28
  'italic',
21
29
  'strike',
22
30
  'link',
31
+ 'anchor',
23
32
  'bulletList',
24
33
  'orderedList',
25
34
  'blockquote'
@@ -83,6 +92,11 @@ module.exports = {
83
92
  label: 'apostrophe:richTextLink',
84
93
  icon: 'link-icon'
85
94
  },
95
+ anchor: {
96
+ component: 'AposTiptapAnchor',
97
+ label: 'apostrophe:richTextAnchor',
98
+ icon: 'anchor-icon'
99
+ },
86
100
  bulletList: {
87
101
  component: 'AposTiptapButton',
88
102
  label: 'apostrophe:richTextBulletedList',
@@ -168,20 +182,20 @@ module.exports = {
168
182
  setNode: [ 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre' ],
169
183
  toggleMark: [
170
184
  'b', 'strong', 'code', 'mark', 'em', 'i',
171
- 'a', 's', 'del', 'strike', 'span', 'u'
185
+ 'a', 's', 'del', 'strike', 'span', 'u', 'anchor'
172
186
  ],
173
187
  wrapIn: [ 'blockquote' ]
174
188
  },
175
189
  tiptapTypes: {
176
190
  heading: [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ],
177
191
  paragraph: [ 'p' ],
178
- textStyle: [ 'span' ],
179
192
  code: [ 'code' ],
180
193
  bold: [ 'strong', 'b' ],
181
194
  strike: [ 's', 'del', 'strike' ],
182
195
  italic: [ 'i', 'em' ],
183
196
  highlight: [ 'mark' ],
184
197
  link: [ 'a' ],
198
+ anchor: [ 'span' ],
185
199
  underline: [ 'u' ],
186
200
  codeBlock: [ 'pre' ],
187
201
  blockquote: [ 'blockquote' ]
@@ -205,7 +219,37 @@ module.exports = {
205
219
  return widget.content;
206
220
  },
207
221
 
222
+ // Handle permalinks
208
223
  async load(req, widgets) {
224
+ const widgetsByDocId = new Map();
225
+ let ids = [];
226
+ for (const widget of widgets) {
227
+ if (!widget.permalinkIds) {
228
+ continue;
229
+ }
230
+ for (const id of widget.permalinkIds) {
231
+ const docWidgets = widgetsByDocId.get(id) || [];
232
+ docWidgets.push(widget);
233
+ widgetsByDocId.set(id, docWidgets);
234
+ ids.push(id);
235
+ }
236
+ }
237
+ ids = [ ...new Set(ids) ];
238
+ if (!ids.length) {
239
+ return;
240
+ }
241
+ const docs = await self.apos.doc.find(req, {
242
+ aposDocId: {
243
+ $in: ids
244
+ }
245
+ }).project(self.options.project).toArray();
246
+ for (const doc of docs) {
247
+ const widgets = widgetsByDocId.get(doc.aposDocId) || [];
248
+ for (const widget of widgets) {
249
+ widget._permalinkDocs = widget._permalinkDocs || [];
250
+ widget._permalinkDocs.push(doc);
251
+ }
252
+ }
209
253
  },
210
254
 
211
255
  // Convert area rich text options into a valid sanitize-html
@@ -252,7 +296,8 @@ module.exports = {
252
296
  'pre',
253
297
  'code'
254
298
  ],
255
- underline: [ 'u' ]
299
+ underline: [ 'u' ],
300
+ anchor: [ 'span' ]
256
301
  };
257
302
  for (const item of options.toolbar || []) {
258
303
  if (simple[item]) {
@@ -296,6 +341,10 @@ module.exports = {
296
341
  alignJustify: {
297
342
  tag: '*',
298
343
  attributes: [ 'style' ]
344
+ },
345
+ anchor: {
346
+ tag: 'span',
347
+ attributes: [ 'id' ]
299
348
  }
300
349
  };
301
350
  for (const item of options.toolbar || []) {
@@ -393,6 +442,47 @@ module.exports = {
393
442
  isEmpty(widget) {
394
443
  const text = self.apos.util.htmlToPlaintext(widget.content || '');
395
444
  return !text.trim().length;
445
+ },
446
+
447
+ sanitizeHtml(html, options) {
448
+ html = sanitizeHtml(html, options);
449
+ html = self.sanitizeAnchors(html);
450
+ return html;
451
+ },
452
+
453
+ sanitizeAnchors(html) {
454
+ const $ = cheerio.load(html);
455
+ const seen = new Set();
456
+ $('[data-anchor]').each(function() {
457
+ const $el = $(this);
458
+ const anchor = $el.attr('data-anchor');
459
+ if (!self.validateAnchor(anchor)) {
460
+ return;
461
+ }
462
+ // tiptap will apply data-anchor to every tag involved in the selection
463
+ // at any depth. For ids and anchors this doesn't really make sense.
464
+ // Save the id to the first, rootmost tag involved
465
+ if (!seen.has(anchor)) {
466
+ $el.attr('id', anchor);
467
+ seen.add(anchor);
468
+ }
469
+ });
470
+ const result = $('body').html();
471
+ return result;
472
+ },
473
+
474
+ validateAnchor(anchor) {
475
+ if ((typeof anchor) !== 'string') {
476
+ return false;
477
+ }
478
+ if (!anchor.length) {
479
+ return false;
480
+ }
481
+ // Don't let them break the editor
482
+ if (anchor.startsWith('apos-')) {
483
+ return false;
484
+ }
485
+ return true;
396
486
  }
397
487
  };
398
488
  },
@@ -407,9 +497,66 @@ module.exports = {
407
497
  const output = await _super(req, input, rteOptions);
408
498
  const finalOptions = self.optionsToSanitizeHtml(rteOptions);
409
499
 
410
- output.content = sanitizeHtml(input.content, finalOptions);
500
+ output.content = self.sanitizeHtml(input.content, finalOptions);
501
+
502
+ const anchors = output.content.match(/"#apostrophe-permalink-[^"?]*?\?/g);
503
+ output.permalinkIds = (anchors && anchors.map(anchor => {
504
+ const matches = anchor.match(/apostrophe-permalink-(.*)\?/);
505
+ return matches[1];
506
+ })) || [];
507
+
411
508
  return output;
412
509
  },
510
+ async output(_super, req, widget, options, _with) {
511
+ let i;
512
+ let content = widget.content || '';
513
+ // "Why no regexps?" We need to do this as quickly as we can.
514
+ // indexOf and lastIndexOf are much faster.
515
+ for (const doc of (widget._permalinkDocs || [])) {
516
+ let offset = 0;
517
+ while (true) {
518
+ i = content.indexOf('apostrophe-permalink-' + doc.aposDocId, offset);
519
+ if (i === -1) {
520
+ break;
521
+ }
522
+ offset = i + ('apostrophe-permalink-' + doc.aposDocId).length;
523
+ let updateTitle = content.indexOf('?updateTitle=1', i);
524
+ if (updateTitle === i + ('apostrophe-permalink-' + doc.aposDocId).length) {
525
+ updateTitle = true;
526
+ } else {
527
+ updateTitle = false;
528
+ }
529
+ // If you can edit the widget, you don't want the link replaced,
530
+ // as that would lose the permalink if you edit the widget
531
+ const left = content.lastIndexOf('<', i);
532
+ const href = content.indexOf(' href="', left);
533
+ const close = content.indexOf('"', href + 7);
534
+ if (!widget._edit) {
535
+ if ((left !== -1) && (href !== -1) && (close !== -1)) {
536
+ content = content.substr(0, href + 6) + doc._url + content.substr(close + 1);
537
+ } else {
538
+ // So we don't get stuck in an infinite loop
539
+ break;
540
+ }
541
+ }
542
+ if (!updateTitle) {
543
+ continue;
544
+ }
545
+ const right = content.indexOf('>', left);
546
+ const nextLeft = content.indexOf('<', right);
547
+ if ((right !== -1) && (nextLeft !== -1)) {
548
+ content = content.substr(0, right + 1) + self.apos.util.escapeHtml(doc.title) + content.substr(nextLeft);
549
+ }
550
+ }
551
+ }
552
+ // We never modify the original widget.content because we do not want
553
+ // it to lose its permalinks in the database
554
+ const _widget = {
555
+ ...widget,
556
+ content
557
+ };
558
+ return _super(req, _widget, options, _with);
559
+ },
413
560
  // Add on the core default options to use, if needed.
414
561
  getBrowserData(_super, req) {
415
562
  const initialData = _super(req);
@@ -420,7 +567,8 @@ module.exports = {
420
567
  defaultOptions: self.options.defaultOptions,
421
568
  tiptapTextCommands: self.options.tiptapTextCommands,
422
569
  tiptapTypes: self.options.tiptapTypes,
423
- placeholderText: self.options.placeholder && self.options.placeholderText
570
+ placeholderText: self.options.placeholder && self.options.placeholderText,
571
+ linkWithType: Array.isArray(self.options.linkWithType) ? self.options.linkWithType : [ self.options.linkWithType ]
424
572
  };
425
573
  return finalData;
426
574
  }
@@ -0,0 +1,28 @@
1
+ // This code is for EDITORS, to allow them to follow the permalinks
2
+ // even though they are not replaced with the actual page URLs in
3
+ // the markup on the server side. We do not do that for editors
4
+ // because otherwise the permalink would be lost on the next edit.
5
+
6
+ export default function() {
7
+ window.addEventListener('hashchange', e => {
8
+ // Typical case: hash link followed after page load
9
+ if (followPermalink()) {
10
+ e.stopPropagation();
11
+ }
12
+ });
13
+ // Or, the URL could already contain a permalink
14
+ followPermalink();
15
+ async function followPermalink() {
16
+ const hash = location.hash || '';
17
+ const matches = hash.match(/#apostrophe-permalink-(.*)$/);
18
+ if (matches) {
19
+ const aposDocId = matches[1].replace(/\?.*$/, '');
20
+ const doc = await apos.http.get(`${apos.doc.action}/${aposDocId}`, {});
21
+ if (doc._url) {
22
+ // This way the hashed version does not enter the history
23
+ location.replace(doc._url);
24
+ return true;
25
+ }
26
+ }
27
+ }
28
+ };
@@ -107,16 +107,20 @@ export default {
107
107
  editorOptions() {
108
108
  const activeOptions = Object.assign({}, this.options);
109
109
 
110
- // Allow toolbar option to pass through if `false`
111
- activeOptions.toolbar = (activeOptions.toolbar !== undefined)
112
- ? activeOptions.toolbar : this.defaultOptions.toolbar;
113
-
114
110
  activeOptions.styles = this.enhanceStyles(
115
111
  activeOptions.styles?.length
116
112
  ? activeOptions.styles
117
113
  : this.defaultOptions.styles
118
114
  );
119
115
 
116
+ // Allow default options to pass through if `false`
117
+ Object.keys(this.defaultOptions).forEach((option) => {
118
+ if (option !== 'styles') {
119
+ activeOptions[option] = (activeOptions[option] !== undefined)
120
+ ? activeOptions[option] : this.defaultOptions[option];
121
+ }
122
+ });
123
+
120
124
  activeOptions.className = (activeOptions.className !== undefined)
121
125
  ? activeOptions.className : this.moduleOptions.className;
122
126
 
@@ -127,7 +131,7 @@ export default {
127
131
  return !this.stripPlaceholderBrs(this.value.content).length;
128
132
  },
129
133
  initialContent() {
130
- const content = this.stripPlaceholderBrs(this.value.content);
134
+ const content = this.transformNamedAnchors(this.stripPlaceholderBrs(this.value.content));
131
135
  if (!content.length) {
132
136
  // If we don't supply a valid instance of the first style, then
133
137
  // the text align control will not work until the user manually
@@ -166,13 +170,6 @@ export default {
166
170
  },
167
171
  placeholderText() {
168
172
  return this.moduleOptions.placeholderText;
169
- },
170
- aposTiptapExtensions() {
171
- return (apos.tiptapExtensions || [])
172
- .map(extension => extension({
173
- styles: this.editorOptions.styles.map(this.localizeStyle),
174
- types: this.tiptapTypes
175
- }));
176
173
  }
177
174
  },
178
175
  watch: {
@@ -188,7 +185,8 @@ export default {
188
185
  const extensions = [
189
186
  StarterKit.configure({
190
187
  document: false,
191
- heading: false
188
+ heading: false,
189
+ listItem: false
192
190
  }),
193
191
  TextAlign.configure({
194
192
  types: [ 'heading', 'paragraph' ]
@@ -215,7 +213,7 @@ export default {
215
213
  })
216
214
  ]
217
215
  .filter(Boolean)
218
- .concat(this.aposTiptapExtensions);
216
+ .concat(this.aposTiptapExtensions());
219
217
 
220
218
  this.editor = new Editor({
221
219
  content: this.initialContent,
@@ -294,6 +292,59 @@ export default {
294
292
  stripPlaceholderBrs(html) {
295
293
  return html.replace(/<(p[^>]*)>\s*<br \/>\s*<\/p>/gi, '<$1></p>');
296
294
  },
295
+ // Legacy content may have `id` and `name` attributes on anchor tags
296
+ // but our tiptap anchor extension needs them on a separate `span`, so nest
297
+ // a span to migrate this content for each relevant anchor tag encountered
298
+ transformNamedAnchors(html) {
299
+ const el = document.createElement('div');
300
+ el.innerHTML = html;
301
+ const anchors = el.querySelectorAll('a[name]');
302
+ for (const anchor of anchors) {
303
+ const name = anchor.getAttribute('id') || anchor.getAttribute('name');
304
+ if (typeof name !== 'string' || !name.length) {
305
+ continue;
306
+ }
307
+ const span = document.createElement('span');
308
+ span.setAttribute('id', name);
309
+ anchor.removeAttribute('id');
310
+ anchor.removeAttribute('name');
311
+ if (anchor.children.length) {
312
+ // Migrate children of the anchor to the span
313
+ while (anchor.firstElementChild) {
314
+ span.append(anchor.firstElementChild);
315
+ }
316
+ if (anchor.attributes.length) {
317
+ anchor.prepend(span);
318
+ } else {
319
+ anchor.replaceWith(span);
320
+ }
321
+ if (!span.innerText.length) {
322
+ span.innerText = '⚓︎';
323
+ }
324
+ } else {
325
+ // Empty anchors result in empty spans, which
326
+ // disappear in tiptap. Wrap the anchor around
327
+ // the next text node encountered
328
+ let el = anchor;
329
+ while (true) {
330
+ if ((el.nodeType === Node.TEXT_NODE) && (el.textContent.length > 0)) {
331
+ break;
332
+ }
333
+ el = traverseNextNode(el);
334
+ }
335
+ if (el) {
336
+ el.parentNode.insertBefore(span, el);
337
+ span.append(el);
338
+ } else {
339
+ // Still no text discovered, supply something the
340
+ // editor can lock on to
341
+ span.innerText = '⚓︎';
342
+ anchor.prepend(span);
343
+ }
344
+ }
345
+ }
346
+ return el.innerHTML;
347
+ },
297
348
  // Enhances the dev-defined styles list with tiptap
298
349
  // commands and parameters used internally.
299
350
  enhanceStyles(styles) {
@@ -355,10 +406,29 @@ export default {
355
406
  ...style,
356
407
  label: this.$t(style.label)
357
408
  };
409
+ },
410
+ aposTiptapExtensions() {
411
+ return (apos.tiptapExtensions || [])
412
+ .map(extension => extension({
413
+ styles: this.editorOptions.styles.map(this.localizeStyle),
414
+ types: this.tiptapTypes
415
+ }));
358
416
  }
359
417
  }
360
418
  };
361
419
 
420
+ function traverseNextNode(node) {
421
+ if (node.firstChild) {
422
+ return node.firstChild;
423
+ }
424
+ while (node) {
425
+ if (node.nextSibling) {
426
+ return node.nextSibling;
427
+ }
428
+ node = node.parentNode;
429
+ }
430
+ return null;
431
+ }
362
432
  </script>
363
433
 
364
434
  <style lang="scss" scoped>
@@ -440,4 +510,8 @@ export default {
440
510
  margin: 0;
441
511
  }
442
512
 
513
+ // So editors can find anchors again
514
+ .apos-rich-text-editor__editor ::v-deep span[id] {
515
+ text-decoration: underline dotted;
516
+ }
443
517
  </style>
@@ -0,0 +1,253 @@
1
+ <template>
2
+ <div class="apos-anchor-control">
3
+ <AposButton
4
+ type="rich-text"
5
+ @click="click"
6
+ :class="{ 'apos-is-active': buttonActive }"
7
+ :label="tool.label"
8
+ :icon-only="!!tool.icon"
9
+ :icon="tool.icon ? tool.icon : false"
10
+ :modifiers="['no-border', 'no-motion']"
11
+ />
12
+ <div
13
+ v-if="active"
14
+ v-click-outside-element="close"
15
+ class="apos-popover apos-anchor-control__dialog"
16
+ x-placement="bottom"
17
+ :class="{
18
+ 'apos-is-triggered': active,
19
+ 'apos-has-selection': hasSelection
20
+ }"
21
+ >
22
+ <AposContextMenuDialog
23
+ menu-placement="bottom-start"
24
+ >
25
+ <div v-if="hasAnchorOnOpen" class="apos-anchor-control__remove">
26
+ <AposButton
27
+ type="quiet"
28
+ @click="removeAnchor"
29
+ label="apostrophe:removeRichTextAnchor"
30
+ />
31
+ </div>
32
+ <AposSchema
33
+ :schema="schema"
34
+ :trigger-validation="triggerValidation"
35
+ v-model="docFields"
36
+ :utility-rail="false"
37
+ :modifiers="formModifiers"
38
+ :key="lastSelectionTime"
39
+ :generation="generation"
40
+ :following-values="followingValues()"
41
+ :conditional-fields="conditionalFields()"
42
+ />
43
+ <footer class="apos-anchor-control__footer">
44
+ <AposButton
45
+ type="default" label="apostrophe:cancel"
46
+ @click="close"
47
+ :modifiers="formModifiers"
48
+ />
49
+ <AposButton
50
+ type="primary" label="apostrophe:save"
51
+ @click="save"
52
+ :modifiers="formModifiers"
53
+ />
54
+ </footer>
55
+ </AposContextMenuDialog>
56
+ </div>
57
+ </div>
58
+ </template>
59
+
60
+ <script>
61
+ import AposEditorMixin from 'Modules/@apostrophecms/modal/mixins/AposEditorMixin';
62
+
63
+ export default {
64
+ name: 'AposTiptapAnchor',
65
+ mixins: [ AposEditorMixin ],
66
+ props: {
67
+ name: {
68
+ type: String,
69
+ required: true
70
+ },
71
+ tool: {
72
+ type: Object,
73
+ required: true
74
+ },
75
+ editor: {
76
+ type: Object,
77
+ required: true
78
+ }
79
+ },
80
+ data () {
81
+ return {
82
+ generation: 1,
83
+ keepInBounds: true,
84
+ hasAnchorOnOpen: false,
85
+ triggerValidation: false,
86
+ docFields: {
87
+ data: {}
88
+ },
89
+ formModifiers: [ 'small', 'margin-micro' ],
90
+ originalSchema: [
91
+ {
92
+ name: 'anchor',
93
+ label: this.$t('apostrophe:anchorId'),
94
+ type: 'string',
95
+ required: true
96
+ }
97
+ ],
98
+ active: false
99
+ };
100
+ },
101
+ computed: {
102
+ buttonActive() {
103
+ return this.editor.isActive('anchor') || this.active;
104
+ },
105
+ lastSelectionTime() {
106
+ return this.editor.view.lastSelectionTime;
107
+ },
108
+ hasSelection() {
109
+ const { state } = this.editor;
110
+ const { selection } = this.editor.state;
111
+ const { from, to } = selection;
112
+ const text = state.doc.textBetween(from, to, '');
113
+ return text !== '';
114
+ },
115
+ offset() {
116
+ const selection = window.getSelection();
117
+ const range = selection.getRangeAt(0);
118
+ const rect = range.getBoundingClientRect();
119
+ return (rect.height + 15) + 'px';
120
+ },
121
+ schema() {
122
+ return this.originalSchema;
123
+ }
124
+ },
125
+ watch: {
126
+ active(newVal) {
127
+ if (newVal) {
128
+ this.hasAnchorOnOpen = !!(this.docFields.data.anchor);
129
+ window.addEventListener('keydown', this.keyboardHandler);
130
+ } else {
131
+ window.removeEventListener('keydown', this.keyboardHandler);
132
+ }
133
+ },
134
+ 'editor.view.lastSelectionTime': {
135
+ handler(newVal, oldVal) {
136
+ this.populateFields();
137
+ }
138
+ },
139
+ hasSelection(newVal, oldVal) {
140
+ if (!newVal) {
141
+ this.close();
142
+ }
143
+ }
144
+ },
145
+ methods: {
146
+ removeAnchor() {
147
+ this.docFields.data = {};
148
+ this.editor.commands.unsetAnchor();
149
+ this.close();
150
+ },
151
+ click() {
152
+ if (this.hasSelection) {
153
+ this.active = !this.active;
154
+ this.populateFields();
155
+ }
156
+ },
157
+ close() {
158
+ if (this.active) {
159
+ this.active = false;
160
+ this.editor.chain().focus();
161
+ }
162
+ },
163
+ save() {
164
+ this.triggerValidation = true;
165
+ this.$nextTick(() => {
166
+ if (this.docFields.hasErrors) {
167
+ return;
168
+ }
169
+ this.editor.commands.setAnchor({
170
+ id: this.docFields.data.anchor
171
+ });
172
+ this.close();
173
+ });
174
+ },
175
+ keyboardHandler(e) {
176
+ if (e.keyCode === 27) {
177
+ this.close();
178
+ }
179
+ if (e.keyCode === 13) {
180
+ if (this.docFields.data.anchor) {
181
+ this.save();
182
+ this.close();
183
+ e.preventDefault();
184
+ } else {
185
+ e.preventDefault();
186
+ }
187
+ }
188
+ },
189
+ async populateFields() {
190
+ try {
191
+ const attrs = {
192
+ anchor: this.editor.getAttributes('anchor').id
193
+ };
194
+ this.docFields.data = {};
195
+ this.schema.forEach((item) => {
196
+ this.docFields.data[item.name] = attrs[item.name] || '';
197
+ });
198
+ } finally {
199
+ this.generation++;
200
+ }
201
+ }
202
+ }
203
+ };
204
+ </script>
205
+
206
+ <style lang="scss" scoped>
207
+ .apos-anchor-control {
208
+ position: relative;
209
+ display: inline-block;
210
+ }
211
+
212
+ .apos-anchor-control__dialog {
213
+ z-index: $z-index-modal;
214
+ position: absolute;
215
+ top: calc(100% + 5px);
216
+ left: -15px;
217
+ width: 250px;
218
+ opacity: 0;
219
+ pointer-events: none;
220
+ }
221
+
222
+ .apos-anchor-control__dialog.apos-is-triggered.apos-has-selection {
223
+ opacity: 1;
224
+ pointer-events: auto;
225
+ }
226
+
227
+ .apos-is-active {
228
+ background-color: var(--a-base-7);
229
+ }
230
+
231
+ .apos-anchor-control__footer {
232
+ display: flex;
233
+ justify-content: flex-end;
234
+ margin-top: 10px;
235
+ }
236
+
237
+ .apos-anchor-control__footer .apos-button__wrapper {
238
+ margin-left: 7.5px;
239
+ }
240
+
241
+ .apos-anchor-control__remove {
242
+ display: flex;
243
+ justify-content: flex-end;
244
+ }
245
+
246
+ // special schema style for this use
247
+ .apos-anchor-control ::v-deep .apos-field--target {
248
+ .apos-field__label {
249
+ display: none;
250
+ }
251
+ }
252
+
253
+ </style>