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 @@
@@ -80,8 +81,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'));
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 @@