const editor = grapesjs.init({ container: '#gjs', fromElement: true, height: '100%', storageManager: false, noticeOnUnload: false, styleManager: { custom: true, }, }); const sm = editor.StyleManager; // Make the editor global in the app Vue.mixin({ data() { return { editor } } }); // Property field component Vue.component('property-field', { props: { prop: Object }, template: '#property-field', computed: { labelCls() { const { prop } = this; const parent = prop.getParent(); const hasParentValue = prop.hasValueParent() && (parent ? parent.isDetached() : true); return ['flex-nowrap', prop.canClear() && 'indigo--text text--accent-1', hasParentValue && 'orange--text']; }, inputValue() { return this.prop.hasValue() ? this.prop.getValue() : ''; }, propName() { return this.prop.getName(); }, propType() { return this.prop.getType(); }, defValue() { return this.prop.getDefaultValue(); }, toOptions() { const { prop } = this; return prop.getOptions().map(o => ({ value: prop.getOptionId(o), text: prop.getOptionLabel(o) })) }, }, methods: { handleChange(value) { this.prop.upValue(value); }, handleInput(value) { this.prop.upValue(value, { partial: true }); }, openAssets(prop) { const { Assets } = this.editor; Assets.open({ select: (asset, complete) => { prop.upValue(asset.getSrc(), { partial: !complete }); complete && Assets.close(); }, types: ['image'], accept: 'image/*', }) } } }); const app = new Vue({ vuetify: new Vuetify({ theme: { dark: true }, }), el: '.style-manager', data: { sectors: [] }, mounted() { editor.on('style:custom', this.handleCustom); }, destroyed() { editor.off('style:custom', this.handleCustom); }, methods: { handleCustom(props = {}) { const { container } = props; if (container && !container.contains(this.$el)) { container.appendChild(this.$el); } this.sectors = sm.getSectors({ visible: true }); }, } });
<div id="gjs"> <div style="padding: 25px">Custom Style Manager</div> </div> <div style="display: none;"> <!-- Vue app --> <div class="style-manager"> <v-app> <v-main> <v-expansion-panels accordion multiple> <v-expansion-panel v-for="sector in sectors" :key="sector.getId()"> <v-expansion-panel-header> {{ sector.getName() }} </v-expansion-panel-header> <v-expansion-panel-content> <v-row> <property-field v-for="prop in sector.getProperties()" :key="prop.getId() + prop.canClear()" :prop="prop"/> </v-row> </v-expansion-panel-content> </v-expansion-panel> </v-expansion-panels> </v-main> </v-app> </div> <!-- Property Field template --> <div id="property-field" style="display: none;"> <v-col :class="['py-0 px-1 mb-1', prop.isFull() && 'mb-3']" :cols="prop.get('full') ? '12' : '6'"> <v-row :class="labelCls"> <v-col cols="auto pr-0">{{ prop.getLabel() }}</v-col> <v-col cols="auto" v-if="prop.canClear()"> <v-icon @click="prop.clear()" color="indigo accent-1" small>mdi-close</v-icon> </v-col> </v-row> <div v-if="propType === 'number'"> <v-text-field :placeholder="defValue" :value="inputValue" @change="handleChange" outlined dense/> </div> <div v-else-if="propType === 'radio'"> <v-radio-group :value="prop.getValue()" @change="handleChange" row dense> <v-radio v-for="opt in prop.getOptions()" :key="prop.getOptionId(opt)" :label="prop.getOptionLabel(opt)" :value="prop.getOptionId(opt)"/> </v-radio-group> </div> <div v-else-if="propType === 'select'"> <v-select :items="toOptions" :value="prop.getValue()" @change="handleChange" outlined dense/> </div> <div v-else-if="propType === 'color'"> <v-text-field :placeholder="defValue" :value="inputValue" @change="handleChange" outlined dense> <template v-slot:append> <div :style="{ backgroundColor: prop.hasValue() ? prop.getValue() : defValue }"> <input class="sm-input-color" type="color" :value="prop.hasValue() ? prop.getValue() : defValue" @change="(ev) => handleChange(ev.target.value)" @input="(ev) => handleInput(ev.target.value)" /> </div> </template> </v-text-field> </div> <div v-else-if="propType === 'slider'"> <v-slider track-color="white" :value="prop.getValue()" :min="prop.getMin()" :max="prop.getMax()" :step="prop.getStep()" @change="handleChange" @input="(value) => { console.log('trigger input', value) }" @start="(value) => { console.log('trigger start', value) }" /> </div> <div v-else-if="propType === 'file'"> <v-btn @click="openAssets(prop)" block> <v-row> <v-col v-if="prop.getValue() && prop.getValue() !== defValue" cols="auto"> <div class="sm-btn-prv" :style="{ backgroundImage: `url(${prop.getValue()})` }"></div> </v-col> <v-col>Select image</v-col> </v-row> </v-btn> </div> <div v-else-if="propType === 'composite'"> <v-row no-gutters class="sm-type-cmp pa-2"> <property-field v-for="p in prop.getProperties()" :key="p.getId() + p.canClear()" :prop="p"/> </v-row> </div> <div v-else-if="propType === 'stack'"> <div class="sm-type-cmp pa-3"> <v-icon @click="prop.addLayer({}, { at: 0 })" class="sm-add-layer" small>mdi-plus</v-icon> <v-row class="sm-layer" v-for="layer in prop.getLayers()" :key="layer.getId()"> <v-col> <v-row> <v-col cols="auto" class="pr-1"> <v-icon @click="layer.move(layer.getIndex() - 1)" small>mdi-arrow-up</v-icon> </v-col> <v-col cols="auto" class="pl-1"> <v-icon @click="layer.move(layer.getIndex() + 1)" small>mdi-arrow-down</v-icon> </v-col> <v-col @click="layer.select()">{{ layer.getLabel() }}</v-col> <v-col cols="auto"> <div :class="['sm-layer-prv white', `sm-layer-prv--${propName}`]" :style="layer.getStylePreview({ number: { min: -3, max: 3 } })"></div> </v-col> <v-col cols="auto"> <v-icon @click="layer.remove()" small>mdi-close</v-icon> </v-col> </v-row> <v-row v-if="layer.isSelected()" no-gutters class="sm-type-cmp pa-2 mt-3"> <property-field v-for="p in prop.getProperties()" :key="p.getId()" :prop="p"/> </v-row> </v-col> </v-row> </div> </div> <div v-else> <v-text-field :placeholder="defValue" :value="inputValue" @change="handleChange" outlined dense/> </div> </v-col> </div> </div>
body, html { margin: 0; height: 100%; } .style-manager { font-size: 12px; } .sm-input-color { width: 16px !important; height: 15px !important; opacity: 0 !important; } .sm-type-cmp { background-color: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.25); border-radius: 3px; position: relative; min-height: 45px; } .sm-add-layer { position: absolute !important; top: -20px; right: 12px; } .sm-layer + .sm-layer { border-top: 1px solid rgba(255,255,255,.25); } .sm-layer-prv, .sm-btn-prv { width: 16px; height: 16px; border-radius: 3px; display: inline-block; } .sm-layer-prv--text-shadow::after { color: #000; content: "T"; font-weight: 900; font-size: 10px; text-align: center; display: block; } .gjs-pn-views-container, .gjs-pn-views { width: 280px; } .gjs-pn-options { right: 280px; } .gjs-cv-canvas { width: calc(100% - 280px); } /* Vuetify overrides */ .v-application { background: transparent !important; } .v-application--wrap { min-height: auto; } .v-input__slot { font-size: 12px; min-height: 10px !important; } .v-select__selections { flex-wrap: nowrap; } .v-text-field .v-input__slot { padding: 0 10px !important; } .v-input--selection-controls { margin-top: 0; } .v-text-field__details, .v-messages { display: none; }