diff --git a/.phpstan/baseline.neon b/.phpstan/baseline.neon
index 5e43a294010..865f5ca2805 100644
--- a/.phpstan/baseline.neon
+++ b/.phpstan/baseline.neon
@@ -66,12 +66,6 @@ parameters:
count: 1
path: ../src/Facades/Endpoint/Parse.php
- -
- message: '#^Method Illuminate\\Contracts\\Validation\\DataAwareRule@anonymous/Fieldtypes/Bard\.php\:934\:\:setData\(\) should return \$this\(Illuminate\\Contracts\\Validation\\DataAwareRule@anonymous/Fieldtypes/Bard\.php\:934\) but return statement is missing\.$#'
- identifier: return.missing
- count: 1
- path: ../src/Fieldtypes/Bard.php
-
-
message: '#^Access to an undefined property Statamic\\Filesystem\\AbstractAdapter\:\:\$filesystem\.$#'
identifier: property.notFound
diff --git a/resources/css/components/fieldtypes/bard.css b/resources/css/components/fieldtypes/bard.css
index 1477f7ddead..4aa2f6daabd 100644
--- a/resources/css/components/fieldtypes/bard.css
+++ b/resources/css/components/fieldtypes/bard.css
@@ -257,6 +257,48 @@
}
}
}
+
+/* BARD / DRAGGING
+=================================================== */
+/* Hide set bodies synchronously (classList, not waiting on Vue) so collapse-on-drag
+ doesn't leave the pointer over empty space where the expanded set used to be. */
+.bard-dragging [data-set-body] {
+ display: none;
+}
+
+/* BARD / DROP + GAP CURSORS
+=================================================== */
+/* Unlayered so we beat ProseMirror's injected 1px black defaults. */
+.bard-dropcursor {
+ border-radius: 999px;
+ background-color: var(--focus-outline-color, var(--color-blue-400));
+}
+
+/* Leading disc on block (horizontal) drops — Notion-style slot marker.
+ Inline (vertical) carets stay a rounded pill so they don't grow a blob. */
+.bard-dropcursor.prosemirror-dropcursor-block::before {
+ content: '';
+ position: absolute;
+ inset-inline-start: 0;
+ top: 50%;
+ width: 6px;
+ height: 6px;
+ border-radius: 999px;
+ background-color: inherit;
+ translate: -30% -50%;
+}
+
+[dir='rtl'] .bard-dropcursor.prosemirror-dropcursor-block::before {
+ translate: 30% -50%;
+}
+
+/* Gapcursor: click-between-blocks caret. Same token as dropcursor. */
+.ProseMirror-gapcursor:after {
+ border-top: 2px solid var(--focus-outline-color, var(--color-blue-400));
+ border-radius: 999px;
+ width: 1.5rem;
+}
+
/* BARD / FULL SCREEN
=================================================== */
@layer ui-states {
diff --git a/resources/css/components/fieldtypes/replicator.css b/resources/css/components/fieldtypes/replicator.css
index cbf0f8dc3bf..b65819e04a6 100644
--- a/resources/css/components/fieldtypes/replicator.css
+++ b/resources/css/components/fieldtypes/replicator.css
@@ -1,3 +1,9 @@
/* ==========================================================================
REPLICATOR FIELDTYPE
========================================================================== */
+
+/* Collapse set bodies to header bars while dragging so swaps only relayout
+ header-height rows instead of full expanded field trees. */
+.replicator-dragging [data-replicator-set] > [data-set-body] {
+ display: none;
+}
diff --git a/resources/css/core/layout.css b/resources/css/core/layout.css
index f258de42bc9..f9bc73f0507 100644
--- a/resources/css/core/layout.css
+++ b/resources/css/core/layout.css
@@ -163,3 +163,8 @@ main.nav-closed {
body > .draggable-mirror {
z-index: var(--z-index-draggable);
}
+
+/* Don't clone expanded set bodies into the drag mirror. */
+body > .draggable-mirror [data-set-body] {
+ display: none;
+}
diff --git a/resources/css/cp.css b/resources/css/cp.css
index dd8ab0f1172..7d532f95c00 100644
--- a/resources/css/cp.css
+++ b/resources/css/cp.css
@@ -35,6 +35,7 @@
@import './components/fieldtypes/markdown.css';
@import './components/fieldtypes/partial.css';
@import './components/fieldtypes/relationship.css';
+@import './components/fieldtypes/replicator.css';
@import './components/fieldtypes/section.css';
@import './components/fieldtypes/table.css';
@import './components/fieldtypes/width.css';
diff --git a/resources/js/bootstrap/globals.js b/resources/js/bootstrap/globals.js
index da068ea8924..fefbdb7b0ff 100644
--- a/resources/js/bootstrap/globals.js
+++ b/resources/js/bootstrap/globals.js
@@ -125,6 +125,8 @@ export function truncate(string, length, ending = '...') {
}
export function escapeHtml(string) {
+ if (typeof string !== 'string') return string;
+
return string
.replaceAll('&', '&')
.replaceAll('<', '<')
diff --git a/resources/js/components/Reveal.js b/resources/js/components/Reveal.js
index 83a903246bf..bf25d6255d3 100644
--- a/resources/js/components/Reveal.js
+++ b/resources/js/components/Reveal.js
@@ -8,6 +8,9 @@ class Reveal {
}
mount(el, callback) {
+ // Progressive set mounting can call this before the template ref exists.
+ if (!el) return;
+
registry.set(el, callback);
onBeforeUnmount(() => registry.delete(el));
diff --git a/resources/js/components/field-conditions/ShowField.js b/resources/js/components/field-conditions/ShowField.js
index 963ca7c7be9..5bc32211241 100644
--- a/resources/js/components/field-conditions/ShowField.js
+++ b/resources/js/components/field-conditions/ShowField.js
@@ -5,7 +5,10 @@ import { nextTick } from 'vue';
export default class {
constructor(values, extraValues, rootValues, revealerValues, hiddenFields, setHiddenField, extraPayload) {
this.values = values;
+ // Merge once per instance — reused across showField() calls when Sections/Tabs
+ // construct a single ShowField for a filter loop.
this.extraValues = { ...extraValues, ...revealerValues };
+ this.mergedValues = { ...values, ...this.extraValues };
this.rootValues = rootValues;
this.revealerValues = revealerValues;
this.hiddenFields = hiddenFields;
@@ -31,7 +34,14 @@ export default class {
}
// Use validation to determine whether field should be shown.
- let validator = new Validator(field, { ...this.values, ...this.extraValues }, this.rootValues, dottedFieldPath, Object.keys(this.revealerValues), this.extraPayload);
+ let validator = new Validator(
+ field,
+ this.mergedValues,
+ this.rootValues,
+ dottedFieldPath,
+ Object.keys(this.revealerValues),
+ this.extraPayload,
+ );
let passes = validator.passesConditions();
// If the field is configured to always save, never omit value.
@@ -45,12 +55,17 @@ export default class {
return passes;
}
+ // With no revealers registered, passesNonRevealerConditions === passesConditions.
+ const hasRevealers = Object.keys(this.revealerValues).length > 0;
+
// Ensure DOM is updated to ensure all revealers are properly loaded and tracked before committing to store.
nextTick(() => {
this.setHiddenFieldState({
dottedKey: dottedFieldPath,
hidden: !passes,
- omitValue: field.type === 'revealer' || !validator.passesNonRevealerConditions(dottedPrefix),
+ omitValue:
+ field.type === 'revealer' ||
+ (hasRevealers ? !validator.passesNonRevealerConditions(dottedPrefix) : !passes),
});
});
diff --git a/resources/js/components/field-conditions/Validator.js b/resources/js/components/field-conditions/Validator.js
index 7d08b41c001..22b00c2eac6 100644
--- a/resources/js/components/field-conditions/Validator.js
+++ b/resources/js/components/field-conditions/Validator.js
@@ -5,6 +5,9 @@ import { data_get } from '../../bootstrap/globals.js';
import { isObject, intersection } from 'lodash-es';
const NUMBER_SPECIFIC_COMPARISONS = ['>', '>=', '<', '<='];
+const CUSTOM_PREFIX_RE = /^custom /;
+const ROOT_PREFIX_RE = /^\$?root\./;
+const TRAILING_FIELD_RE = /\.[^.]+$/;
const isEmpty = (value) => {
if (value === null || value === undefined) return true;
@@ -25,6 +28,7 @@ export default class {
this.passOnAny = false;
this.showOnPass = true;
this.converter = new Converter();
+ this._conditionsResolved = false;
}
usingRootValues() {
@@ -56,25 +60,42 @@ export default class {
}
getConditions() {
+ // Memoized per Validator instance — field config is static for the evaluation cycle.
+ // Side-effect flags (passOnAny / showOnPass) are restored on subsequent calls.
+ if (this._conditionsResolved) {
+ this.passOnAny = this._passOnAny;
+ this.showOnPass = this._showOnPass;
+ return this._conditions;
+ }
+
+ this._conditionsResolved = true;
+ this._passOnAny = false;
+ this._showOnPass = true;
+
let key = KEYS.filter((key) => this.field[key])[0];
if (!key) {
+ this._conditions = undefined;
return undefined;
}
if (key.includes('any')) {
this.passOnAny = true;
+ this._passOnAny = true;
}
if (key.includes('unless') || key.includes('hide_when')) {
this.showOnPass = false;
+ this._showOnPass = false;
}
let conditions = this.field[key];
- return this.isCustomConditionWithoutTarget(conditions)
+ this._conditions = this.isCustomConditionWithoutTarget(conditions)
? conditions
: this.converter.fromBlueprint(conditions, this.field.prefix);
+
+ return this._conditions;
}
isCustomConditionWithoutTarget(conditions) {
@@ -189,7 +210,7 @@ export default class {
}
prepareFunctionName(condition) {
- return condition.replace(new RegExp('^custom '), '').split(':')[0];
+ return condition.replace(CUSTOM_PREFIX_RE, '').split(':')[0];
}
prepareParams(condition) {
@@ -204,7 +225,7 @@ export default class {
}
if (field.startsWith('$root.') || field.startsWith('root.')) {
- return data_get(this.rootValues, field.replace(new RegExp('^\\$?root\\.'), ''));
+ return data_get(this.rootValues, field.replace(ROOT_PREFIX_RE, ''));
}
return data_get(this.values, field);
@@ -293,14 +314,14 @@ export default class {
}
if (lhs.startsWith('$root.') || lhs.startsWith('root.')) {
- return lhs.replace(new RegExp('^\\$?root\\.'), '');
+ return lhs.replace(ROOT_PREFIX_RE, '');
}
return dottedPrefix ? dottedPrefix + '.' + lhs : lhs;
}
scopeValuesToParent() {
- let scope = this.currentFieldPath.replace(new RegExp('\.[^\.]+$'), '');
+ let scope = this.currentFieldPath.replace(TRAILING_FIELD_RE, '');
this.values = data_get(this.rootValues, scope);
diff --git a/resources/js/components/fieldtypes/TemplateFieldtype.vue b/resources/js/components/fieldtypes/TemplateFieldtype.vue
index 673f0f0a7bb..89cdb8d301a 100644
--- a/resources/js/components/fieldtypes/TemplateFieldtype.vue
+++ b/resources/js/components/fieldtypes/TemplateFieldtype.vue
@@ -22,6 +22,20 @@
diff --git a/resources/js/components/fieldtypes/assets/AssetsFieldtype.vue b/resources/js/components/fieldtypes/assets/AssetsFieldtype.vue
index d1c52c6dc5a..ea6cc68c59e 100644
--- a/resources/js/components/fieldtypes/assets/AssetsFieldtype.vue
+++ b/resources/js/components/fieldtypes/assets/AssetsFieldtype.vue
@@ -201,6 +201,7 @@ import { isEqual } from 'lodash-es';
import { Button, Dropdown, DropdownMenu, DropdownItem, Stack } from '@/components/ui';
import ItemActions from '@/components/actions/ItemActions.vue';
import useCheckerboard from '@/composables/checkerboard.js';
+import { dedupeInFlight } from '@/util/dedupeInFlight.js';
export default {
components: {
@@ -496,14 +497,16 @@ export default {
this.loading = true;
- this.$axios
- .post(cp_url('assets-fieldtype'), {
- assets,
- })
- .then((response) => {
- this.assets = response.data;
- this.loading = false;
- });
+ const cacheKey = JSON.stringify([...assets].slice().sort());
+
+ dedupeInFlight('assets-fieldtype', cacheKey, () =>
+ this.$axios.post(cp_url('assets-fieldtype'), { assets }),
+ ).then((response) => {
+ // Clone so mutations on one field's asset rows don't bleed into others
+ // sharing the same in-flight response.
+ this.assets = clone(response.data);
+ this.loading = false;
+ });
},
/**
diff --git a/resources/js/components/fieldtypes/bard/BardFieldtype.vue b/resources/js/components/fieldtypes/bard/BardFieldtype.vue
index e682303b6f1..3d1ca825651 100644
--- a/resources/js/components/fieldtypes/bard/BardFieldtype.vue
+++ b/resources/js/components/fieldtypes/bard/BardFieldtype.vue
@@ -7,7 +7,7 @@
set.handle === handle);
- text += ` [${__(set ? set.display : handle)}]`;
- }
- if (text.length > 150) {
- break;
- }
- if (node.content) {
- stack.unshift(...node.content);
- }
- }
- return text;
+ return extractBardText(this.value, 150, this.setConfigs);
},
inputIsInline() {
@@ -376,6 +366,7 @@ export default {
},
shouldShowAddSetHelperText() {
+ if (!this.editor) return false;
return !this.$refs.setPicker?.isOpen && this.suitableToShowSetButton(this.editor);
},
},
@@ -390,17 +381,10 @@ export default {
}
},
- async mounted() {
- tiptap = await importTiptap();
-
+ mounted() {
+ // Preview text and value watchers work without TipTap. Defer the expensive
+ // Editor construction until this field is (nearly) visible or focused.
this.initToolbarButtons();
- this.initEditor();
-
- this.json = this.editor.getJSON().content;
- this.html = this.editor.getHTML();
-
- this.$nextTick(() => this.mounted = true);
-
this.pageHeader = document.querySelector('.global-header');
if (!commandPaletteCallbackRegistered) {
@@ -415,16 +399,19 @@ export default {
}
this.$nextTick(() => {
+ this.setupLazyEditor();
+
let el = document.querySelector(`label[for="${this.fieldId}"]`);
if (el) {
el.addEventListener('click', () => {
- this.editor.commands.focus();
+ this.ensureEditor().then(() => this.editor?.commands.focus());
});
}
});
},
beforeUnmount() {
+ this._intersectionObserver?.disconnect();
this.editor?.destroy();
this.escBinding?.destroy();
},
@@ -461,7 +448,7 @@ export default {
},
readOnly(readOnly) {
- this.editor.setEditable(!this.readOnly);
+ this.editor?.setEditable(!this.readOnly);
},
collapsed(value) {
@@ -471,19 +458,22 @@ export default {
},
fullScreenMode(fullScreenMode) {
- this.initEditor();
-
- if (fullScreenMode) {
- this.escBinding = this.$keys.bindGlobal('esc', this.closeFullscreen);
- // Focus the editor content when entering fullscreen mode
- this.$nextTick(() => {
- if (this.editor) {
- this.editor.commands.focus();
- }
- });
- } else {
- this.escBinding?.destroy();
- }
+ // Portal remount needs a fresh TipTap instance bound to the new DOM.
+ // ensureEditor() covers the lazy-init case; initEditor() recreates when
+ // an editor already existed outside the portal.
+ const hadEditor = !!this.editor;
+ this.ensureEditor().then(() => {
+ if (hadEditor) this.initEditor();
+
+ if (fullScreenMode) {
+ this.escBinding = this.$keys.bindGlobal('esc', this.closeFullscreen);
+ this.$nextTick(() => {
+ this.editor?.commands.focus();
+ });
+ } else {
+ this.escBinding?.destroy();
+ }
+ });
},
loadingSet(loading) {
@@ -513,6 +503,65 @@ export default {
},
methods: {
+ setupLazyEditor() {
+ const el = this.$refs.container;
+ if (!el || typeof IntersectionObserver === 'undefined') {
+ this.ensureEditor();
+ return;
+ }
+
+ // Already in view (e.g. first expanded set) — init immediately.
+ const rect = el.getBoundingClientRect();
+ const inView = rect.top < window.innerHeight + 100 && rect.bottom > -100;
+ if (inView) {
+ this.ensureEditor();
+ return;
+ }
+
+ this._intersectionObserver = new IntersectionObserver(
+ (entries) => {
+ if (entries.some((entry) => entry.isIntersecting)) {
+ this._intersectionObserver?.disconnect();
+ this._intersectionObserver = null;
+ this.ensureEditor();
+ }
+ },
+ { rootMargin: '100px' },
+ );
+ this._intersectionObserver.observe(el);
+ },
+
+ async ensureEditor() {
+ if (this.editor) return this.editor;
+ if (this._editorInitPromise) return this._editorInitPromise;
+
+ this._editorInitPromise = (async () => {
+ try {
+ tiptap = await importTiptap();
+ this.initEditor();
+ // Seed json from the editor once. Skip getHTML() here — it's a full-doc
+ // serialize and only needed when reading-time/footer config is enabled
+ // (computed lazily via onUpdate / readingTime).
+ // Defer `mounted` so the json watcher skips this seed — TipTap often
+ // normalizes content slightly, and pushing that into values was
+ // refreshing Live Preview on scroll/expand (lazy init).
+ this.json = this.editor.getJSON().content;
+ await this.$nextTick();
+ this.mounted = true;
+ return this.editor;
+ } catch (error) {
+ this.initError = error.message || String(error);
+ throw error;
+ }
+ })();
+
+ try {
+ return await this._editorInitPromise;
+ } finally {
+ this._editorInitPromise = null;
+ }
+ },
+
addSet(handle) {
this.loadingSet = handle;
@@ -825,6 +874,8 @@ export default {
},
buttonIsActive(button) {
+ // Toolbar can render before lazy TipTap init finishes.
+ if (!this.editor) return false;
if (button.hasOwnProperty('active')) {
return button.active(this.editor, button.args);
}
@@ -834,6 +885,7 @@ export default {
},
buttonIsVisible(button) {
+ if (!this.editor) return !button.hasOwnProperty('visibleWhenActive');
if (button.hasOwnProperty('visible')) {
return button.visible(this.editor, button.args);
}
@@ -895,7 +947,10 @@ export default {
if (countNodes(oldJson) !== countNodes(newJson)) this.debounceNextUpdate = false;
this.json = newJson;
- this.html = this.editor.getHTML();
+
+ if (this.config.reading_time) {
+ this.html = this.editor.getHTML();
+ }
},
onCreate: ({ editor }) => {
const state = editor.view.state;
@@ -945,7 +1000,7 @@ export default {
},
valueToContent(value) {
- return value.length ? { type: 'doc', content: value } : null;
+ return value?.length ? { type: 'doc', content: value } : null;
},
getExtensions() {
@@ -1013,7 +1068,11 @@ export default {
setConfigs: this.setConfigs,
addSet: this.addSet,
}),
- Dropcursor,
+ Dropcursor.configure({
+ color: false,
+ width: 2,
+ class: 'bard-dropcursor',
+ }),
Gapcursor,
History,
Paragraph,
diff --git a/resources/js/components/fieldtypes/bard/Image.vue b/resources/js/components/fieldtypes/bard/Image.vue
index 0dead0b49ed..5f6fc51cab7 100644
--- a/resources/js/components/fieldtypes/bard/Image.vue
+++ b/resources/js/components/fieldtypes/bard/Image.vue
@@ -71,6 +71,7 @@ import { NodeViewWrapper } from '@tiptap/vue-3';
import Selector from '../../assets/Selector.vue';
import { Input, Button, Stack } from '@ui';
import { containerContextKey } from '@/components/ui/Publish/Container.vue';
+import { dedupeInFlight } from '@/util/dedupeInFlight.js';
export default {
mixins: [Asset],
@@ -184,13 +185,13 @@ export default {
return;
}
- this.$axios
- .post(cp_url('assets-fieldtype'), {
- assets: [id],
- })
- .then((response) => {
- this.setAsset(response.data[0]);
- });
+ const cacheKey = JSON.stringify([id]);
+
+ dedupeInFlight('assets-fieldtype', cacheKey, () =>
+ this.$axios.post(cp_url('assets-fieldtype'), { assets: [id] }),
+ ).then((response) => {
+ this.setAsset(response.data[0]);
+ });
},
setAsset(asset) {
diff --git a/resources/js/components/fieldtypes/bard/Set.vue b/resources/js/components/fieldtypes/bard/Set.vue
index 1c99d83d775..42f624f8850 100644
--- a/resources/js/components/fieldtypes/bard/Set.vue
+++ b/resources/js/components/fieldtypes/bard/Set.vue
@@ -22,8 +22,9 @@
@@ -117,9 +119,12 @@ import {
PublishFields as Fields
} from '@ui';
import { containerContextKey } from '@/components/ui/Publish/Container.vue';
-import { watch } from 'vue';
+import { watch, inject } from 'vue';
import { reveal } from '@api';
import { useUiDirection } from '@/composables/ui-direction';
+import { createMountScheduler } from '@/util/createMountScheduler.js';
+import ShowField from '@/components/field-conditions/ShowField.js';
+import { keepElementUnderPointer } from '@/util/keepElementUnderPointer.js';
export default {
props: nodeViewProps,
@@ -127,6 +132,17 @@ export default {
setup() {
return {
uiDirection: useUiDirection().direction,
+ mountScheduler: inject('mountScheduler', createMountScheduler()),
+ };
+ },
+
+ data() {
+ const collapsedIds = this.extension.options.bard.collapsed || [];
+ const initiallyCollapsed = collapsedIds.includes(this.node.attrs.id);
+
+ return {
+ hasBeenExpanded: !initiallyCollapsed,
+ fieldsReady: !initiallyCollapsed,
};
},
@@ -321,6 +337,16 @@ export default {
}
},
+ prewarmFields() {
+ if (this.hasBeenExpanded || !this.hasFields) return;
+ this.hasBeenExpanded = true;
+ this.mountScheduler.schedule(() => {
+ if (!this._setUnmounted) {
+ this.fieldsReady = true;
+ }
+ });
+ },
+
collapse() {
// this.$events.$emit('collapsed', this.node.attrs.id);
this.extension.options.bard.collapseSet(this.node.attrs.id);
@@ -344,13 +370,35 @@ export default {
this._draggableObserver?.disconnect();
this.$el.setAttribute('draggable', true);
+ // dragstart fires on this.$el (the draggable wrapper), not the inner container.
+ this.$el.addEventListener('dragstart', this.collapseSiblingsForDrag, { once: true });
document.addEventListener('mouseup', this.disableDragging, { once: true });
document.addEventListener('dragend', this.disableDragging, { once: true });
},
+ collapseSiblingsForDrag(event) {
+ const bard = this.extension.options.bard;
+ const root = this.$el.closest('.bard-fieldtype');
+
+ keepElementUnderPointer(this.$el, () => {
+ bard.dragging = true;
+ root?.classList.add('bard-dragging');
+ });
+
+ const rect = this.$el.getBoundingClientRect();
+ event.dataTransfer?.setDragImage(this.$el, event.clientX - rect.left, event.clientY - rect.top);
+
+ bard.collapseAll();
+ },
+
disableDragging() {
+ this.$el.removeEventListener('dragstart', this.collapseSiblingsForDrag);
this.$el.setAttribute('draggable', false);
this._draggableObserver?.observe(this.$el, { attributes: true, attributeFilter: ['draggable'] });
+
+ const bard = this.extension.options.bard;
+ bard.dragging = false;
+ this.$el.closest('.bard-fieldtype')?.classList.remove('bard-dragging');
},
preventNodeSelectionDrag(event) {
@@ -379,6 +427,54 @@ export default {
{ deep: true }
);
+ watch(
+ () => this.collapsed,
+ (collapsed) => {
+ if (!collapsed && !this.hasBeenExpanded) {
+ this.hasBeenExpanded = true;
+ this.mountScheduler.schedule(() => {
+ if (!this._setUnmounted) {
+ this.fieldsReady = true;
+ }
+ });
+ } else if (!collapsed) {
+ this.fieldsReady = true;
+ }
+ },
+ );
+
+ // Headlessly evaluate conditions for never-mounted sets so omitValue
+ // bookkeeping stays correct for the save payload.
+ watch(
+ [() => this.values, () => this.fieldsReady],
+ () => {
+ if (this.fieldsReady || !this.hasFields) return;
+
+ const fields = Array.isArray(this.fields)
+ ? this.fields
+ : Object.values(this.fields || {});
+
+ const showField = new ShowField(
+ this.values || {},
+ {},
+ this.publishContainer.visibleValues.value,
+ this.publishContainer.revealerValues.value,
+ this.publishContainer.hiddenFields.value,
+ this.publishContainer.setHiddenField,
+ { container: this.publishContainer.container },
+ );
+
+ fields.forEach((field) => {
+ showField.showField(field, `${this.fieldPathPrefix}.${field.handle}`);
+ });
+ },
+ { deep: true, immediate: true },
+ );
+
+ if (this.hasError && this.collapsed) {
+ this.expand();
+ }
+
reveal.mount(this.$refs.container, this.expand);
// Firefox bug 739071: text selection doesn't work inside elements with a
@@ -398,6 +494,7 @@ export default {
},
beforeUnmount() {
+ this._setUnmounted = true;
this._draggableObserver?.disconnect();
},
};
diff --git a/resources/js/components/fieldtypes/markdown/MarkdownFieldtype.vue b/resources/js/components/fieldtypes/markdown/MarkdownFieldtype.vue
index e2b6adb043c..c140ecfd078 100644
--- a/resources/js/components/fieldtypes/markdown/MarkdownFieldtype.vue
+++ b/resources/js/components/fieldtypes/markdown/MarkdownFieldtype.vue
@@ -186,6 +186,7 @@ import Uploader from '../../assets/Uploader.vue';
import Uploads from '../../assets/Uploads.vue';
import MarkdownToolbar from './MarkdownToolbar.vue';
import { useContentDirection } from '@/composables/content-direction';
+import { dedupeInFlight } from '@/util/dedupeInFlight.js';
// Keymaps
import 'codemirror/keymap/sublime';
@@ -587,8 +588,12 @@ export default {
this.closeAssetSelector();
this.selectedAssets = [];
- this.$axios.post(cp_url('assets-fieldtype'), { assets }).then(({ data }) => {
- data.forEach(asset => {
+ const cacheKey = JSON.stringify([...assets].slice().sort());
+
+ dedupeInFlight('assets-fieldtype', cacheKey, () =>
+ this.$axios.post(cp_url('assets-fieldtype'), { assets }),
+ ).then(({ data }) => {
+ data.forEach((asset) => {
const alt = asset.values.alt || '';
const url = encodeURI(`statamic://${asset.reference}`);
const method = assets.length === 1 ? 'insert' : 'append';
diff --git a/resources/js/components/fieldtypes/replicator/ManagesPreviewText.js b/resources/js/components/fieldtypes/replicator/ManagesPreviewText.js
index 44149c7d579..b9b5f7ec479 100644
--- a/resources/js/components/fieldtypes/replicator/ManagesPreviewText.js
+++ b/resources/js/components/fieldtypes/replicator/ManagesPreviewText.js
@@ -1,30 +1,22 @@
-import PreviewHtml from './PreviewHtml';
+import { buildPreviewText } from '@/util/buildPreviewText';
+import formatPreviewValueUtil from '@/util/formatPreviewValue';
export default {
computed: {
previewText() {
- return Object.entries(this.previews)
- .filter(([handle, value]) => {
- if (!handle.endsWith('_')) return false;
- handle = handle.substr(0, handle.length - 1); // Remove the trailing underscore.
- const config = this.config.fields.find((f) => f.handle === handle);
- if (!config) return false;
- return config.replicator_preview === undefined ? this.showFieldPreviews : config.replicator_preview;
- })
- .map(([handle, value]) => value)
- .filter((value) => (['null', '[]', '{}', '', undefined].includes(JSON.stringify(value)) ? null : value))
- .map((value) => {
- if (value instanceof PreviewHtml) return value.html;
-
- if (typeof value === 'string') return escapeHtml(value);
-
- if (Array.isArray(value) && typeof value[0] === 'string') {
- return escapeHtml(value.join(', '));
- }
+ return buildPreviewText({
+ previews: this.previews,
+ config: this.config,
+ values: this.values,
+ showFieldPreviews: this.showFieldPreviews,
+ separator: ' / ',
+ });
+ },
+ },
- return escapeHtml(JSON.stringify(value));
- })
- .join(' / ');
+ methods: {
+ formatPreviewValue(value, fieldConfig) {
+ return formatPreviewValueUtil(value, fieldConfig, { escape: false });
},
},
};
diff --git a/resources/js/components/fieldtypes/replicator/Replicator.vue b/resources/js/components/fieldtypes/replicator/Replicator.vue
index 085eafa56f7..ac629f79b9d 100644
--- a/resources/js/components/fieldtypes/replicator/Replicator.vue
+++ b/resources/js/components/fieldtypes/replicator/Replicator.vue
@@ -16,17 +16,21 @@
@close="toggleFullscreen"
/>
-
+
@@ -95,6 +99,8 @@ import AddSetButton from './AddSetButton.vue';
import ManagesSetMeta from './ManagesSetMeta';
import { SortableList } from '../../sortable/Sortable';
import { data_get } from "@/bootstrap/globals.js";
+import { createMountScheduler } from '@/util/createMountScheduler.js';
+import { keepElementUnderPointer } from '@/util/keepElementUnderPointer.js';
export default {
mixins: [Fieldtype, ManagesSetMeta],
@@ -114,10 +120,12 @@ export default {
provide: {
replicatorSets: this.config.sets,
showReplicatorFieldPreviews: this.config.previews,
+ mountScheduler: createMountScheduler(),
},
errorsById: {},
setsCache: {},
loadingSet: null,
+ dragging: false,
};
},
@@ -210,6 +218,25 @@ export default {
this.update(value);
},
+ dragStarted(event) {
+ const source = event?.source || event?.originalSource;
+ const root = this.$refs.sets;
+
+ keepElementUnderPointer(source, () => {
+ this.dragging = true;
+ root?.classList.add('replicator-dragging');
+ });
+
+ this.collapseAll();
+ this.$emit('focus');
+ },
+
+ dragEnded() {
+ this.dragging = false;
+ this.$refs.sets?.classList.remove('replicator-dragging');
+ this.$emit('blur');
+ },
+
addSet(handle, index) {
this.loadingSet = handle;
diff --git a/resources/js/components/fieldtypes/replicator/Set.vue b/resources/js/components/fieldtypes/replicator/Set.vue
index 3b7ef4c3a72..cd4fc672433 100644
--- a/resources/js/components/fieldtypes/replicator/Set.vue
+++ b/resources/js/components/fieldtypes/replicator/Set.vue
@@ -1,5 +1,5 @@
@@ -143,8 +206,9 @@ reveal.use(rootEl, () => emit('expanded'));
@@ -211,7 +277,7 @@ reveal.use(rootEl, () => emit('expanded'));
diff --git a/resources/js/components/inputs/relationship/RelationshipInput.vue b/resources/js/components/inputs/relationship/RelationshipInput.vue
index a6bb3dd7d9c..b4e94fad73e 100644
--- a/resources/js/components/inputs/relationship/RelationshipInput.vue
+++ b/resources/js/components/inputs/relationship/RelationshipInput.vue
@@ -109,6 +109,19 @@ import { router } from '@inertiajs/vue3';
import axios from 'axios';
const inFlightRequests = new Map();
+// Settled responses reused for the page-view lifetime. Cleared on Inertia
+// navigation and when selections are confirmed from the selector stack
+// (items may have been edited there).
+const settledResponses = new Map();
+let navigationListenerAttached = false;
+
+function ensureSettledCacheClearedOnNavigation() {
+ if (navigationListenerAttached) return;
+ navigationListenerAttached = true;
+ router.on('before', () => {
+ settledResponses.clear();
+ });
+}
function detachFromInFlightRequest(component) {
const entry = component._activeRequest;
@@ -258,6 +271,8 @@ export default {
},
created() {
+ ensureSettledCacheClearedOnNavigation();
+
this.removeNavigationListener = router.on('before', () => {
detachFromInFlightRequest(this);
});
@@ -334,6 +349,9 @@ export default {
},
selectionsUpdated(selections) {
+ // Items may have been edited inside the selector stack — invalidate settled cache.
+ settledResponses.clear();
+
this.getDataForSelections(selections).then(() => {
this.update(selections);
});
@@ -354,6 +372,14 @@ export default {
detachFromInFlightRequest(this);
const cacheKey = JSON.stringify([this.itemDataUrl, this.site, selections?.slice().sort()]);
+
+ const settled = settledResponses.get(cacheKey);
+ if (settled) {
+ this.$emit('item-data-updated', settled.data.data);
+ this.loading = false;
+ return Promise.resolve(settled);
+ }
+
let entry = inFlightRequests.get(cacheKey);
if (!entry) {
@@ -361,6 +387,10 @@ export default {
entry = { cacheKey, controller, subscribers: 0 };
entry.promise = this.$axios
.post(this.itemDataUrl, { site: this.site, selections }, { signal: controller.signal })
+ .then((response) => {
+ settledResponses.set(cacheKey, response);
+ return response;
+ })
.finally(() => {
if (inFlightRequests.get(cacheKey) === entry) {
inFlightRequests.delete(cacheKey);
diff --git a/resources/js/components/inputs/relationship/SelectField.vue b/resources/js/components/inputs/relationship/SelectField.vue
index 628d716ae12..d5b1b9d5b21 100644
--- a/resources/js/components/inputs/relationship/SelectField.vue
+++ b/resources/js/components/inputs/relationship/SelectField.vue
@@ -41,12 +41,34 @@