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",