diff --git a/resources/js/app.js b/resources/js/app.js index 90c5616dae..b0bac64676 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -94,6 +94,7 @@ import './components/portals/Portals'; import './components/stacks/Stacks'; import './components/ProgressBar'; import './components/DirtyState'; +import './components/Clipboard'; import './components/Config'; import './components/Preference'; import './components/Permission'; diff --git a/resources/js/components/Clipboard.js b/resources/js/components/Clipboard.js new file mode 100644 index 0000000000..b63c10f922 --- /dev/null +++ b/resources/js/components/Clipboard.js @@ -0,0 +1,93 @@ +import Vue from 'vue' +import Cookies from 'cookies-js'; +import uid from 'uniqid'; + +const vm = new Vue({ + + data: { + id: null, + data: null, + }, + + created() { + let id = Cookies.get('statamic.clipboard'); + if (!id) { + id = uid(); + Cookies.set('statamic.clipboard', id); + } + this.id = id; + this.data = this.storageParse(this.storageRead()); + window.addEventListener('storage', (event) => { + if (event.key === 'statamic.clipboard') { + this.data = this.storageParse(event.newValue); + } + }); + }, + + methods: { + + set(data) { + this.data = data; + this.storageWrite(this.data); + }, + + get() { + return this.data; + }, + + clear() { + this.data = null; + this.storageClear(); + }, + + storageRead() { + return localStorage.getItem('statamic.clipboard'); + }, + + storageWrite(data) { + localStorage.setItem('statamic.clipboard', JSON.stringify({ + id: this.id, + payload: data, + })); + }, + + storageClear() { + localStorage.removeItem('statamic.clipboard'); + }, + + storageParse(value) { + const parsed = JSON.parse(value); + if (!parsed) { + return null; + } + const { id, payload } = parsed; + if (id !== this.id) { + this.storageClear(); + return null; + } + return payload; + }, + + } + +}); + +class Clipboard { + set(data) { + vm.set(data); + } + get() { + return vm.data; + } + clear(data) { + vm.clear(); + } +} + +Object.defineProperties(Vue.prototype, { + $clipboard: { + get() { + return new Clipboard; + } + } +}); diff --git a/resources/js/components/fieldtypes/replicator/AddSetButton.vue b/resources/js/components/fieldtypes/replicator/AddSetButton.vue index be7591494f..f8a29bbe51 100644 --- a/resources/js/components/fieldtypes/replicator/AddSetButton.vue +++ b/resources/js/components/fieldtypes/replicator/AddSetButton.vue @@ -1,6 +1,6 @@ @@ -41,6 +52,7 @@ export default { groups: Array, index: Number, last: Boolean, + pasteEnabled: Boolean, enabled: { type: Boolean, default: true }, label: String, }, @@ -55,7 +67,11 @@ export default { if (this.sets.length === 1) { this.addSet(this.sets[0].handle); } - } + }, + + pasteSets() { + this.$emit('pasted', this.index); + }, } diff --git a/resources/js/components/fieldtypes/replicator/Replicator.vue b/resources/js/components/fieldtypes/replicator/Replicator.vue index 5744aadf50..01fb1d5008 100644 --- a/resources/js/components/fieldtypes/replicator/Replicator.vue +++ b/resources/js/components/fieldtypes/replicator/Replicator.vue @@ -49,6 +49,8 @@ @collapsed="collapseSet(set._id)" @expanded="expandSet(set._id)" @duplicated="duplicateSet(set._id)" + @cut="copySet(set._id, true)" + @copied="copySet(set._id)" @updated="updated" @meta-updated="updateSetMeta(set._id, $event)" @removed="removed(set, index)" @@ -63,7 +65,9 @@ :sets="setConfigs" :index="index" :enabled="canAddSet" - @added="addSet" /> + :paste-enabled="canPasteSets" + @added="addSet" + @pasted="pasteSets" /> @@ -76,7 +80,9 @@ :sets="setConfigs" :index="value.length" :label="config.button_label" - @added="addSet" /> + :paste-enabled="canPasteSets" + @added="addSet" + @pasted="pasteSets" /> @@ -128,12 +134,29 @@ export default { return !this.config.max_sets || this.value.length < this.config.max_sets; }, + canPasteSets() { + if (!this.canAddSet) { + return false; + } + const data = this.$clipboard.get(); + if (data?.type !== 'replicator') { + return false; + } + const itemConfigHashes = data.items.map(item => item.configHash); + const setConfigHashes = Object.values(this.setConfigHashes); + return itemConfigHashes.every(hash => setConfigHashes.includes(hash)); + }, + setConfigs() { return reduce(this.groupConfigs, (sets, group) => { return sets.concat(group.sets); }, []); }, + setConfigHashes() { + return this.meta.setConfigHashes; + }, + groupConfigs() { return this.config.sets; }, @@ -172,6 +195,14 @@ export default { visibleWhenReadOnly: true, run: this.collapseAll, }, + { + title: __('Cut All Sets'), + run: () => this.copySets(true), + }, + { + title: __('Copy All Sets'), + run: () => this.copySets(), + }, { title: __('Toggle Fullscreen Mode'), icon: ({ vm }) => vm.fullScreenMode ? 'shrink-all' : 'expand-bold', @@ -189,6 +220,10 @@ export default { return _.find(this.setConfigs, { handle }) || {}; }, + setConfigHash(handle) { + return this.setConfigHashes[handle]; + }, + updated(index, set) { this.update([...this.value.slice(0, index), set, ...this.value.slice(index + 1)]); }, @@ -245,6 +280,81 @@ export default { this.expandSet(set._id); }, + copySet(id, cut = false) { + const index = this.value.findIndex(v => v._id === id); + const value = this.value[index]; + const meta = this.meta.existing[id]; + + this.$clipboard.set({ + type: 'replicator', + items: [{ + configHash: this.setConfigHash(value.type), + value: value, + meta: meta, + }], + }); + + if (cut) { + this.removed(value, index); + } + }, + + copySets(cut = false) { + this.$clipboard.set({ + type: 'replicator', + items: this.value.map((value) => ({ + configHash: this.setConfigHash(value.type), + value: value, + meta: this.meta.existing[value._id], + })), + }); + + if (cut) { + this.update([]); + this.updateMeta({ ...this.meta, existing: {} }); + } + }, + + pasteSets(index) { + const data = this.$clipboard.get(); + if (!data || data.type !== 'replicator') { + return; + } + + const value = []; + const meta = {}; + const previews = {}; + data.items.forEach((item) => { + const set = { ...item.value, _id: uniqid() }; + value.push(set); + meta[set._id] = item.meta; + previews[set._id] = {}; + }); + + this.previews = { + ...this.previews, + ...previews, + }; + + this.updateMeta({ + ...this.meta, + existing: { + ...this.meta.existing, + ...meta, + }, + }); + + this.update([ + ...this.value.slice(0, index), + ...value, + ...this.value.slice(index) + ]); + + value.forEach((set) => { + this.expandSet(set._id); + }); + }, + updateSetPreviews(id, previews) { this.previews[id] = previews; }, @@ -292,6 +402,7 @@ export default { return Object.keys(this.storeState.errors ?? []).some(handle => handle.startsWith(prefix)); }, + }, mounted() { diff --git a/resources/js/components/fieldtypes/replicator/Set.vue b/resources/js/components/fieldtypes/replicator/Set.vue index b28cc3a8c5..27839f2ccb 100644 --- a/resources/js/components/fieldtypes/replicator/Set.vue +++ b/resources/js/components/fieldtypes/replicator/Set.vue @@ -35,6 +35,8 @@
+ +
@@ -263,6 +265,14 @@ export default { this.$emit('duplicated'); }, + cut() { + this.$emit('cut'); + }, + + copy() { + this.$emit('copied'); + }, + fieldPath(field) { return `${this.fieldPathPrefix}.${this.index}.${field.handle}`; }, diff --git a/src/Fields/Field.php b/src/Fields/Field.php index b7b9f4f17e..7514a20870 100644 --- a/src/Fields/Field.php +++ b/src/Fields/Field.php @@ -18,6 +18,7 @@ class Field implements Arrayable protected $handle; protected $prefix; protected $config; + protected $configHash; protected $value; protected $parent; protected $parentField; @@ -459,6 +460,15 @@ public function form(): ?Form return $this->form; } + public function configHash(): string + { + if (! isset($this->configHash)) { + $this->configHash = md5($this->handle.json_encode($this->config)); + } + + return $this->configHash; + } + public static function commonFieldOptions(): Fields { $reserved = [ diff --git a/src/Fieldtypes/Replicator.php b/src/Fieldtypes/Replicator.php index ee6e1a5722..e1228516c1 100644 --- a/src/Fieldtypes/Replicator.php +++ b/src/Fieldtypes/Replicator.php @@ -122,12 +122,13 @@ protected function preProcessRow($row, $index) public function fields($set, $index = -1) { - $config = Arr::get($this->flattenedSetsConfig(), "$set.fields"); - $hash = md5($this->field->fieldPathPrefix().$index.json_encode($config)); + $config = Arr::get($this->flattenedSetsConfig(), $set); + $configHash = $config['hash'] ?? 'invalid_set'; + $itemHash = md5($this->field->fieldPathPrefix().'.'.$index); - return Blink::once($hash, function () use ($config, $index) { + return Blink::once('replicator-'.$configHash.'-'.$itemHash, function () use ($config, $index) { return new Fields( - $config, + $config['fields'] ?? [], $this->field()->parent(), $this->field(), $index @@ -215,13 +216,13 @@ protected function performAugmentation($values, $shallow) public function preload() { + $configHash = $this->field->configHash(); + $existing = collect($this->field->value())->mapWithKeys(function ($set, $index) { return [$set['_id'] => $this->fields($set['type'], $index)->addValues($set)->meta()->put('_', '_')]; })->toArray(); - $blink = md5(json_encode($this->flattenedSetsConfig())); - - $defaults = Blink::once($blink.'-defaults', function () { + $defaults = Blink::once('replicator-'.$configHash.'-defaults', function () { return collect($this->flattenedSetsConfig())->map(function ($set, $handle) { return $this->fields($handle)->all()->map(function ($field) { return $field->fieldtype()->preProcess($field->defaultValue()); @@ -229,7 +230,7 @@ public function preload() })->all(); }); - $new = Blink::once($blink.'-new', function () use ($defaults) { + $new = Blink::once('replicator-'.$configHash.'-new', function () use ($defaults) { return collect($this->flattenedSetsConfig())->map(function ($set, $handle) use ($defaults) { return $this->fields($handle)->addValues($defaults[$handle])->meta()->put('_', '_'); })->toArray(); @@ -247,14 +248,17 @@ public function preload() 'defaults' => $defaults, 'collapsed' => [], 'previews' => $previews, + 'setConfigHashes' => $this->flattenedSetsConfig() + ->map(fn ($set) => $set['hash']) + ->all(), ]; } public function flattenedSetsConfig() { - $blink = md5($this->field?->handle().json_encode($this->field?->config())); + $configHash = $this->field->configHash(); - return Blink::once($blink, function () { + return Blink::once('replicator-'.$configHash.'-sets', function () { $sets = collect($this->config('sets')); // If the first set doesn't have a nested "set" key, it would be the legacy format. @@ -268,9 +272,16 @@ public function flattenedSetsConfig() ]); } - return $sets->flatMap(function ($section) { - return $section['sets']; - }); + return $sets + ->flatMap(function ($section) { + return $section['sets']; + }) + ->map(function ($config, $handle) { + return [ + ...$config, + 'hash' => md5($handle.json_encode($config)), + ]; + }); }); } diff --git a/tests/Modifiers/MarkTest.php b/tests/Modifiers/MarkTest.php index 5f4f865bb0..8582319a0a 100644 --- a/tests/Modifiers/MarkTest.php +++ b/tests/Modifiers/MarkTest.php @@ -3,6 +3,7 @@ namespace Tests\Modifiers; use PHPUnit\Framework\Attributes\Test; +use Statamic\Fields\Field; use Statamic\Fields\Value; use Statamic\Fieldtypes\Bard; use Statamic\Fieldtypes\Markdown; @@ -78,7 +79,7 @@ public function it_marks_bard_value() ['type' => 'text', 'text' => 'amet', 'marks' => [['type' => 'bold']]], ], ], - ], 'content', new Bard()); + ], 'content', (new Bard)->setField(new Field('test', []))); $words = 'elüt amet'; $expected = '

Lorem, ipsum elüt sit amet

';