diff --git a/CHANGELOG.md b/CHANGELOG.md
index 018608a73a..db2a9685e9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@
* Adds focus states for media library's Uploader tile
* Adds focus states file attachment's input UI
* Simplified importing rich text widgets via the REST API. If you you have HTML that contains `img` tags pointing to existing images, you can now import them all quickly. When supplying the rich text widget object, include an `import` property with an `html` subproperty, rather than the usual `content` property. You can optionally provide a `baseUrl` subproperty as well. Any images present in `html` will be imported automatically and the correct `figure` tags will be added to the new rich text widget, along with any other markup acceptable to the widget's configuration.
+* Add mobile preview feature to the admin UI. The feature can be enabled using the `@apostrophecms/asset` module new `devicePreviewMode` option. Once enabled, the asset build process will duplicate existing media queries as container queries. There are some limitations in the equivalence media queries / container queries. You can refer to the [CSS @container at-rule](https://developer.mozilla.org/en-US/docs/Web/CSS/@container) documentation for more information. You can also enable `devicePreviewMode.debug` to be notified in the console when the build encounter an unsupported media query.
### Changes
diff --git a/modules/@apostrophecms/admin-bar/index.js b/modules/@apostrophecms/admin-bar/index.js
index 0f80498ce2..afd4884fa9 100644
--- a/modules/@apostrophecms/admin-bar/index.js
+++ b/modules/@apostrophecms/admin-bar/index.js
@@ -14,6 +14,56 @@ module.exports = {
pageTree: true
},
commands(self) {
+ const devicePreviewModeScreens = (
+ self.apos.asset.options.devicePreviewMode?.enable &&
+ self.apos.asset.options.devicePreviewMode?.screens
+ ) || {};
+ const devicePreviewModeCommands = {
+ [`${self.__meta.name}:toggle-device-preview-mode:exit`]: {
+ type: 'item',
+ label: {
+ key: 'apostrophe:commandMenuToggleDevicePreviewMode',
+ device: '$t(apostrophe:devicePreviewExit)'
+ },
+ action: {
+ type: 'command-menu-admin-bar-toggle-device-preview-mode',
+ payload: {
+ mode: null,
+ width: null,
+ height: null
+ }
+ },
+ shortcut: 'P,0'
+ }
+ };
+ let index = 1;
+ for (const [ name, screen ] of Object.entries(devicePreviewModeScreens)) {
+ // Up to 9 shortcuts available
+ if (index === 9) {
+ break;
+ }
+
+ devicePreviewModeCommands[`${self.__meta.name}:toggle-device-preview-mode:${name}`] = {
+ type: 'item',
+ label: {
+ key: 'apostrophe:commandMenuToggleDevicePreviewMode',
+ device: `$t(${screen.label})`
+ },
+ action: {
+ type: 'command-menu-admin-bar-toggle-device-preview-mode',
+ payload: {
+ mode: name,
+ label: `$t(${screen.label})`,
+ width: screen.width,
+ height: screen.height
+ }
+ },
+ shortcut: `P,${index}`
+ };
+
+ index += 1;
+ };
+
return {
add: {
[`${self.__meta.name}:undo`]: {
@@ -63,7 +113,8 @@ module.exports = {
type: 'command-menu-admin-bar-toggle-publish-draft'
},
shortcut: 'Ctrl+Shift+D Meta+Shift+D'
- }
+ },
+ ...devicePreviewModeCommands
},
modal: {
default: {
@@ -80,7 +131,8 @@ module.exports = {
label: 'apostrophe:commandMenuMode',
commands: [
`${self.__meta.name}:toggle-edit-preview-mode`,
- `${self.__meta.name}:toggle-published-draft-document`
+ `${self.__meta.name}:toggle-published-draft-document`,
+ ...Object.keys(devicePreviewModeCommands)
]
}
}
@@ -355,6 +407,13 @@ module.exports = {
aposLocale: context.aposLocale,
aposDocId: context.aposDocId
},
+ devicePreviewMode: self.apos.asset.options.devicePreviewMode ||
+ {
+ enable: false,
+ debug: false,
+ resizable: false,
+ screens: {}
+ },
// Base API URL appropriate to the context document
contextBar: context && self.apos.doc.getManager(context.type).options.contextBar,
showAdminBar: self.getShowAdminBar(req),
diff --git a/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextDevicePreviewMode.vue b/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextDevicePreviewMode.vue
new file mode 100644
index 0000000000..f6ca58c543
--- /dev/null
+++ b/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextDevicePreviewMode.vue
@@ -0,0 +1,166 @@
+
+
+
+
+
+
+
diff --git a/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextTitle.vue b/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextTitle.vue
index ed022e73df..55cd343fc0 100644
--- a/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextTitle.vue
+++ b/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextTitle.vue
@@ -50,6 +50,13 @@
:tooltip="tooltip"
:modifiers="modifiers"
/>
+
@@ -94,6 +101,15 @@ export default {
isUnpublished() {
return !this.context.lastPublishedAt;
},
+ isDevicePreviewModeEnabled() {
+ return this.moduleOptions.devicePreviewMode.enable || false;
+ },
+ devicePreviewModeScreens() {
+ return this.moduleOptions.devicePreviewMode.screens || {};
+ },
+ devicePreviewModeResizable() {
+ return this.moduleOptions.devicePreviewMode.resizable || false;
+ },
docTooltip() {
return {
key: 'apostrophe:lastUpdatedBy',
@@ -142,6 +158,15 @@ export default {
},
switchDraftMode(mode) {
this.$emit('switch-draft-mode', mode);
+ },
+ addContextLabel({
+ label
+ }) {
+ document.querySelector('[data-apos-context-label]')
+ ?.replaceChildren(document.createTextNode(this.$t(label)));
+ },
+ removeContextLabel() {
+ document.querySelector('[data-apos-context-label]')?.replaceChildren();
}
}
};
diff --git a/modules/@apostrophecms/asset/index.js b/modules/@apostrophecms/asset/index.js
index 22a11a9172..f5737bae3e 100644
--- a/modules/@apostrophecms/asset/index.js
+++ b/modules/@apostrophecms/asset/index.js
@@ -44,7 +44,47 @@ module.exports = {
rebundleModules: undefined,
// In case of external front end like Astro, this option allows to
// disable the build of the public UI assets.
- publicBundle: true
+ publicBundle: true,
+ // Device preview in the admin UI.
+ // NOTE: the whole devicePreviewMode option must be carried over
+ // to the project for override to work properly.
+ // Nested object options are not deep merged in Apostrophe.
+ devicePreviewMode: {
+ // Enable device preview mode
+ enable: false,
+ // Warn during build about unsupported media queries.
+ debug: false,
+ // If we can resize the preview container?
+ resizable: false,
+ // Screens with icons
+ // For adding icons, please refer to the icons documentation
+ // https://docs.apostrophecms.org/reference/module-api/module-overview.html#icons
+ screens: {
+ desktop: {
+ label: 'apostrophe:devicePreviewDesktop',
+ width: '1500px',
+ height: '900px',
+ icon: 'monitor-icon'
+ },
+ tablet: {
+ label: 'apostrophe:devicePreviewTablet',
+ width: '1024px',
+ height: '768px',
+ icon: 'tablet-icon'
+ },
+ mobile: {
+ label: 'apostrophe:devicePreviewMobile',
+ width: '480px',
+ height: '1000px',
+ icon: 'cellphone-icon'
+ }
+ },
+ // Transform method used on media feature
+ // Can be either:
+ // - (mediaFeature) => { return mediaFeature.replaceAll('xx', 'yy'); }
+ // - null
+ transform: null
+ }
},
async init(self) {
diff --git a/modules/@apostrophecms/asset/lib/globalIcons.js b/modules/@apostrophecms/asset/lib/globalIcons.js
index fc04fe1055..709f2ce368 100644
--- a/modules/@apostrophecms/asset/lib/globalIcons.js
+++ b/modules/@apostrophecms/asset/lib/globalIcons.js
@@ -19,6 +19,7 @@ module.exports = {
'arrow-up-icon': 'ArrowUp',
'binoculars-icon': 'Binoculars',
'calendar-icon': 'Calendar',
+ 'cellphone-icon': 'Cellphone',
'check-all-icon': 'CheckAll',
'check-bold-icon': 'CheckBold',
'check-circle-icon': 'CheckCircle',
@@ -98,6 +99,7 @@ module.exports = {
'menu-down-icon': 'MenuDown',
'minus-box-icon': 'MinusBox',
'minus-icon': 'Minus',
+ 'monitor-icon': 'Monitor',
'paperclip-icon': 'Paperclip',
'pencil-icon': 'Pencil',
'phone-icon': 'Phone',
@@ -107,6 +109,7 @@ module.exports = {
'refresh-icon': 'Refresh',
'shape-icon': 'Shape',
'sign-text-icon': 'SignText',
+ 'tablet-icon': 'Tablet',
'tag-icon': 'Tag',
'text-box-icon': 'TextBox',
'text-box-multiple-icon': 'TextBoxMultiple',
diff --git a/modules/@apostrophecms/asset/lib/webpack/apos/webpack.scss.js b/modules/@apostrophecms/asset/lib/webpack/apos/webpack.scss.js
index 38e1da68bd..d127e25ae0 100644
--- a/modules/@apostrophecms/asset/lib/webpack/apos/webpack.scss.js
+++ b/modules/@apostrophecms/asset/lib/webpack/apos/webpack.scss.js
@@ -1,4 +1,16 @@
+const path = require('path');
+
module.exports = (options, apos) => {
+ const mediaToContainerQueriesLoader = apos.asset.options.devicePreviewMode?.enable === true
+ ? {
+ loader: path.resolve(__dirname, '../media-to-container-queries-loader.js'),
+ options: {
+ debug: apos.asset.options.devicePreviewMode?.debug === true,
+ transform: apos.asset.options.devicePreviewMode?.transform || null
+ }
+ }
+ : '';
+
return {
module: {
rules: [
@@ -6,28 +18,16 @@ module.exports = (options, apos) => {
test: /\.css$/,
use: [
'vue-style-loader',
- // https://github.com/vuejs/vue-style-loader/issues/46#issuecomment-670624576
- {
- loader: 'css-loader',
- options: {
- esModule: false,
- sourceMap: true
- }
- }
+ mediaToContainerQueriesLoader,
+ 'css-loader'
]
},
- // https://github.com/vuejs/vue-style-loader/issues/46#issuecomment-670624576
{
test: /\.s[ac]ss$/,
use: [
'vue-style-loader',
- {
- loader: 'css-loader',
- options: {
- esModule: false,
- sourceMap: true
- }
- },
+ mediaToContainerQueriesLoader,
+ 'css-loader',
{
loader: 'postcss-loader',
options: {
diff --git a/modules/@apostrophecms/asset/lib/webpack/media-to-container-queries-loader.js b/modules/@apostrophecms/asset/lib/webpack/media-to-container-queries-loader.js
new file mode 100644
index 0000000000..71cc16108c
--- /dev/null
+++ b/modules/@apostrophecms/asset/lib/webpack/media-to-container-queries-loader.js
@@ -0,0 +1,94 @@
+const postcss = require('postcss');
+
+module.exports = function (source) {
+ const schema = {
+ title: 'Media to Container Queries Loader options',
+ type: 'object',
+ properties: {
+ debug: {
+ type: 'boolean'
+ },
+ transform: {
+ anyOf: [
+ { type: 'null' },
+ { instanceof: 'Function' }
+ ]
+ }
+ }
+ };
+ const options = this.getOptions(schema);
+
+ const mediaQueryRegex = /@media[^{]*{([\s\S]*?})\s*(\\n)*}/g;
+
+ const convertToContainerQuery = (mediaFeature) => {
+ // NOTE: media queries does not work with the combo
+ // - min-width, max-width, min-height, max-height
+ // - lower than equal, greater than equal
+ const DESCRIPTORS = [
+ 'min-width',
+ 'max-width',
+ 'min-height',
+ 'max-height'
+ ];
+ const OPERATORS = [
+ '>=',
+ '<='
+ ];
+
+ const containerFeature = typeof options.transform === 'function'
+ ? options.transform(mediaFeature)
+ : mediaFeature;
+
+ if (
+ options.debug &&
+ DESCRIPTORS.some(descriptor => containerFeature.includes(descriptor)) &&
+ OPERATORS.some(operator => containerFeature.includes(operator))
+ ) {
+ console.warn('[mediaToContainerQueryLoader] Unsupported media query', containerFeature);
+ }
+
+ return containerFeature;
+ };
+
+ // Prepend container query to media queries
+ const modifiedSource = source.replace(mediaQueryRegex, (match) => {
+ const root = postcss.parse(match.replaceAll(/(? {
+ if (
+ atRule.params.includes('print') &&
+ (!atRule.params.includes('all') || !atRule.params.includes('screen'))
+ ) {
+ return;
+ }
+
+ // Container query
+ const containerAtRule = atRule.clone({
+ name: 'container',
+ params: convertToContainerQuery(atRule.params)
+ .replaceAll(/(only\s*)?(all|screen|print)(,)?(\s)*(and\s*)?/g, '')
+ });
+
+ // Media query
+ // Only apply when data-device-preview-mode is not set
+ atRule.walkRules(rule => {
+ const newRule = rule.clone({
+ selectors: rule.selectors.map(selector => {
+ if (selector.startsWith('body')) {
+ return selector.replace('body', ':where(body:not([data-device-preview-mode]))');
+ }
+
+ return `:where(body:not([data-device-preview-mode])) ${selector}`;
+ })
+ });
+
+ rule.replaceWith(newRule);
+ });
+
+ root.append(containerAtRule);
+ });
+
+ return root.toString();
+ });
+
+ return modifiedSource;
+};
diff --git a/modules/@apostrophecms/asset/lib/webpack/src/webpack.scss.js b/modules/@apostrophecms/asset/lib/webpack/src/webpack.scss.js
index 5ff73b758f..6b94fbc9df 100644
--- a/modules/@apostrophecms/asset/lib/webpack/src/webpack.scss.js
+++ b/modules/@apostrophecms/asset/lib/webpack/src/webpack.scss.js
@@ -1,6 +1,17 @@
+const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = (options, apos, srcBuildNames) => {
+ const mediaToContainerQueriesLoader = apos.asset.options.devicePreviewMode?.enable === true
+ ? {
+ loader: path.resolve(__dirname, '../media-to-container-queries-loader.js'),
+ options: {
+ debug: apos.asset.options.devicePreviewMode?.debug === true,
+ transform: apos.asset.options.devicePreviewMode?.transform || null
+ }
+ }
+ : '';
+
return {
module: {
rules: [
@@ -9,6 +20,7 @@ module.exports = (options, apos, srcBuildNames) => {
use: [
// Instead of style-loader, to avoid FOUC
MiniCssExtractPlugin.loader,
+ mediaToContainerQueriesLoader,
// Parses CSS imports and make css-loader ignore urls. Urls will still be handled by webpack
{
loader: 'css-loader',
diff --git a/modules/@apostrophecms/command-menu/ui/apos/components/AposCommandMenuKey.vue b/modules/@apostrophecms/command-menu/ui/apos/components/AposCommandMenuKey.vue
index 8982507083..8a8a2152fb 100644
--- a/modules/@apostrophecms/command-menu/ui/apos/components/AposCommandMenuKey.vue
+++ b/modules/@apostrophecms/command-menu/ui/apos/components/AposCommandMenuKey.vue
@@ -8,7 +8,7 @@
fill-color="color"
/>
- {{ $t(label ) }}
+ {{ $t(label) }}
diff --git a/modules/@apostrophecms/command-menu/ui/apos/components/AposCommandMenuShortcut.vue b/modules/@apostrophecms/command-menu/ui/apos/components/AposCommandMenuShortcut.vue
index 29a77c1c6f..d0d210a330 100644
--- a/modules/@apostrophecms/command-menu/ui/apos/components/AposCommandMenuShortcut.vue
+++ b/modules/@apostrophecms/command-menu/ui/apos/components/AposCommandMenuShortcut.vue
@@ -110,10 +110,13 @@ export default {
this.modal.showModal = false;
},
getLabel(label) {
- if (label && label.key && label.type) {
+ if (typeof label === 'object' && label.key) {
+ const type = label.type ? this.$t(label.type) : '';
+
return this.$t({
+ ...label,
key: label.key,
- type: this.$t(label.type)
+ type
});
}
diff --git a/modules/@apostrophecms/i18n/i18n/en.json b/modules/@apostrophecms/i18n/i18n/en.json
index 7ab221e5f7..b4c1469e37 100644
--- a/modules/@apostrophecms/i18n/i18n/en.json
+++ b/modules/@apostrophecms/i18n/i18n/en.json
@@ -96,6 +96,7 @@
"commandMenuSelectAll": "Select all",
"commandMenuShortcut": "Keyboard Shortcuts",
"commandMenuShowShortcutList": "Show shortcut list",
+ "commandMenuToggleDevicePreviewMode": "Preview: {{ device }}",
"commandMenuToggleEditPreviewMode": "Toggle edit / preview",
"commandMenuTogglePublishedDraftDocument": "Toggle published / draft document",
"commandMenuTaskbar": "Taskbar",
@@ -126,6 +127,10 @@
"deleteTable": "Delete Table",
"description": "Description",
"deselectAll": "Deselect All",
+ "devicePreviewDesktop": "Desktop",
+ "devicePreviewExit": "Exit",
+ "devicePreviewMobile": "Mobile",
+ "devicePreviewTablet": "Tablet",
"disabled": "Disabled",
"discardChanges": "Discard Changes",
"discardChangesPrompt": "Do you want to discard changes?",
diff --git a/modules/@apostrophecms/template/views/outerLayoutBase.html b/modules/@apostrophecms/template/views/outerLayoutBase.html
index 73380f352d..7647c03d20 100644
--- a/modules/@apostrophecms/template/views/outerLayoutBase.html
+++ b/modules/@apostrophecms/template/views/outerLayoutBase.html
@@ -29,6 +29,7 @@
{% endif %}
{% endblock %}
+
{% block beforeMain %}{% endblock %}
{% block mainAnchor %}
{% endblock %}
diff --git a/modules/@apostrophecms/ui/ui/apos/scss/global/_device_preview.scss b/modules/@apostrophecms/ui/ui/apos/scss/global/_device_preview.scss
new file mode 100644
index 0000000000..4c5bcdc022
--- /dev/null
+++ b/modules/@apostrophecms/ui/ui/apos/scss/global/_device_preview.scss
@@ -0,0 +1,38 @@
+[data-apos-context-label] {
+ @include type-help;
+
+ & {
+ position: relative;
+ top: $spacing-base * 5;
+ display: none;
+ text-align: center;
+ }
+}
+
+body[data-device-preview-mode] {
+ background: var(--a-base-10);
+
+ [data-apos-context-label] {
+ display: block;
+ }
+
+ [data-apos-refreshable] {
+ container-type: size;
+ overflow: clip scroll;
+ flex-grow: unset;
+ margin: $spacing-base * 6 auto auto;
+ border: 1px solid var(--a-base-6);
+ box-shadow: 0 0 12px 0 var(--a-base-7);
+ background-color: var(--a-background-primary);
+
+ &[data-resizable="false"] {
+ transition: width 400ms ease, height 400ms ease;
+ }
+
+ &[data-resizable="true"] {
+ resize: both;
+ overflow: scroll;
+ }
+ }
+
+}
diff --git a/modules/@apostrophecms/ui/ui/apos/scss/global/import-all.scss b/modules/@apostrophecms/ui/ui/apos/scss/global/import-all.scss
index 788cdcd5e0..13d08ad003 100644
--- a/modules/@apostrophecms/ui/ui/apos/scss/global/import-all.scss
+++ b/modules/@apostrophecms/ui/ui/apos/scss/global/import-all.scss
@@ -11,3 +11,4 @@
@import './_tables';
@import './_tooltips';
@import './_widgets';
+@import './_device_preview';
diff --git a/package.json b/package.json
index ce4c1b025e..419bc87f71 100644
--- a/package.json
+++ b/package.json
@@ -100,6 +100,7 @@
"path-to-regexp": "^1.8.0",
"performance-now": "^2.1.0",
"pinia": "^2.1.7",
+ "postcss": "^8.4.47",
"postcss-html": "^1.3.0",
"postcss-loader": "^5.0.0",
"postcss-scss": "^4.0.3",