apostrophe
Version:
The Apostrophe Content Management System.
744 lines (722 loc) • 21.3 kB
JavaScript
import { createId } from '@paralleldrive/cuid2';
import { unref } from 'vue';
import { mapState, mapActions } from 'pinia';
import AposThemeMixin from 'Modules/@apostrophecms/ui/mixins/AposThemeMixin';
import newInstance from 'apostrophe/modules/@apostrophecms/schema/lib/newInstance.js';
import { useModalStore } from 'Modules/@apostrophecms/ui/stores/modal';
import { useWidgetStore } from 'Modules/@apostrophecms/ui/stores/widget';
import { useWidgetGraphStore } from 'Modules/@apostrophecms/ui/stores/widgetGraph';
import cloneWidget from 'Modules/@apostrophecms/area/lib/clone-widget.js';
import { klona } from 'klona';
export default {
mixins: [ AposThemeMixin ],
inject: {
aposGraphKey: {
from: 'aposGraphKey',
default: null
}
},
props: {
docId: {
type: String,
default: null
},
docType: {
type: String,
default: null
},
id: {
type: String,
required: true
},
field: {
type: Object,
default() {
return {};
}
},
fieldId: {
type: String,
required: true
},
options: {
type: Object,
default() {
return {};
}
},
items: {
type: Array,
default() {
return [];
}
},
meta: {
type: Object,
default() {
return {};
}
},
followingValues: {
type: Object,
default() {
return {};
}
},
choices: {
type: Array,
required: true
},
renderings: {
type: Object,
default() {
return {};
}
},
generation: {
type: Number,
required: false,
default() {
return null;
}
}
},
emits: [ 'changed' ],
data() {
return {
addWidgetEditor: null,
addWidgetOptions: null,
addWidgetType: null,
areaId: createId(),
next: this.getValidItems(),
hoveredNonForeignWidget: null,
contextMenuOptions: {
menu: this.choices
},
edited: {},
widgets: {}
};
},
computed: {
...mapState(useWidgetStore, [ 'focusedWidget', 'hoveredWidget', 'focusedArea' ]),
isEmptySingleton() {
return this.next.length === 0 &&
this.options.widgets &&
Object.keys(this.options.widgets).length === 1 &&
(this.options.max || this.field.max) &&
(this.options.max === 1 || this.field.max === 1);
},
icon() {
let icon = null;
if (
this.isEmptySingleton &&
this.contextMenuOptions.menu[0] &&
this.contextMenuOptions.menu[0].icon
) {
icon = this.contextMenuOptions.menu[0].icon;
}
return icon;
},
moduleOptions() {
return window.apos.area;
},
types() {
return Object.keys(this.widgets);
},
maxReached() {
const max = this.options.max || this.field.max;
return max && this.next.length >= max;
},
foreign() {
// Cast to boolean is necessary to satisfy prop typing
return !!(this.docId && (window.apos.adminBar.contextId !== this.docId));
},
focusedWidgetIndex() {
if (!this.focusedWidget) {
return -1;
}
return this.next.findIndex(widget => widget._id === this.focusedWidget);
},
/**
* Set of widget _ids (from `next`) that should have a raised z-index
* because they are, or contain, the currently focused widget.
* Computed once per focusedWidget / graph change; O(depth) ancestor
* walk + O(1) per-widget lookup in the template.
*/
raisedWidgets() {
const raised = new Set();
if (!this.focusedWidget) {
return raised;
}
const graphKey = unref(this.aposGraphKey);
if (!graphKey) {
// No graph — fall back to exact match only
if (this.next.some(w => w._id === this.focusedWidget)) {
raised.add(this.focusedWidget);
}
return raised;
}
const ancestors = this.storeGetAncestors(graphKey, this.focusedWidget);
const chain = new Set([ this.focusedWidget, ...ancestors ]);
for (const widget of this.next) {
if (chain.has(widget._id)) {
raised.add(widget._id);
}
}
return raised;
}
},
watch: {
// Note: please don't make this a deep watcher as that could cause
// issues with live widget preview and also performance, the top level
// array will change in situations where a patch API call is actually
// needed at this level
next() {
if (!this.docId) {
// For the benefit of AposInputArea which is the
// direct parent when we are not editing on-page
this.$emit('changed', {
items: this.next
});
}
// For the benefit of all other area editors on-page
// which may have this one as a sub-area in some way, and
// mistakenly think they know its contents have not changed
apos.bus.$emit('area-updated', {
_id: this.id,
items: this.next
});
},
generation() {
this.next = this.getValidItems();
}
},
created() {
if (this.options.groups) {
for (const group of Object.keys(this.options.groups)) {
this.widgets = {
...this.options.groups[group].widgets,
...this.widgets
};
}
}
},
mounted() {
this.bindEventListeners();
},
beforeUnmount() {
this.unbindEventListeners();
},
methods: {
...mapActions(useWidgetStore, [ 'setFocusedArea', 'setFocusedWidget' ]),
...mapActions(useModalStore, [ 'isOnTop' ]),
...mapActions(useWidgetGraphStore, {
storeGetAncestors: 'getAncestors'
}),
bindEventListeners() {
apos.bus.$on('area-updated', this.areaUpdatedHandler);
apos.bus.$on('command-menu-area-copy-widget', this.handleCopy);
apos.bus.$on('command-menu-area-cut-widget', this.handleCut);
apos.bus.$on('command-menu-area-duplicate-widget', this.handleDuplicate);
apos.bus.$on('command-menu-area-paste-widget', this.handlePaste);
apos.bus.$on('command-menu-area-remove-widget', this.handleRemove);
window.addEventListener('keydown', this.focusParentEvent);
},
unbindEventListeners() {
apos.bus.$off('area-updated', this.areaUpdatedHandler);
apos.bus.$off('command-menu-area-copy-widget', this.handleCopy);
apos.bus.$off('command-menu-area-cut-widget', this.handleCut);
apos.bus.$off('command-menu-area-duplicate-widget', this.handleDuplicate);
apos.bus.$off('command-menu-area-paste-widget', this.handlePaste);
apos.bus.$off('command-menu-area-remove-widget', this.handleRemove);
window.removeEventListener('keydown', this.focusParentEvent);
},
isInsideContentEditable() {
return document.activeElement.closest('[contenteditable]') !== null;
},
isInsideFocusedArea() {
return this.focusedArea === this.areaId;
},
resetFocusedArea() {
if (this.focusedArea !== this.areaId) {
return;
}
this.setFocusedArea(null);
},
handleCopy() {
if (
!this.isInsideFocusedArea() ||
this.isInsideContentEditable() ||
this.focusedWidgetIndex === -1
) {
return;
}
this.copy({ index: this.focusedWidgetIndex });
},
handleCut() {
if (
!this.isInsideFocusedArea() ||
this.isInsideContentEditable() ||
this.focusedWidgetIndex === -1
) {
return;
}
this.cut({ index: this.focusedWidgetIndex });
},
handleDuplicate() {
if (
!this.isInsideFocusedArea() ||
this.isInsideContentEditable() ||
this.focusedWidgetIndex === -1
) {
return;
}
this.clone({ index: this.focusedWidgetIndex });
},
handlePaste() {
if (
!this.isInsideFocusedArea() ||
this.isInsideContentEditable() ||
(this.focusedWidgetIndex === -1 && this.next.length > 0)
) {
return;
}
this.paste({ index: Math.max(this.focusedWidgetIndex, 0) });
},
handleRemove() {
if (
!this.isInsideFocusedArea() ||
this.isInsideContentEditable() ||
this.focusedWidgetIndex === -1
) {
return;
}
this.remove({ index: this.focusedWidgetIndex });
},
areaUpdatedHandler(area) {
for (const item of this.next) {
if (this.patchSubobject(item, area)) {
break;
}
}
},
focusParentEvent(event) {
if (!this.isOnTop(this.$el)) {
return;
}
if (event.metaKey && event.keyCode === 8) {
// meta + backspace
apos.bus.$emit('widget-focus-parent', this.focusedWidget);
}
},
async editStyles({ widgetId, index }) {
if (this.foreign) {
return;
}
const widget = this.next.find(({ _id }) => _id === widgetId);
if (!widget) {
return;
}
apos.area.activeEditor = this;
apos.bus.$on('apos-refreshing', cancelRefresh);
const preview = this.widgetPreview(widget.type, index, false);
const stylesEditorComponent = this.widgetStylesEditorComponent(widget.type);
const contextualStyles = apos.modules[
apos.area.widgetManagers[widget.type]
]?.contextualStyles;
const result = await apos.modal.execute(stylesEditorComponent, {
modelValue: widget,
options: this.widgetOptionsByType(widget.type),
type: widget.type,
docId: this.docId,
parentFollowingValues: this.followingValues,
areaFieldId: this.fieldId,
meta: this.meta[widget._id]?.aposMeta,
preview,
contextualStyles,
defaultTab: 'styles'
});
apos.area.activeEditor = null;
apos.bus.$off('apos-refreshing', cancelRefresh);
if (result) {
return this.update(result);
}
},
async up({ index }) {
if (this.docId === window.apos.adminBar.contextId) {
apos.bus.$emit('context-edited', {
$move: {
[`@${this.id}.items`]: {
$item: this.next[index]._id,
$before: this.next[index - 1]._id
}
}
});
}
this.next = [
...this.next.slice(0, index - 1),
this.next[index],
this.next[index - 1],
...this.next.slice(index + 1)
];
},
async down({ index }) {
if (this.docId === window.apos.adminBar.contextId) {
apos.bus.$emit('context-edited', {
$move: {
[`@${this.id}.items`]: {
$item: this.next[index]._id,
$after: this.next[index + 1]._id
}
}
});
}
this.next = [
...this.next.slice(0, index),
this.next[index + 1],
this.next[index],
...this.next.slice(index + 2)
];
},
async remove({ index }, { autosave = true } = {}) {
if (autosave && (this.docId === window.apos.adminBar.contextId)) {
apos.bus.$emit('context-edited', {
$pullAllById: {
[`@${this.id}.items`]: [ this.next[index]._id ]
}
});
}
this.next = [
...this.next.slice(0, index),
...this.next.slice(index + 1)
];
const focusNext = this.next[index - 1] || this.next[index];
if (focusNext) {
this.setFocusedWidget(focusNext._id, this.areaId, { scrollTo: true });
}
},
async cut({ index }) {
apos.area.widgetClipboard.set(this.next[index]);
await this.remove({ index });
apos.notify('Widget cut to clipboard', {
type: 'success',
icon: 'content-cut-icon',
dismiss: true
});
},
async copy({ index }) {
apos.area.widgetClipboard.set(this.next[index]);
apos.notify('Widget copied to clipboard', {
type: 'success',
icon: 'content-copy-icon',
dismiss: true
});
},
async edit({ index }) {
if (this.foreign) {
try {
const doc = await apos.http.get(
`${window.apos.doc.action}/${this.docId}`,
{
busy: true
}
);
if (doc._url) {
const contextTitle = window.apos.adminBar.context.title;
if (await apos.confirm({
heading: this.$t('apostrophe:leavePageHeading', {
oldTitle: contextTitle,
newTitle: doc.title
}),
description: this.$t('apostrophe:leavePageDescription', {
oldTitle: contextTitle
}),
localize: false
})) {
location.assign(doc._url);
}
} else {
apos.bus.$emit('admin-menu-click', {
itemName: `${doc.type}:editor`,
props: {
docId: doc._id
}
});
}
return;
} catch (e) {
if (e.status === 404) {
apos.notify('apostrophe:notFound', { type: 'error' });
return;
} else {
throw e;
}
}
}
const widget = this.next[index];
if (!this.widgetIsContextual(widget.type)) {
const componentName = this.widgetEditorComponent(widget.type);
apos.area.activeEditor = this;
apos.bus.$on('apos-refreshing', cancelRefresh);
const preview = this.widgetPreview(widget.type, index, false);
const result = await apos.modal.execute(componentName, {
modelValue: widget,
options: this.widgetOptionsByType(widget.type),
type: widget.type,
docId: this.docId,
parentFollowingValues: this.followingValues,
areaFieldId: this.fieldId,
meta: this.meta[widget._id]?.aposMeta,
preview
});
apos.area.activeEditor = null;
apos.bus.$off('apos-refreshing', cancelRefresh);
if (result) {
return this.update(result);
}
}
},
clone({ index }) {
const widget = cloneWidget(this.next[index]);
this.insert({
widget,
index: index + 1
});
},
async paste({ index }) {
const clipboard = apos.area.widgetClipboard.get();
if (clipboard) {
const widget = clipboard;
const allowed = this.contextMenuOptions.menu.find(
option => option.name === widget.type
);
if (allowed) {
this.add({
index,
clipboard
});
}
}
},
async update(updated, { autosave = true, reverting = false } = {}) {
if (!reverting) {
updated.aposPlaceholder = false;
}
if (!updated.metaType) {
updated.metaType = 'widget';
}
if (autosave && (this.docId === window.apos.adminBar.contextId)) {
apos.bus.$emit('context-edited', {
[`@${updated._id}`]: updated
});
}
this.next = this.next.map((widget) => {
if (widget._id === updated._id) {
return updated;
}
return widget;
});
this.edited[updated._id] = true;
},
// Add a widget into an area. index is required, along
// with one and only one of name, widget or clipboard.
// If widget is passed it is inserted directly. If
// clipboard is passed it is cloned and inserted.
async add({
index,
name,
widget,
clipboard
}) {
if (clipboard) {
clipboard = cloneWidget(clipboard);
return this.insert({
widget: clipboard,
index
});
}
if (widget) {
return this.insert({
widget,
index
});
}
if (this.widgetIsContextual(name)) {
return this.insert({
widget: {
...this.newWidget(name),
...this.contextualWidgetDefaultData(name),
aposPlaceholder: this.widgetHasPlaceholder(name)
},
index
});
}
if (!this.widgetHasInitialModal(name)) {
const newWidget = this.newWidget(name);
return this.insert({
widget: {
...newWidget,
aposPlaceholder: this.widgetHasPlaceholder(name)
},
index
});
}
const componentName = this.widgetEditorComponent(name);
apos.area.activeEditor = this;
const preview = this.widgetPreview(name, index, true);
const newWidget = await apos.modal.execute(componentName, {
modelValue: null,
options: this.widgetOptionsByType(name),
type: name,
docId: this.docId,
areaFieldId: this.fieldId,
parentFollowingValues: this.followingValues,
preview
});
apos.area.activeEditor = null;
if (newWidget) {
return this.insert({
widget: newWidget,
index
});
}
},
widgetOptionsByType(name) {
if (this.options.widgets) {
return this.options.widgets[name];
} else if (this.options.expanded) {
for (const info of Object.values(this.options.groups || {})) {
if (info?.widgets?.[name]) {
return info.widgets[name];
}
}
}
return null;
},
contextualWidgetDefaultData(type) {
return klona(this.moduleOptions.contextualWidgetDefaultData[type]);
},
async insert({
index, widget, autosave = true
} = {}) {
if (!widget._id) {
widget._id = createId();
}
if (!widget.metaType) {
widget.metaType = 'widget';
}
if (autosave && (this.docId === window.apos.adminBar.contextId)) {
const push = {
$each: [ widget ]
};
if (index < this.next.length) {
push.$before = this.next[index]._id;
}
apos.bus.$emit('context-edited', {
$push: {
[`@${this.id}.items`]: push
}
});
}
this.next = [
...this.next.slice(0, index),
widget,
...this.next.slice(index)
];
if (this.widgetIsContextual(widget.type)) {
this.edit({ index });
}
this.setFocusedWidget(widget._id, this.areaId, { scrollTo: true });
},
widgetIsContextual(type) {
return this.moduleOptions.widgetIsContextual[type];
},
widgetHasPlaceholder(type) {
return this.moduleOptions.widgetHasPlaceholder[type];
},
widgetHasInitialModal(type) {
return this.moduleOptions.widgetHasInitialModal[type];
},
widgetEditorComponent(type) {
return this.moduleOptions.components.widgetEditors[type];
},
widgetStylesEditorComponent(type) {
return this.moduleOptions.components.widgetStylesEditors[type];
},
widgetPreview(type, index, create) {
return this.moduleOptions.widgetPreview[type]
? {
area: this,
index,
create
}
: null;
},
// Recursively seek `subObject` within `object`, based on whether
// its _id matches that of a sub-object of `object`. If found,
// replace that sub-object with `subObject` and return `true`.
patchSubobject(object, subObject) {
let result;
for (const [ key, val ] of Object.entries(object)) {
if (key.charAt(0) === '_') {
// Patch only the thing itself, not a relationship that also contains
// a copy
continue;
}
if (val && typeof val === 'object') {
if (val._id === subObject._id) {
object[key] = subObject;
return true;
}
result = this.patchSubobject(val, subObject);
if (result) {
return result;
}
}
}
},
rendering(widget) {
if (this.edited[widget._id]) {
return null;
} else {
return this.renderings[widget._id];
}
},
getValidItems() {
return this.items.filter(item => {
if (!window.apos.modules[`${item.type}-widget`]) {
// eslint-disable-next-line no-console
console.warn(`The widget type ${item.type} exists in the content but is not configured.`);
}
return window.apos.modules[`${item.type}-widget`];
});
},
// Return a new widget object in which defaults are fully populated,
// especially valid sub-area objects, so that nested edits work on the page
// Optional schemaOverride array can be provided to override any fields from
// the base schema. Useful for e.g. per-instance defaults.
newWidget(type, schemaOverride) {
const schema = apos.modules[apos.area.widgetManagers[type]].schema;
const finalSchema = Array.isArray(schemaOverride)
? schema.map(field => {
const _field = schemaOverride.find(f => f.name === field.name);
if (_field) {
return {
...field,
..._field
};
}
return field;
})
: schema;
const widget = {
...newInstance(finalSchema),
type
};
return widget;
}
}
};
function cancelRefresh(refreshOptions) {
refreshOptions.refresh = false;
}