GrapesJS Custom Style Manager

by artur_arseniev

HTML

<script src="https://unpkg.com/grapesjs"></script>
<link rel="stylesheet" href="https://unpkg.com/grapesjs/dist/css/grapes.min.css">
<script src="https://unpkg.com/grapesjs-blocks-basic"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/[email protected]/css/materialdesignicons.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/vuetify.min.css">
<script src="https://cdn.jsdelivr.net/npm/vue@2"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/vuetify.min.js"></script>
<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'">
       ...

CSS

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;
}

Babel + JSX

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...