apostrophe 4.27.1 → 4.28.1

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 (57) hide show
  1. package/.claude/settings.local.json +15 -0
  2. package/CHANGELOG.md +40 -0
  3. package/README.md +142 -0
  4. package/index.js +3 -0
  5. package/lib/stream-proxy.js +49 -0
  6. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextTitle.vue +2 -11
  7. package/modules/@apostrophecms/area/ui/apos/apps/AposAreas.js +38 -6
  8. package/modules/@apostrophecms/area/ui/apos/components/AposAreaEditor.vue +12 -1
  9. package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +111 -41
  10. package/modules/@apostrophecms/area/ui/apos/components/AposBreadcrumbOperations.vue +1 -0
  11. package/modules/@apostrophecms/area/ui/apos/components/AposWidgetControls.vue +22 -10
  12. package/modules/@apostrophecms/area/ui/apos/logic/AposAreaEditor.js +40 -0
  13. package/modules/@apostrophecms/asset/index.js +3 -2
  14. package/modules/@apostrophecms/attachment/index.js +270 -0
  15. package/modules/@apostrophecms/doc/index.js +8 -2
  16. package/modules/@apostrophecms/doc-type/index.js +81 -1
  17. package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocEditor.vue +18 -2
  18. package/modules/@apostrophecms/express/index.js +30 -1
  19. package/modules/@apostrophecms/file/index.js +70 -6
  20. package/modules/@apostrophecms/i18n/index.js +20 -1
  21. package/modules/@apostrophecms/image/index.js +11 -0
  22. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue +31 -6
  23. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue +12 -10
  24. package/modules/@apostrophecms/login/index.js +43 -11
  25. package/modules/@apostrophecms/modal/ui/apos/components/AposDocsManagerToolbar.vue +2 -1
  26. package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +5 -0
  27. package/modules/@apostrophecms/page/index.js +9 -11
  28. package/modules/@apostrophecms/page-type/index.js +6 -1
  29. package/modules/@apostrophecms/piece-page-type/index.js +100 -13
  30. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposImageControlDialog.vue +1 -0
  31. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +28 -12
  32. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposTiptapLink.vue +1 -0
  33. package/modules/@apostrophecms/schema/ui/apos/components/AposSearchList.vue +1 -1
  34. package/modules/@apostrophecms/styles/lib/apiRoutes.js +25 -5
  35. package/modules/@apostrophecms/styles/lib/handlers.js +19 -0
  36. package/modules/@apostrophecms/styles/lib/methods.js +35 -12
  37. package/modules/@apostrophecms/styles/ui/apos/components/TheAposStyles.vue +7 -2
  38. package/modules/@apostrophecms/task/index.js +9 -1
  39. package/modules/@apostrophecms/template/views/outerLayoutBase.html +3 -0
  40. package/modules/@apostrophecms/ui/index.js +2 -0
  41. package/modules/@apostrophecms/ui/ui/apos/components/AposButtonGroup.vue +1 -1
  42. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue +5 -0
  43. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenuDialog.vue +5 -0
  44. package/modules/@apostrophecms/ui/ui/apos/lib/vue.js +2 -0
  45. package/modules/@apostrophecms/ui/ui/apos/stores/widget.js +12 -7
  46. package/modules/@apostrophecms/ui/ui/apos/stores/widgetGraph.js +461 -0
  47. package/modules/@apostrophecms/ui/ui/apos/universal/graph.js +452 -0
  48. package/modules/@apostrophecms/ui/ui/apos/universal/widgetGraph.js +10 -0
  49. package/modules/@apostrophecms/uploadfs/index.js +15 -1
  50. package/modules/@apostrophecms/url/index.js +419 -1
  51. package/package.json +5 -5
  52. package/test/add-missing-schema-fields-project/node_modules/.package-lock.json +131 -0
  53. package/test/external-front.js +1 -0
  54. package/test/files.js +264 -0
  55. package/test/login-requirements.js +145 -3
  56. package/test/static-build.js +2701 -0
  57. package/test/universal-graph.js +1135 -0
@@ -0,0 +1,15 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(timeout 180 npx mocha:*)",
5
+ "Bash(timeout 600 npx mocha:*)",
6
+ "Bash(npm ls:*)",
7
+ "Bash(timeout 540 npx mocha:*)",
8
+ "Bash(echo:*)",
9
+ "Bash(timeout 10 node:*)",
10
+ "Bash(timeout 300 npx mocha:*)",
11
+ "Bash(timeout 60 npx mocha:*)",
12
+ "Bash(timeout 120 npx mocha:*)"
13
+ ]
14
+ }
15
+ }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.28.1
4
+
5
+ ### Patch Changes
6
+
7
+ - f8d1952: Bug fix: the "pretty URLs" feature of @apostrophecms/file is now compatible with locale prefixes.
8
+
9
+ ## 4.28.0
10
+
11
+ ### Adds
12
+
13
+ - Adds support for static URLs and static external frontend builds.
14
+ - Adds widget graph store, accessible in Admin UI.
15
+ - Support for the new `prettyUrls: true` option for @apostrophecms/file, which enables "pretty URLs" for PDFs and other items in the file library, in exchange for a small performance impact. Edit the slug field to adjust the pretty URL
16
+
17
+ ### Fixes
18
+
19
+ - Fix a bug when rich text link open in new tab checkbox can't be cleared
20
+ - Ensures the `getOne` API can correctly retrieve documents that are not localized. Thanks to [Eduardo Correal](https://github.com/ecb34).
21
+ - Clicking a choice should dismiss the relationship suggestions dropdown. This regression was caused by part of the patch in release 4.27.1.
22
+ - lint on main
23
+ - Adds inner wrapper to area widget that creates a separate z-index context for the user, fixing widget/apostrophe UI conflicts
24
+ - Fixes a bug where Layout's mode erroneously switches state
25
+ - Fixes a bug where Layouts synthetic column styles were not reaching their children
26
+ - Fixes styles being sanitized as html (breaks quotes) and resolves duplicate styles on the page.
27
+ - Fix subtle bug in AposPermissionGrid that caused unrelated clicks to be "swallowed" due to a race condition at low network speeds
28
+ - Fixes two conditions where slow internet speed could cause input to lose focus before a selection can be registered.
29
+
30
+ ### Changes
31
+
32
+ - Improve re-rendering UX while keeping the performance optimization
33
+ - raise the user's widget z-index context only when focused
34
+ - Hide add content buttons on rich text editing, like widget controls
35
+ - Refine in-context focus states for calmer UX
36
+ - Simplifies some in-context UI rendering checks
37
+ - Updated dependencies
38
+
39
+ ### Security
40
+
41
+ - This previously undisclosed security vulnerability allowed users who had compromised a password to perform actions in the CMS without 2FA. For sites not using 2FA (e.g. our @apostrophecms/login-totp module), this changes nothing. But for those using our TOTP module or similar, upgrading to this release is urgent. Thanks to 0xkakashi for reporting the issue and recommending a fix.
42
+
3
43
  ## 4.27.1 (2026-03-03)
4
44
 
5
45
  ### Fixes
package/README.md ADDED
@@ -0,0 +1,142 @@
1
+ <div align="center">
2
+ <a href="https://github.com/apostrophecms/apostrophe">
3
+ <img src="logo.svg" alt="ApostropheCMS logo" width="80" height="80">
4
+ </a>
5
+
6
+ <h1>ApostropheCMS</h1>
7
+
8
+ <p>
9
+ <a aria-label="Join the community on Discord" href="http://chat.apostrophecms.org">
10
+ <img alt="" src="https://img.shields.io/discord/517772094482677790?color=5865f2&label=Join%20the%20Discord&logo=discord&logoColor=fff&labelColor=000&style=for-the-badge&logoWidth=20" />
11
+ </a>
12
+ <a aria-label="License" href="https://github.com/apostrophecms/apostrophe/blob/main/LICENSE.md">
13
+ <img alt="" src="https://img.shields.io/static/v1?style=for-the-badge&labelColor=000000&label=License&message=MIT&color=3DA639" />
14
+ </a>
15
+ </p>
16
+
17
+ <p>
18
+ <strong>Full-stack CMS for developers and content teams</strong><br />
19
+ Build websites with in-context editing and headless flexibility using Node.js and MongoDB.
20
+ <br />
21
+ <a href="https://docs.apostrophecms.org/"><strong>Documentation »</strong></a>
22
+ <br />
23
+ <br />
24
+ <a href="http://demo.apostrophecms.com">Demo</a>
25
+ ·
26
+ <a href="https://roadmap.apostrophecms.com/roadmap">Roadmap</a>
27
+ ·
28
+ <a href="https://github.com/apostrophecms/apostrophe/issues/new?assignees=&labels=bug,3.0&template=bug_report.md&title=">Report Bug</a>
29
+ </p>
30
+ </div>
31
+
32
+ ## About
33
+
34
+ ApostropheCMS is a full-stack content management system built with Node.js and MongoDB. Content creators can edit directly on live pages without switching between admin interfaces, while developers can build with modern JavaScript or use it headlessly with any frontend framework.
35
+
36
+ ### Key Features
37
+
38
+ - **🎯 In-Context Editing** - Content creators edit directly on the live page, seeing changes instantly
39
+ - **⚡ Headless-Ready** - Use any frontend framework while keeping the powerful admin experience
40
+ - **🛠️ Developer-First** - Built with Node.js and MongoDB for full-stack JavaScript development
41
+ - **📈 Scales Beautifully** - From small sites to enterprise applications handling millions of pages
42
+ - **🔐 Enterprise Features** - Advanced permissions, workflow management, automated translations, and more
43
+
44
+ ## System Requirements
45
+
46
+ | Requirement | Version | Installation Notes |
47
+ |-------------|---------|-------------------|
48
+ | **Node.js** | 20.x+ | Use [NVM](https://github.com/nvm-sh/nvm) for version management |
49
+ | **MongoDB** | 6.0+ | [MongoDB Atlas](https://www.mongodb.com/atlas) (cloud) or local install |
50
+ | **npm** | 10.x+ | Included with Node.js |
51
+
52
+ See our [setup guides](https://docs.apostrophecms.org/guide/development-setup.html) for installation instructions.
53
+
54
+ ## Quick Start
55
+
56
+ Get ApostropheCMS running locally in minutes:
57
+
58
+ ```bash
59
+ # Option 1: Install CLI globally (recommended for multiple projects)
60
+ npm install -g @apostrophecms/cli
61
+ apos create my-website
62
+ cd my-website
63
+ npm run dev
64
+
65
+ # Option 2: Use npx for one-time project creation
66
+ npx @apostrophecms/cli create my-website
67
+ cd my-website
68
+ npm run dev
69
+ ```
70
+
71
+ Your new ApostropheCMS site will be available at `http://localhost:3000` with a powerful admin interface at `/login`.
72
+
73
+ ### Prefer to Go Headless?
74
+
75
+ **Get started with Astro integration** - the easiest way to build headless sites while keeping visual editing:
76
+
77
+ - **[Apollo Starter Kit (Astro)](https://apostrophecms.com/starter-kits/apollo-starter-kit-for-astro-cms)** - Production-ready foundation with beautiful design system and rich content features
78
+ - **[Essentials Starter Kit (Astro)](git clone https://github.com/apostrophecms/starter-kit-astro-essentials)** - Minimal, clean foundation for building custom designs from scratch
79
+
80
+ Both starter kits provide headless CMS power with in-context editing, letting content creators edit directly on the live site while you build with modern frontend tools. Our Astro integration handles all the content fetching automatically—no REST API calls to write.
81
+
82
+ **Desire a different frontend framework?** Use our REST APIs with React, Vue, Svelte, or any other framework:
83
+
84
+ - **[REST API Documentation](https://docs.apostrophecms.org/reference/api/pieces.html)** - Complete API reference
85
+ - **[Headless CMS Guide](https://docs.apostrophecms.org/guide/headless-cms.html)** - Integration walkthrough for any framework
86
+
87
+ ### Hosting & Deployment
88
+
89
+ Choose [ApostropheCMS hosting](https://apostrophecms.com/hosting) for turnkey solutions with optimized performance and dedicated support, or deploy to [any platform where Node.js runs](https://docs.apostrophecms.org/guide/hosting.html).
90
+
91
+ ## Built With Modern Tech
92
+
93
+ - **[Node.js](https://nodejs.org/)** - JavaScript runtime for server-side development
94
+ - **[MongoDB](https://www.mongodb.com/)** - Flexible document database for content storage
95
+ - **ESM Modules** - Native ES6 module support for modern JavaScript
96
+ - **Vite** - Lightning-fast build tool and development server
97
+ - **Modern JavaScript** - ES6+, async/await, and contemporary development patterns
98
+
99
+ ## Community & Support
100
+
101
+ **Join other developers and content creators using ApostropheCMS:**
102
+
103
+ - **[Discord](https://discord.com/invite/XkbRNq7)** - Get help, share projects, and connect with other users
104
+ - **[GitHub Discussions](https://github.com/apostrophecms/apostrophe/discussions)** - Feature requests, technical discussions, and community support
105
+ - **[Documentation](https://docs.apostrophecms.org/)** - Comprehensive guides, tutorials, and API references
106
+
107
+ ## Contributing
108
+
109
+ We welcome contributions from the community! Whether you're fixing bugs, adding features, or improving documentation, your help makes ApostropheCMS better for everyone.
110
+
111
+ - **[Contribution Guide](https://github.com/apostrophecms/apostrophe/blob/main/CONTRIBUTING.md)** - How to contribute code, documentation, and feedback
112
+ - **[Good First Issues](https://github.com/apostrophecms/apostrophe/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22)** - Perfect starting points for new contributors
113
+
114
+
115
+ ## Pro Features
116
+
117
+ **For teams and organizations requiring additional features:**
118
+
119
+ - **🔐 Advanced User Management** - Granular permissions, user groups, and access controls
120
+ - **🌍 Automated Translation** - AI-powered translation with DeepL, Google Translate, and Azure
121
+ - **📊 Analytics & SEO** - Built-in SEO optimization and content analytics
122
+ - **⚡ Performance Optimization** - Advanced caching, CDN integration, and performance monitoring
123
+ - **🏢 Multisite Management** - Manage multiple sites from a single dashboard with shared resources
124
+ - **💼 Professional Support** - Dedicated support, training, and consultation services
125
+
126
+ [Explore all the pro extensions](https://apostrophecms.com/extensions?autocomplete=&license=assembly&license=pro) and [sign up](https://app.apostrophecms.com/login) for a Pro license in our self-service Apostrophe Workspaces, or [contact us](https://apostrophecms.com/contact-us) to learn about licensing and support options.
127
+
128
+ ## License
129
+
130
+ ApostropheCMS is open source software licensed under the [MIT License](https://github.com/apostrophecms/apostrophe/blob/main/LICENSE.md). This means you're free to use, modify, and distribute it for both personal and commercial projects.
131
+
132
+ ---
133
+
134
+ <div align="center">
135
+ <p>
136
+ <strong>Ready to build something amazing?</strong><br>
137
+ <a href="https://docs.apostrophecms.org/">Get started with our documentation</a> or <a href="https://apostrophecms.com/contact-us">talk to our team</a>
138
+ </p>
139
+ <p>
140
+ <em>Built with ❤️ by the <a href="https://apostrophecms.com">ApostropheCMS team</a></em>
141
+ </p>
142
+ </div>
package/index.js CHANGED
@@ -479,6 +479,9 @@ async function apostrophe(options, telemetry, rootSpan) {
479
479
  // Environment variable override
480
480
  self.options.baseUrl = process.env.APOS_BASE_URL || self.options.baseUrl;
481
481
  self.baseUrl = self.options.baseUrl;
482
+ self.options.staticBaseUrl = process.env.APOS_STATIC_BASE_URL ||
483
+ self.options.staticBaseUrl;
484
+ self.staticBaseUrl = self.options.staticBaseUrl;
482
485
  self.prefix = self.options.prefix || '';
483
486
  }
484
487
 
@@ -0,0 +1,49 @@
1
+ // Make a request for url and stream it to req.res,
2
+ // passing through relevant headers, without downloading
3
+ // the whole thing to disk. Site-relative URLs are
4
+ // resolved via req.baseUrl. Server-side errors are logged to
5
+ // error() which should accept multiple arguments
6
+
7
+ module.exports = async function(req, url, { error }) {
8
+ const res = req.res;
9
+ if (url.startsWith('/')) {
10
+ // Can't make a self-request without an absolute URL
11
+ url = `${req.baseUrl}${url}`;
12
+ }
13
+ let response;
14
+ try {
15
+ response = await fetch(url);
16
+ } catch (e) {
17
+ return send502(e);
18
+ }
19
+ for (const header of [ 'content-type', 'etag', 'last-modified', 'content-disposition', 'cache-control' ]) {
20
+ const result = response.headers.get(header);
21
+ if (result != null) {
22
+ res.header(header, result);
23
+ }
24
+ }
25
+ res.status(response.status);
26
+ if (response.body == null) {
27
+ return res.end();
28
+ }
29
+ response.body.pipeTo(new WritableStream({
30
+ write(chunk) {
31
+ res.write(chunk);
32
+ },
33
+ close() {
34
+ res.end();
35
+ },
36
+ abort(reason) {
37
+ if (!res.headersSent) {
38
+ return send502(reason);
39
+ } else {
40
+ // Only way to signal failure after headers are sent
41
+ res.destroy();
42
+ }
43
+ }
44
+ }));
45
+ function send502(e) {
46
+ error(`Error fetching "ugly URL" ${url} to resolve pretty URL ${req.url}:`, e);
47
+ return res.status(502).send('upstream media error fetching data for pretty URL');
48
+ }
49
+ };
@@ -166,22 +166,13 @@ export default {
166
166
  color: var(--a-text-primary);
167
167
  }
168
168
 
169
- &__document-title {
170
- margin-top: 1px;
171
- }
172
-
173
169
  &__separator {
174
170
  align-items: center;
175
- margin-top: 1px;
176
171
  padding: 0 7px;
177
172
  }
178
173
 
179
- &__document {
180
- margin-top: 3.5px;
181
-
182
- :deep(.apos-context-menu__items) {
183
- min-width: 150px;
184
- }
174
+ &__document :deep(.apos-context-menu__items) {
175
+ min-width: 150px;
185
176
  }
186
177
  }
187
178
 
@@ -1,5 +1,6 @@
1
1
 
2
- import createApp from 'Modules/@apostrophecms/ui/lib/vue';
2
+ import createApp, { pinia } from 'Modules/@apostrophecms/ui/lib/vue';
3
+ import { useWidgetGraphStore } from 'Modules/@apostrophecms/ui/stores/widgetGraph.js';
3
4
  import { nextTick } from 'vue';
4
5
 
5
6
  export default function() {
@@ -24,6 +25,12 @@ export default function() {
24
25
  });
25
26
 
26
27
  apos.bus.$on('refreshed', function() {
28
+ // Re-instantiate the on-page widget graph before remounting areas.
29
+ // The normal mounted hooks will rebuild it from fresh data.
30
+ const graphStore = useWidgetGraphStore(pinia);
31
+ if (apos.adminBar?.contextId) {
32
+ graphStore.resetGraph(apos.adminBar.contextId);
33
+ }
27
34
  createAreaAppsAndRunPlayersIfDone();
28
35
  });
29
36
 
@@ -117,10 +124,18 @@ export default function() {
117
124
 
118
125
  el.parentNode.replaceChild(apos.area.activeEditor.$el, el);
119
126
  } else {
120
- observer = new IntersectionObserver(observed, {
121
- rootMargin: '600px'
122
- });
123
- observer.observe(el);
127
+ const rect = el.getBoundingClientRect();
128
+ const isInViewport = rect.bottom >= 0 &&
129
+ rect.top <= window.innerHeight;
130
+
131
+ if (isInViewport) {
132
+ mountApp();
133
+ } else {
134
+ observer = new IntersectionObserver(observed, {
135
+ rootMargin: '600px'
136
+ });
137
+ observer.observe(el);
138
+ }
124
139
  }
125
140
 
126
141
  function observed(entries) {
@@ -129,8 +144,14 @@ export default function() {
129
144
  return;
130
145
  }
131
146
  if (created) {
147
+ observer.disconnect();
132
148
  return;
133
149
  }
150
+ mountApp();
151
+ observer.disconnect();
152
+ }
153
+
154
+ function mountApp() {
134
155
  const app = createApp(component, {
135
156
  options,
136
157
  id: data._id,
@@ -142,10 +163,21 @@ export default function() {
142
163
  parentOptions,
143
164
  renderings
144
165
  });
166
+
167
+ // Resolve graphKey: if this area is inside a modal that owns a
168
+ // graph (data-apos-graph-key), use that key. Otherwise fall back
169
+ // to the on-page contextId. This single DOM lookup bridges the
170
+ // provide/inject gap created by createApp.
171
+ const graphKey = el.closest('[data-apos-graph-key]')
172
+ ?.getAttribute('data-apos-graph-key') || apos.adminBar?.contextId || null;
173
+ // Provide the resolved graphKey so every descendant component
174
+ // can simply inject('aposGraphKey') and get the correct value.
175
+ if (graphKey) {
176
+ app.provide('aposGraphKey', graphKey);
177
+ }
145
178
  app.mount(el);
146
179
  mountedApps.set(el, app);
147
180
  created = true;
148
- observer.disconnect();
149
181
  }
150
182
  }
151
183
 
@@ -3,7 +3,12 @@
3
3
  v-click-outside-element="resetFocusedArea"
4
4
  :data-apos-area="areaId"
5
5
  class="apos-area"
6
- :class="themeClass"
6
+ :class="[
7
+ themeClass,
8
+ {
9
+ 'apos-area--empty': next.length === 0
10
+ }
11
+ ]"
7
12
  @click="setFocusedArea(areaId, $event)"
8
13
  >
9
14
  <div
@@ -61,6 +66,12 @@
61
66
  :disabled="field && field.readOnly"
62
67
  :max-reached="maxReached"
63
68
  :rendering="rendering(widget)"
69
+ :raised="raisedWidgets.has(widget._id)"
70
+ :style="{
71
+ 'z-index': raisedWidgets.has(widget._id)
72
+ ? next.length + 1
73
+ : null
74
+ }"
64
75
  @up="up"
65
76
  @down="down"
66
77
  @remove="remove"
@@ -23,14 +23,13 @@
23
23
  @keyup.enter="onKeyup"
24
24
  >
25
25
  <div
26
- v-if="!breadcrumbDisabled"
27
26
  ref="label"
28
27
  class="apos-area-widget-controls apos-area-widget__label"
29
28
  :class="labelsClasses"
30
29
  >
31
30
  <ol
32
31
  class="apos-area-widget__breadcrumbs"
33
- @click="isSuppressingWidgetControls = false"
32
+ @click="clearSuppressionFlags"
34
33
  >
35
34
  <li
36
35
  class="
@@ -92,7 +91,7 @@
92
91
  />
93
92
  </div>
94
93
  <div
95
- v-if="!controlsDisabled"
94
+ v-if="!controlsDisabled && !maxReached && !isSuppressingAddContentButtons"
96
95
  class="
97
96
  apos-area-widget-controls
98
97
  apos-area-widget-controls--add--top
@@ -143,43 +142,52 @@
143
142
  @operation="onOperation"
144
143
  />
145
144
  </div>
146
-
147
- <!-- Still used for contextual editing components -->
148
- <component
149
- :is="widgetEditorComponent(widget.type)"
150
- v-if="isContextual && !foreign"
151
- :key="generation"
152
- :class="adminContentDirectionClass"
153
- :options="widgetOptions"
154
- :type="widget.type"
155
- :model-value="widget"
156
- :meta="meta"
157
- :doc-id="docId"
158
- :focused="isFocused"
159
- @update="$emit('update', $event)"
160
- @suppress-widget-controls="isSuppressingWidgetControls = true"
161
- />
162
- <component
163
- :is="widgetComponent(widget.type)"
164
- v-else
165
- :id="widget._id"
166
- :key="`${generation}-preview`"
167
- :class="adminContentDirectionClass"
168
- :options="widgetOptions"
169
- :type="widget.type"
170
- :area-field-id="fieldId"
171
- :following-values="followingValuesWithParent"
172
- :model-value="widget"
173
- :value="widget"
174
- :meta="meta"
175
- :foreign="foreign"
176
- :doc-id="docId"
177
- :rendering="rendering"
178
- @edit="$emit('edit', i);"
179
- @update="$emit('update', $event);"
180
- />
181
145
  <div
182
- v-if="!controlsDisabled"
146
+ class="apos-area-widget-rendered-widget"
147
+ :style="{
148
+ 'z-index': raised
149
+ ? 0
150
+ : null
151
+ }"
152
+ >
153
+ <!-- Still used for contextual editing components -->
154
+ <component
155
+ :is="widgetEditorComponent(widget.type)"
156
+ v-if="isContextual && !foreign"
157
+ :key="generation"
158
+ :class="adminContentDirectionClass"
159
+ :options="widgetOptions"
160
+ :type="widget.type"
161
+ :model-value="widget"
162
+ :meta="meta"
163
+ :doc-id="docId"
164
+ :focused="isFocused"
165
+ @update="$emit('update', $event)"
166
+ @suppress-widget-controls="doSuppressWidgetControls"
167
+ @suppress-add-content-buttons="doSuppressAddContentButtons"
168
+ />
169
+ <component
170
+ :is="widgetComponent(widget.type)"
171
+ v-else
172
+ :id="widget._id"
173
+ :key="`${generation}-preview`"
174
+ :class="adminContentDirectionClass"
175
+ :options="widgetOptions"
176
+ :type="widget.type"
177
+ :area-field-id="fieldId"
178
+ :following-values="followingValuesWithParent"
179
+ :model-value="widget"
180
+ :value="widget"
181
+ :meta="meta"
182
+ :foreign="foreign"
183
+ :doc-id="docId"
184
+ :rendering="rendering"
185
+ @edit="$emit('edit', i);"
186
+ @update="$emit('update', $event);"
187
+ />
188
+ </div>
189
+ <div
190
+ v-if="!controlsDisabled && !maxReached && !isSuppressingAddContentButtons"
183
191
  class="
184
192
  apos-area-widget-controls
185
193
  apos-area-widget-controls--add
@@ -209,13 +217,25 @@
209
217
 
210
218
  <script>
211
219
  import { mapState, mapActions } from 'pinia';
220
+ import { unref } from 'vue';
212
221
  import { useWidgetStore } from 'Modules/@apostrophecms/ui/stores/widget';
213
222
  import { useBreakpointPreviewStore } from 'Modules/@apostrophecms/ui/stores/breakpointPreview.js';
214
223
  import { useModalStore } from 'Modules/@apostrophecms/ui/stores/modal.js';
224
+ import { useWidgetGraphStore } from 'Modules/@apostrophecms/ui/stores/widgetGraph.js';
215
225
 
216
226
  export default {
217
227
  name: 'AposAreaWidget',
228
+ inject: {
229
+ aposGraphKey: {
230
+ from: 'aposGraphKey',
231
+ default: null
232
+ }
233
+ },
218
234
  props: {
235
+ raised: {
236
+ type: Boolean,
237
+ default: false
238
+ },
219
239
  docId: {
220
240
  type: String,
221
241
  required: false,
@@ -316,6 +336,7 @@ export default {
316
336
  mounted: false, // hack around needing DOM to be rendered for computed classes
317
337
  menuOpen: null,
318
338
  isSuppressingWidgetControls: false,
339
+ isSuppressingAddContentButtons: false,
319
340
  hasClickOutsideListener: false,
320
341
  classes: {
321
342
  show: 'apos-is-visible',
@@ -447,7 +468,7 @@ export default {
447
468
  },
448
469
  labelsClasses() {
449
470
  return {
450
- [this.classes.show]: this.isHovered || this.isFocused || this.isEmphasized,
471
+ [this.classes.show]: (this.isHovered && !this.focusedWidget) || this.isFocused,
451
472
  // Force LTR for breadcrumbs for now so that nested AreaWidgets
452
473
  // behave properly.
453
474
  'apos-ltr': true
@@ -455,7 +476,7 @@ export default {
455
476
  },
456
477
  addClasses() {
457
478
  return {
458
- [this.classes.show]: this.isHovered || this.isFocused,
479
+ [this.classes.show]: (this.isHovered && !this.focusedWidget) || this.isFocused,
459
480
  [`${this.classes.open}--menu-${this.menuOpen}`]: !!this.menuOpen
460
481
  };
461
482
  },
@@ -493,6 +514,7 @@ export default {
493
514
  } else {
494
515
  this.menuOpen = null;
495
516
  this.isSuppressingWidgetControls = false;
517
+ this.isSuppressingAddContentButtons = false;
496
518
  this.removeClickOutsideListener();
497
519
  }
498
520
  // Helps get scroll tracking unstuck on new/modified widgets
@@ -515,11 +537,14 @@ export default {
515
537
  // a 'focus my parent' plea
516
538
  apos.bus.$on('widget-focus-parent', this.focusParent);
517
539
  apos.bus.$on('context-menu-toggled', this.getFocusForMenu);
540
+ apos.bus.$on('suppress-focused-widget-controls', this.doSuppressWidgetControls);
518
541
 
519
542
  this.breadcrumbs.$lastEl = this.$el;
520
543
 
521
544
  this.getBreadcrumbs();
522
545
 
546
+ this.registerInGraph();
547
+
523
548
  if (this.focusedWidget) {
524
549
  // If another widget was in focus (because the user clicked the "add"
525
550
  // menu, for example), and this widget was created, give the new widget
@@ -549,12 +574,50 @@ export default {
549
574
  unmounted() {
550
575
  // Remove the focus parent listener when unmounted
551
576
  apos.bus.$off('widget-focus-parent', this.focusParent);
577
+ apos.bus.$off('suppress-focused-widget-controls', this.doSuppressWidgetControls);
552
578
  window.removeEventListener('scroll', this.stickyControlsScroll);
553
579
  window.removeEventListener('resize', this.stickyControlsResize);
580
+ this.unregisterFromGraph();
554
581
  },
555
582
  methods: {
583
+ clearSuppressionFlags() {
584
+ this.isSuppressingWidgetControls = false;
585
+ this.isSuppressingAddContentButtons = false;
586
+ },
556
587
  ...mapActions(useWidgetStore, [ 'setFocusedWidget', 'setHoveredWidget' ]),
557
588
  ...mapActions(useModalStore, [ 'getAdminContentDirectionClass' ]),
589
+ ...mapActions(useWidgetGraphStore, {
590
+ storeRegisterWidget: 'registerWidget',
591
+ storeUnregisterWidget: 'unregisterWidget'
592
+ }),
593
+ registerInGraph() {
594
+ if (this.foreign) {
595
+ return;
596
+ }
597
+ const graphKey = unref(this.aposGraphKey);
598
+ if (!graphKey) {
599
+ return;
600
+ }
601
+ this.storeRegisterWidget(graphKey, this.widget, {
602
+ areaId: this.areaId
603
+ });
604
+ },
605
+ unregisterFromGraph() {
606
+ if (this.foreign) {
607
+ return;
608
+ }
609
+ const graphKey = unref(this.aposGraphKey);
610
+ if (!graphKey) {
611
+ return;
612
+ }
613
+ this.storeUnregisterWidget(graphKey, this.widget._id);
614
+ },
615
+ doSuppressWidgetControls() {
616
+ this.isSuppressingWidgetControls = true;
617
+ },
618
+ doSuppressAddContentButtons() {
619
+ this.isSuppressingAddContentButtons = true;
620
+ },
558
621
  // Emits same actions as the native operations,
559
622
  // e.g ('edit', { index }), ('remove', { index }), etc.
560
623
  onOperation({ name, payload }) {
@@ -716,6 +779,9 @@ export default {
716
779
  }
717
780
  },
718
781
  unfocus(event) {
782
+ if (event.target.closest('[data-apos-ignore-unfocus="true"]')) {
783
+ return;
784
+ }
719
785
  if (!this.$el.contains(event.target)) {
720
786
  this.removeClickOutsideListener();
721
787
 
@@ -925,6 +991,10 @@ export default {
925
991
  }
926
992
  }
927
993
 
994
+ .apos-area-widget-rendered-widget {
995
+ position: relative;
996
+ }
997
+
928
998
  .apos-area-widget-controls {
929
999
  z-index: $z-index-widget-controls;
930
1000
  position: absolute;
@@ -209,6 +209,7 @@ export default {
209
209
  ...this.operationButtonDefault,
210
210
  icon: operation.icon
211
211
  },
212
+ ignoreUnfocus: true,
212
213
  teleportContent: this.teleportModals,
213
214
  disabled,
214
215
  tooltip