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.
- package/.github/workflows/main.yml +3 -0
- package/CHANGELOG.md +31 -0
- package/defaults.js +2 -1
- package/index.js +2 -1
- package/modules/@apostrophecms/admin-bar/index.js +74 -0
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBar.vue +16 -6
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextModeAndSettings.vue +24 -1
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextTitle.vue +15 -0
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextUndoRedo.vue +20 -18
- package/modules/@apostrophecms/area/ui/apos/components/AposAreaEditor.vue +22 -1
- package/modules/@apostrophecms/area/ui/apos/components/AposAreaExpandedMenu.vue +35 -0
- package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +6 -1
- package/modules/@apostrophecms/asset/lib/globalIcons.js +41 -17
- package/modules/@apostrophecms/command-menu/index.js +375 -0
- package/modules/@apostrophecms/command-menu/ui/apos/apps/AposCommandMenu.js +34 -0
- package/modules/@apostrophecms/command-menu/ui/apos/components/AposCommandMenuKey.vue +94 -0
- package/modules/@apostrophecms/command-menu/ui/apos/components/AposCommandMenuKeyList.vue +106 -0
- package/modules/@apostrophecms/command-menu/ui/apos/components/AposCommandMenuShortcut.vue +223 -0
- package/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue +116 -0
- package/modules/@apostrophecms/doc/index.js +9 -0
- package/modules/@apostrophecms/doc-type/index.js +117 -1
- package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocContextMenu.vue +7 -0
- package/modules/@apostrophecms/i18n/i18n/de.json +446 -0
- package/modules/@apostrophecms/i18n/i18n/en.json +27 -0
- package/modules/@apostrophecms/i18n/i18n/es.json +19 -0
- package/modules/@apostrophecms/i18n/i18n/fr.json +19 -0
- package/modules/@apostrophecms/i18n/i18n/pt-BR.json +19 -0
- package/modules/@apostrophecms/i18n/i18n/sk.json +19 -0
- package/modules/@apostrophecms/image/index.js +7 -0
- package/modules/@apostrophecms/image/ui/apos/components/AposMediaManager.vue +2 -0
- package/modules/@apostrophecms/image/ui/apos/components/AposMediaUploader.vue +11 -0
- package/modules/@apostrophecms/login/index.js +1 -1
- package/modules/@apostrophecms/modal/ui/apos/apps/AposModals.js +10 -1
- package/modules/@apostrophecms/modal/ui/apos/components/AposDocsManagerToolbar.vue +38 -3
- package/modules/@apostrophecms/modal/ui/apos/components/TheAposModals.vue +32 -2
- package/modules/@apostrophecms/page/index.js +43 -1
- package/modules/@apostrophecms/page/ui/apos/components/AposPagesManager.vue +4 -0
- package/modules/@apostrophecms/piece-type/index.js +145 -20
- package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManager.vue +5 -1
- package/modules/@apostrophecms/rich-text-widget/index.js +153 -5
- package/modules/@apostrophecms/rich-text-widget/ui/apos/apps/AposRichTextPermalinkResolver.js +28 -0
- package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +88 -14
- package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposTiptapAnchor.vue +253 -0
- package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposTiptapLink.vue +134 -24
- package/modules/@apostrophecms/rich-text-widget/ui/apos/tiptap-extensions/Anchor.js +59 -0
- package/modules/@apostrophecms/rich-text-widget/ui/apos/tiptap-extensions/Default.js +12 -4
- package/modules/@apostrophecms/rich-text-widget/ui/apos/tiptap-extensions/ListItem.js +6 -0
- package/modules/@apostrophecms/schema/lib/addFieldTypes.js +17 -0
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputRelationship.vue +4 -2
- package/modules/@apostrophecms/search/index.js +27 -28
- package/modules/@apostrophecms/template/views/outerLayoutBase.html +1 -0
- package/modules/@apostrophecms/ui/ui/apos/components/AposCheckbox.vue +1 -1
- package/modules/@apostrophecms/user/index.js +24 -8
- package/modules/@apostrophecms/util/index.js +13 -0
- package/package.json +2 -2
- package/test/command-menu.js +877 -0
- package/test/concurrent-array-relationships.js +0 -1
- package/test/users.js +21 -0
- 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
|
+
};
|
package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue
CHANGED
|
@@ -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>
|