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 3310ec80d83..f518279cbf7 100644
--- a/resources/js/bootstrap/globals.js
+++ b/resources/js/bootstrap/globals.js
@@ -120,6 +120,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 1183a12e81e..58c3fe4cb3d 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 '../../util/data_get.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/field-conditions/analyzeSetConditions.js b/resources/js/components/field-conditions/analyzeSetConditions.js
new file mode 100644
index 00000000000..45df56fffb0
--- /dev/null
+++ b/resources/js/components/field-conditions/analyzeSetConditions.js
@@ -0,0 +1,152 @@
+import { isPlainObject } from 'lodash-es';
+import { KEYS } from './Constants.js';
+
+// A never-mounted set body has its conditions evaluated by a headless watcher instead
+// of by the Field components. That evaluation is only as reactive as the watcher's
+// sources, so we work out up front what a set's conditions actually depend on.
+//
+// Every unrecognised shape has to fall into the slower branch. A set that watches too
+// much is slow; a set that watches too little writes a stale `omitValue` and silently
+// drops the value from the save payload.
+
+const OUTSIDE_SET_RE = /^(\$root\.|root\.|\$parent\.)/;
+const ROOT_PREFIX_RE = /^\$?root\./;
+const CUSTOM_CONDITION_RE = /^\s*custom\s/;
+
+const cache = new WeakMap();
+
+export default function analyzeSetConditions(config) {
+ if (!isPlainObject(config)) {
+ return { needsRootValues: true, canDeferMount: false, hasRevealer: true, rootPaths: [] };
+ }
+
+ if (!cache.has(config)) cache.set(config, analyze(config));
+
+ return cache.get(config);
+}
+
+function analyze(config) {
+ const fields = fieldList(config.fields);
+
+ if (fields === null) {
+ return { needsRootValues: true, canDeferMount: false, hasRevealer: true, rootPaths: [] };
+ }
+
+ let needsRootValues = false;
+ let canDeferMount = true;
+ let hasRevealer = false;
+ const rootPaths = new Set();
+
+ fields.forEach((field) => {
+ if (!isPlainObject(field)) {
+ needsRootValues = true;
+ canDeferMount = false;
+ hasRevealer = true;
+ return;
+ }
+
+ if (conditionsFor(field).some(dependsOutsideSet)) needsRootValues = true;
+
+ conditionsFor(field).forEach((conditions) => {
+ rootTargets(conditions).forEach((path) => rootPaths.add(path));
+ });
+
+ // Revealers register themselves with the container when they mount, and that
+ // registration changes how every other field's `omitValue` is worked out.
+ if (field.type === 'revealer') {
+ canDeferMount = false;
+ hasRevealer = true;
+ }
+
+ const nested = nestedFields(field);
+
+ // An unrecognised shape could be hiding anything, including a revealer.
+ if (nested === null) {
+ canDeferMount = false;
+ hasRevealer = true;
+ return;
+ }
+
+ if (nested.some((child) => child.type === 'revealer')) {
+ canDeferMount = false;
+ hasRevealer = true;
+ }
+
+ // Nothing evaluates the conditions of fields nested inside this set's fields,
+ // so those only stay correct if the set actually mounts.
+ if (nested.some((child) => conditionsFor(child).length > 0)) canDeferMount = false;
+ });
+
+ return { needsRootValues, canDeferMount, hasRevealer, rootPaths: [...rootPaths] };
+}
+
+function conditionsFor(field) {
+ return KEYS.filter((key) => field[key]).map((key) => field[key]);
+}
+
+// The paths a set's conditions read out of the container's root values, with the prefix
+// stripped so they're the paths `data_get` is handed. Converter leaves `$root.`/`root.`
+// handles alone, so what's written in the blueprint is what gets resolved.
+function rootTargets(conditions) {
+ if (!isPlainObject(conditions)) return [];
+
+ return Object.keys(conditions)
+ .filter((lhs) => typeof lhs === 'string' && ROOT_PREFIX_RE.test(lhs))
+ .map((lhs) => lhs.replace(ROOT_PREFIX_RE, ''));
+}
+
+function dependsOutsideSet(conditions) {
+ // A bare string is a custom condition without a target. The callback is handed the
+ // root values, so we have to assume it reads them.
+ if (typeof conditions === 'string') return true;
+
+ if (!isPlainObject(conditions)) return true;
+
+ return Object.entries(conditions).some(([lhs, rhs]) => {
+ if (typeof lhs !== 'string') return true;
+ if (OUTSIDE_SET_RE.test(lhs)) return true;
+ if (typeof rhs === 'string' && CUSTOM_CONDITION_RE.test(rhs)) return true;
+
+ return !isScalar(rhs);
+ });
+}
+
+// Every field config underneath this one, at any depth. Grids and groups keep theirs in
+// `fields`, replicators and Bards in `sets` (either groups of sets, or bare sets).
+// Returns null if anything along the way isn't a shape we recognise.
+function nestedFields(field) {
+ const found = [];
+ const queue = [field];
+ const seen = new Set();
+
+ while (queue.length) {
+ const current = queue.shift();
+
+ if (seen.has(current)) continue;
+ seen.add(current);
+
+ if (!isPlainObject(current)) return null;
+
+ const children = fieldList(current.fields);
+ const sets = fieldList(current.sets);
+
+ if (children === null || sets === null) return null;
+
+ found.push(...children);
+ queue.push(...children, ...sets);
+ }
+
+ return found;
+}
+
+function fieldList(fields) {
+ if (fields === undefined || fields === null) return [];
+ if (Array.isArray(fields)) return fields;
+ if (isPlainObject(fields)) return Object.values(fields);
+
+ return null;
+}
+
+function isScalar(value) {
+ return value === null || ['string', 'number', 'boolean'].includes(typeof value);
+}
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 acfacc43a41..24bcf69cd94 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 +368,7 @@ export default {
},
shouldShowAddSetHelperText() {
+ if (!this.editor) return false;
return !this.$refs.setPicker?.isOpen && this.suitableToShowSetButton(this.editor);
},
},
@@ -390,17 +383,12 @@ export default {
}
},
- async mounted() {
- tiptap = await importTiptap();
+ mounted() {
+ this.watchSetConditionsWithoutEditor();
+ // 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 +403,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 +452,7 @@ export default {
},
readOnly(readOnly) {
- this.editor.setEditable(!this.readOnly);
+ this.editor?.setEditable(!this.readOnly);
},
collapsed(value) {
@@ -471,19 +462,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 +507,112 @@ export default {
},
methods: {
+ // Until the editor is built, there are no set node views to do the condition
+ // bookkeeping the save payload is built from, so do it from the value. Stops for
+ // good once an editor exists — see evaluateSetConditions.js.
+ watchSetConditionsWithoutEditor() {
+ if (this.setConfigs.length === 0) return;
+
+ const container = this.injectedPublishContainer;
+
+ // Same narrow/wide split as a collapsed set: only take a dependency on the
+ // whole form when a set's conditions can actually reach outside itself.
+ const needsRootValues = this.setConfigs.some(
+ (config) => analyzeSetConditions(config).needsRootValues,
+ );
+
+ const sources = [
+ () => this.value,
+ () => this.editor,
+ () => container.revealerValues.value,
+ ];
+
+ if (needsRootValues) sources.push(() => container.visibleValues.value);
+
+ let stop = null;
+
+ stop = watch(
+ sources,
+ () => {
+ if (this.editor) {
+ stop?.();
+ return;
+ }
+
+ evaluateBardSetConditions({
+ value: this.value,
+ setConfigs: this.setConfigs,
+ fieldPathPrefix: this.setFieldPathPrefix,
+ container,
+ });
+ },
+ { deep: true, immediate: true },
+ );
+
+ this._stopHeadlessSetConditions = stop;
+ },
+
+ 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();
+ // The set node views take over the condition bookkeeping from here.
+ this._stopHeadlessSetConditions?.();
+ // 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;
@@ -823,6 +923,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);
}
@@ -832,6 +934,7 @@ export default {
},
buttonIsVisible(button) {
+ if (!this.editor) return !button.hasOwnProperty('visibleWhenActive');
if (button.hasOwnProperty('visible')) {
return button.visible(this.editor, button.args);
}
@@ -893,7 +996,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;
@@ -943,7 +1049,7 @@ export default {
},
valueToContent(value) {
- return value.length ? { type: 'doc', content: value } : null;
+ return value?.length ? { type: 'doc', content: value } : null;
},
getExtensions() {
@@ -1011,7 +1117,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..b88d8d66b8a 100644
--- a/resources/js/components/fieldtypes/bard/Set.vue
+++ b/resources/js/components/fieldtypes/bard/Set.vue
@@ -22,8 +22,9 @@
@@ -117,9 +119,13 @@ 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 analyzeSetConditions from '@/components/field-conditions/analyzeSetConditions.js';
+import { keepElementUnderPointer } from '@/util/keepElementUnderPointer.js';
export default {
props: nodeViewProps,
@@ -127,6 +133,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,
};
},
@@ -154,6 +171,10 @@ export default {
},
computed: {
+ conditionScope() {
+ return analyzeSetConditions(this.config);
+ },
+
fields() {
return this.config.fields;
},
@@ -321,6 +342,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 +375,43 @@ 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.hideSetBodiesForDrag, { once: true });
+ // The drop recreates this node view, so dragend fires on an element that's no
+ // longer in the document and never reaches the listener below. No mouseup is
+ // dispatched during a native drag either, hence the listener on the element too.
+ this.$el.addEventListener('dragend', this.disableDragging, { once: true });
document.addEventListener('mouseup', this.disableDragging, { once: true });
document.addEventListener('dragend', this.disableDragging, { once: true });
},
+ // The .bard-dragging class hides the set bodies for the duration of the drag.
+ // Don't actually collapse the sets — that would persist to meta and leave
+ // everything collapsed after the drop.
+ hideSetBodiesForDrag(event) {
+ const bard = this.extension.options.bard;
+
+ // Held onto so it can be cleaned up from a detached element.
+ this._dragRoot = this.$el.closest('.bard-fieldtype');
+
+ keepElementUnderPointer(this.$el, () => {
+ bard.dragging = true;
+ this._dragRoot?.classList.add('bard-dragging');
+ });
+
+ const rect = this.$el.getBoundingClientRect();
+ event.dataTransfer?.setDragImage(this.$el, event.clientX - rect.left, event.clientY - rect.top);
+ },
+
disableDragging() {
+ this.$el.removeEventListener('dragstart', this.hideSetBodiesForDrag);
this.$el.setAttribute('draggable', false);
this._draggableObserver?.observe(this.$el, { attributes: true, attributeFilter: ['draggable'] });
+
+ const bard = this.extension.options.bard;
+ bard.dragging = false;
+ this._dragRoot?.classList.remove('bard-dragging');
+ this._dragRoot = null;
},
preventNodeSelectionDrag(event) {
@@ -379,6 +440,78 @@ 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;
+ }
+ },
+ );
+
+ // Some of what mounting used to do can't be done headlessly — nothing evaluates
+ // the conditions of fields nested inside this set's fields, and revealers only
+ // register themselves when they mount. Those sets get mounted anyway, but off
+ // the critical path.
+ if (!this.conditionScope.canDeferMount) this.prewarmFields();
+
+ // Headlessly evaluate conditions for never-mounted sets so omitValue
+ // bookkeeping stays correct for the save payload. Sets whose conditions only
+ // look at their own values keep a narrow dependency; the rest have to watch
+ // the whole tree.
+ const sources = [
+ () => this.values,
+ () => this.fieldsReady,
+ () => this.publishContainer.revealerValues.value,
+ ];
+
+ if (this.conditionScope.needsRootValues) {
+ sources.push(() => this.publishContainer.visibleValues.value);
+ }
+
+ watch(
+ sources,
+ () => {
+ 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 },
+ );
+
+ // Auto-expand when validation errors point at this set. Errors arrive from a
+ // failed save without remounting, so mount alone isn't enough of a hook.
+ watch(
+ () => this.hasError,
+ (hasError) => {
+ if (hasError && this.collapsed) this.expand();
+ },
+ { immediate: true },
+ );
+
reveal.mount(this.$refs.container, this.expand);
// Firefox bug 739071: text selection doesn't work inside elements with a
@@ -398,6 +531,7 @@ export default {
},
beforeUnmount() {
+ this._setUnmounted = true;
this._draggableObserver?.disconnect();
},
};
diff --git a/resources/js/components/fieldtypes/bard/evaluateSetConditions.js b/resources/js/components/fieldtypes/bard/evaluateSetConditions.js
new file mode 100644
index 00000000000..0b6b2516095
--- /dev/null
+++ b/resources/js/components/fieldtypes/bard/evaluateSetConditions.js
@@ -0,0 +1,168 @@
+import { isPlainObject } from 'lodash-es';
+import ShowField from '@/components/field-conditions/ShowField.js';
+import analyzeSetConditions from '@/components/field-conditions/analyzeSetConditions.js';
+import { KEYS } from '@/components/field-conditions/Constants.js';
+
+// A Bard field that has never been scrolled into view has no editor, so it has no set
+// node views either — there is no `bard/Set.vue` to evaluate the conditions of the fields
+// inside its sets, headlessly or otherwise. Nothing writes their omitValue bookkeeping,
+// and a save keeps fields the blueprint says are hidden.
+//
+// So evaluate them from the stored value instead. The paths have to match exactly what a
+// mounted set would write, because a wrong key writes bookkeeping for a field that isn't
+// the one we evaluated. A set's fields live at `
..attrs.values.`,
+// where the index is the node's position in the document. Without an editor the stored
+// value *is* the document — and it's the tree the container omits from — so the index is
+// unambiguous.
+//
+// That last part is also why this must stop the moment an editor exists. From then on the
+// editor's copy of the document is the authoritative one, the indexes are its, and the
+// node views own the bookkeeping.
+export default function evaluateBardSetConditions({ value, setConfigs, fieldPathPrefix, container }) {
+ if (!Array.isArray(value) || !Array.isArray(setConfigs) || setConfigs.length === 0) return;
+
+ // Revealers are the other thing this path can't answer for. A field gated on one is
+ // only kept because the revealer registered itself with the container when it mounted,
+ // and the condition pointing at a registered revealer is then filtered out before
+ // anything decides to omit the value. Nothing in a field with no editor ever mounts,
+ // so its revealers never register, and every field gated on one looks like it simply
+ // failed an ordinary condition — dropped from the save.
+ //
+ // `analyzeSetConditions` handles this everywhere else by refusing to defer the set's
+ // mount. There's no mount to force here, so decline instead.
+ //
+ // The decline covers the whole field rather than only the sets that contain a
+ // revealer, and it doesn't look at what the conditions target. A narrower rule would
+ // have to trust that it recognises every way a condition can reach a revealer, and
+ // that's the trust that lost data before: a `$root.` condition can point across sets,
+ // a bare custom condition names no target at all, and `prefix` rewrites handles before
+ // they're compared. Being wrong about one of those drops content; declining too often
+ // only keeps a value the blueprint would have hidden, and only until the field is
+ // scrolled into view.
+ //
+ // A revealer in *another* never-rendered field is the same bug seen from the far side:
+ // that field's registration is missing too, and a `$root.` condition can point straight
+ // at it. Nothing here can tell whose revealer it is, so the second half of the rule is
+ // blunter — decline whenever a `$root.` condition targets a path that isn't in the root
+ // values at all. An unregistered revealer is always such a path, because a revealer's
+ // own value is omitted and so never survives into `visibleValues`.
+ //
+ // Absent has to mean genuinely missing. A field that's present and null, or present and
+ // an empty string, resolves fine and its conditions are still evaluated normally —
+ // treating those as unresolvable would switch omission off across most forms.
+ //
+ // This does catch more than revealers: a typo'd handle, or a target that some other
+ // condition has already omitted, declines too. Accepted — the alternative is guessing
+ // at what a path was meant to reach.
+ const declineAll = setConfigs.some((config) => {
+ const { hasRevealer, rootPaths } = analyzeSetConditions(config);
+
+ return hasRevealer || rootPaths.some((path) => !isPresent(container.visibleValues.value, path));
+ });
+
+ value.forEach((node, index) => {
+ if (!isPlainObject(node) || node.type !== 'set') return;
+
+ const values = node.attrs?.values;
+ if (!isPlainObject(values)) return;
+
+ const config = setConfigs.find((set) => set?.handle === values.type);
+ if (!isPlainObject(config)) return;
+
+ const fields = fieldList(config.fields);
+ if (fields.length === 0) return;
+
+ const prefix = `${fieldPathPrefix}.${index}.attrs.values`;
+
+ let showField = null;
+
+ fields.forEach((field) => {
+ if (!isPlainObject(field) || !field.handle) return;
+
+ const dottedKey = `${prefix}.${field.handle}`;
+
+ // A revealer's own value is never saved whatever its conditions resolve to,
+ // so the decline doesn't need to cover it — and covering it would write the
+ // toggle state into the entry.
+ const declining = declineAll && field.type !== 'revealer';
+
+ if (declining || !conditionsResolveFromPath(field)) {
+ keepValue(container, dottedKey);
+ return;
+ }
+
+ showField ??= new ShowField(
+ values,
+ {},
+ container.visibleValues.value,
+ container.revealerValues.value,
+ container.hiddenFields.value,
+ container.setHiddenField,
+ { container: container.container },
+ );
+
+ showField.showField(field, dottedKey);
+ });
+ });
+}
+
+// The field path isn't only a key to write bookkeeping under, it's an input to the
+// conditions themselves: `$parent.` is resolved by walking up it. A Bard set's field path
+// carries two more segments than a Replicator's — `bard.0.attrs.values.foo` against
+// `rep.0.foo` — so the walk lands on a path that doesn't exist and the condition can never
+// pass. That's a pre-existing bug, and a mounted set gets the same wrong answer; the
+// difference is that a mounted set is on screen, whereas here the answer would be applied
+// to a field nobody has ever looked at, and would drop it from every save.
+//
+// So don't answer at all. Keeping a field the blueprint would have dropped is noise;
+// dropping one it would have kept is lost content.
+//
+// Nothing else about a set's conditions reads the field path. `$root.` reads the
+// container's values and a bare or dotted handle reads the set's own, and both are the
+// same values a mounted set would be handed.
+const PARENT_RE = /^\$parent\./;
+
+function conditionsResolveFromPath(field) {
+ return KEYS.filter((key) => field[key]).every((key) => resolvableConditions(field[key]));
+}
+
+function resolvableConditions(conditions) {
+ // A bare string is a custom condition without a target. Its callback is handed the
+ // same field path either way, so there's nothing here a mounted set resolves better.
+ if (typeof conditions === 'string') return true;
+
+ if (!isPlainObject(conditions)) return false;
+
+ return Object.keys(conditions).every((lhs) => !PARENT_RE.test(lhs));
+}
+
+// `data_get` can't answer this: it returns its fallback for a missing path and for a null
+// one alike, and it short-circuits on any falsy step along the way.
+function isPresent(values, path) {
+ let current = values;
+
+ for (const segment of path.split('.')) {
+ if (current === null || typeof current !== 'object') return false;
+ if (!(segment in current)) return false;
+
+ current = current[segment];
+ }
+
+ return true;
+}
+
+function keepValue(container, dottedKey) {
+ const current = container.hiddenFields.value[dottedKey];
+
+ // Writing unconditionally would retrigger the watcher this runs from.
+ if (current && current.hidden === false && current.omitValue === false) return;
+
+ container.setHiddenField({ dottedKey, hidden: false, omitValue: false });
+}
+
+function fieldList(fields) {
+ if (Array.isArray(fields)) return fields;
+ if (isPlainObject(fields)) return Object.values(fields);
+
+ return [];
+}
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 d2843c3f0ba..f81d3b3818d 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,27 @@ export default {
this.update(value);
},
+ dragStarted(event) {
+ const source = event?.source || event?.originalSource;
+ const root = this.$refs.sets;
+
+ // The .replicator-dragging class hides the set bodies for the duration of the
+ // drag. Don't actually collapse the sets — that would persist to meta and
+ // leave everything collapsed after the drop.
+ keepElementUnderPointer(source, () => {
+ this.dragging = true;
+ root?.classList.add('replicator-dragging');
+ });
+
+ 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..3cb70b385bb 100644
--- a/resources/js/components/fieldtypes/replicator/Set.vue
+++ b/resources/js/components/fieldtypes/replicator/Set.vue
@@ -1,5 +1,5 @@
@@ -143,8 +216,9 @@ reveal.use(rootEl, () => emit('expanded'));
@@ -211,7 +287,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 @@