Twill / Vue 3 block party

CodivoresCodivores
10 min read

Now that we have set up all the tools to create blocks on both Twill and Vue sides, let's create some generic blocks.

Each block will be created in the Common namespace and will be manually added as an allowed block in the controller of our PageContent Module, and declared and called in our Module Inertia Page.

For example, for a Codivores block:

/app/Http/Controllers/Admin/PageContentController.php

            BlockEditor::make()
                ->withoutSeparator()
                ->blocks([
                    'common-codivores',
                ])

/resources/views/Pages/Page/Content.vue

// <script>
const BlockCommonCodivores = defineAsyncComponent(() => import('@Block/Common/Codivores.vue'))
// </script>

// <template>
<div
  v-for="(block, index) in item.blocks"
  :key="index"
>
  ...
  <BlockCommonCodivores
    v-else-if="block.type == 'common-codivores'"
    :block="block"
  ></BlockCommonCodivores>
</div>
// </template>

Separator Block

Let's start with simple things; it is entirely possible to create a block without manageable content (this can also be the useful for blocks that will load asynchronously their content through Fetch API).

Twill Block component

/app/View/Components/Twill/Blocks/Common/Separator.php

<?php

namespace App\View\Components\Twill\Blocks\Common;

use A17\Twill\Services\Forms\Form;

class Separator extends Base
{
    public function getForm(): Form
    {
        return Form::make();
    }

    public static function getBlockTitle(): string
    {
        return __('Separator');
    }

    public static function getBlockIcon(): string
    {
        return 'more-dots';
    }
}

Preview of the Twill BlockEditor

Vue 3 Block component

/resources/views/Components/Theme/Block/Common/Separator.vue

<script setup lang="ts">
defineOptions({
  name: 'BlockCommonSeparator',
})
</script>

<template>
  <div class="mx-auto my-4 max-w-6xl">
    <hr class="w-full border-t border-gray-800" />
  </div>
</template>

Preview of the Inertia Page

Text Block

It's now time to enable HTML content in our blocks using Twill's WYSIWYG field.

To achieve this, we will enhance our BlockComponent class with new methods:

  • fieldWysiwygTypeDefault(): we will use the quill (https://quilljs.com) editor instead of default tiptap (https://tiptap.dev)

  • fieldWysiwygToolbarOptionsDefault(): our default toolbar with options that we decide to allow (it is also possible to add colorization with a list of hexadecimal codes)

  • fieldWysiwygToolbarOptionsLight(): light toolbar that can be useful for certain content types

/app/View/Components/Twill/Blocks/Base/BlockComponent.php

...

    public static function fieldWysiwygTypeDefault(): string
    {
        return 'quill';
    }

    public static function fieldWysiwygToolbarOptionsDefault(): array
    {
        return [
            ['header' => [2, 3, 4, false]],
            'bold',
            'italic',
            'underline',
            'strike',
            ['list' => 'bullet'],
            ['list' => 'ordered'],
            'link',
            'clean',
            // ['color' => ["#747E8C", "#54D52B", "#3BA618", "#00B2D2", "#FBC000"]],
        ];
    }

    public static function fieldWysiwygToolbarOptionsLight(): array
    {
        return [
            'bold',
            'italic',
            'clean',
        ];
    }

...

Twill Block component

/app/View/Components/Twill/Blocks/Common/Text.php

<?php

namespace App\View\Components\Twill\Blocks\Common;

use A17\Twill\Services\Forms\Form;
use A17\Twill\Services\Forms\Fields\Wysiwyg;

class Text extends Base
{
    public function getForm(): Form
    {
        return Form::make([
            Wysiwyg::make()
                ->name('content')
                ->label(__('Content'))
                ->type(self::fieldWysiwygTypeDefault())
                ->toolbarOptions(self::fieldWysiwygToolbarOptionsDefault())
                ->allowSource()
                ->translatable(),
        ]);
    }

    public static function getBlockTitle(): string
    {
        return __('Text');
    }

    public static function getBlockIcon(): string
    {
        return 'text';
    }
}

Preview of the Twill BlockEditor

Vue 3 Block component

/resources/views/Components/Theme/Block/Common/Text.vue

<script setup lang="ts">
defineOptions({
  name: 'BlockCommonText',
})

interface Props {
  block: Model.Block & PropsBlock
}

type PropsBlock = {
  content: {
    content?: string | null
  }
}

defineProps<Props>()
</script>

<template>
  <div
    v-if="block.content?.content"
    v-html="block.content.content"
    class="text-gray-700 text-justify [&>p]:pb-4"
  ></div>
</template>

Preview of the Inertia Page

Button Block

Now, let's focus on buttons. In addition to a translatable URL and label, we will enable the selection of a type for standard (default) or CTA button.

A small Vue-side nuance: we will use the Inertia Link component for internal links and an HTML <a> tag with target="_blank" for external links.

Twill Block component

/app/View/Components/Twill/Blocks/Common/Button.php

<?php

namespace App\View\Components\Twill\Blocks\Common;

use A17\Twill\Services\Forms\Columns;
use A17\Twill\Services\Forms\Fields\Input;
use A17\Twill\Services\Forms\Fields\Select;
use A17\Twill\Services\Forms\Form;
use A17\Twill\Services\Forms\Option;
use A17\Twill\Services\Forms\Options;

class Button extends Base
{
    public function getForm(): Form
    {
        return Form::make([

            Columns::make()
                ->left([
                    Input::make()
                        ->name('label')
                        ->label(__('Label'))
                        ->translatable()
                        ->required()
                ])
                ->right([
                    Input::make()
                        ->name('url')
                        ->label(__('URL'))
                        ->translatable()
                        ->required()
                ]),

            Select::make()
                ->name('type')
                ->label(__('Type'))
                ->options(
                    Options::make([
                        Option::make('std', __('Standard')),
                        Option::make('cta', __('Call To Action')),

                    ])
                )
                ->default('std')
        ]);
    }

    public static function getBlockTitle(): string
    {
        return __('Button');
    }

    public static function getBlockIcon(): string
    {
        return 'revision-single';
    }
}

Preview of the Twill BlockEditor

Vue 3 Block component

/resources/views/Components/Theme/Block/Common/Button.vue

<script setup lang="ts">
import { Link } from '@inertiajs/vue3'

defineOptions({
  name: 'BlockCommonButton',
})

interface Props {
  block: Model.Block & PropsBlock
}

type PropsBlock = {
  content: {
    label?: string | null
    url?: string | null
    type?: string | null
  }
}

const props = defineProps<Props>()

const classes = computed(() =>
  (props.block?.content?.type == 'cta'
    ? 'bg-teal-600 hover:bg-teal-800 uppercase'
    : 'bg-gray-500 hover:bg-gray-700'
  ).concat(' rounded px-5 py-3 font-bold text-white')
)
</script>

<template>
  <div
    v-if="block.content?.url && block.content?.label"
    class="flex justify-center my-2"
  >
    <template v-if="block.content.url?.startsWith('http')">
      <a
        :href="block.content.url"
        target="_blank"
      >
        <button :class="classes">
          {{ block.content.label }}
        </button>
      </a>
    </template>
    <template v-else>
      <Link :href="block.content.url">
        <button :class="classes">
          {{ block.content.label }}
        </button>
      </Link>
    </template>
  </div>
</template>

Preview of the Inertia Page

Image Block

Another type of content that will likely be used in a project is images. Twill handles this exceptionally well with medias

To streamline our workflow for media management, we will make some adaptations:

  • Enhancing our computeBlock() method to handle medias and include only necessary information (src, height, width, alt, caption and video attributes)

  • Creating a Picture Vue component: naturally, you can customize the integration as you prefer or according to your project needs, and there are also solutions like TwicPics or Imgix that not only offer CDN functionalities but also could display cropped images at the correct ratio automatically

  • Enriching our TypeScript types

In this example, we will use Twill's standard roles, namely desktop (16/9), tablet (4/3), and mobile (1). Again, feel free to adapt these according to your preferences, depending on the project, the Module where it is used, ...

/app/Models/Base/Model.php

class Model extends TwillModel
{
    private function computeBlock($block, string $locale, string $fallbackLocale = null): array
    {
        // Handle medias.
        if (isset($block->medias) && count($block->medias) > 0) {
            $medias = [];
            $roles = $block
                ->medias
                ->unique('pivot.role')
                ->pluck('pivot.role');

            foreach ($roles as $role) {
                $images = $block->imagesAsArraysWithCrops($role);
                $medias[$role] = (is_array($images) && count($images) > 1) ? $images : reset($images);
            }

            $block->medias = $medias;
        }

...

        return $block->only(Arr::collapse(
            [
                [
                    'editor_name',
                    'position',
                    'type',
                    'content',
                ],
                (count($block->medias) > 0) ? ['medias'] : [],
                ($block->childs && count($block->childs) > 0) ? ['childs'] : []
            ]
        ));
    }
}

/resources/views/Components/Theme/UI/Picture.vue

@@ -0,0 +1,121 @@
<script setup lang="ts">
defineOptions({
  name: 'ThemeUiPicture',
  inheritAttrs: false,
})

interface Props {
  media?: Model.Media | null
  mediaList?: Model.MediaWithRoles | null
  sizes?: number | object | null
  lazy?: boolean
  caption?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  media: null,
  mediaList: null,
  lazy: false,
  caption: false,
})

const extensionList = ['webp', 'jpg']

const sizes =
  props.sizes && typeof props.sizes == 'number'
    ? {
        mobile: props.sizes,
        tablet: props.sizes,
        desktop: props.sizes,
        default: props.sizes,
      }
    : Object.assign(
        {
          mobile: 768,
          tablet: 1024,
          desktop: 1536,
          default: 1536,
        },
        props.sizes
      )

const figure = computed(() => {
  if (props.mediaList !== null && props.mediaList?.mobile && props.mediaList?.tablet && props.mediaList?.desktop) {
    return {
      sources: [
        {
          crop: 'mobile',
          object: props.mediaList.mobile,
          size: sizes.mobile,
          media: '(max-width: 768px)',
        },
        {
          crop: 'tablet',
          object: props.mediaList.tablet,
          size: sizes.tablet,
          media: '(max-width: 1024px)',
        },
        {
          crop: 'desktop',
          object: props.mediaList.desktop,
          size: sizes.desktop,
          media: '(min-width: 1025px)',
        },
      ],
      caption:
        props.caption && props.mediaList.desktop?.caption && props.mediaList.desktop.caption !== ''
          ? props.mediaList.desktop.caption
          : null,
    }
  } else if (props.media) {
    return {
      sources: [
        {
          crop: 'default',
          object: props.media,
          size: sizes.default,
          media: '(min-width: 1px)',
        },
      ],
      caption: props.caption && props.media?.caption && props.media.caption !== '' ? props.media.caption : null,
    }
  }

  return null
})
</script>

<template>
  <figure class="w-full h-auto">
    <template v-if="figure && figure.sources">
      <picture>
        <template
          v-for="(media, index) in figure.sources"
          :key="index"
        >
          <template v-if="media.object && media.object.src">
            <source
              v-for="imageFormat in extensionList"
              :srcset="`${media.object.src}&fm=webp&w=${media.size}`"
              :media="media.media"
              :type="`image/${imageFormat}`"
            />
            <img
              v-if="index == figure.sources.length - 1"
              :src="`${media.object.src}&fm=webp&w=${media.size}`"
              :alt="media.object?.alt"
              v-bind="$attrs"
              :loading="lazy ? 'lazy' : 'eager'"
              class=""
            />
          </template>
        </template>
      </picture>
      <template v-if="caption && figure.caption !== null">
        <figcaption class="w-full text-xs text-center py-1">
          {{ figure.caption }}
        </figcaption>
      </template>
    </template>
  </figure>
</template>

/resources/js/types/models.d.ts

declare namespace Model {
  export type Page = {
    ...
    medias: {} | null
  }

  export type Media = {
    alt?: string
    caption?: string
    height: number
    src?: string
    video?: string
    width: number
  }

  export type MediaWithRoles = {
    default?: Media
    desktop?: Media
    mobile?: Media
    tablet?: Media
  }
}

Twill Block component

/app/View/Components/Twill/Blocks/Common/Image.php

<?php

namespace App\View\Components\Twill\Blocks\Common;

use A17\Twill\Services\Forms\Form;
use A17\Twill\Services\Forms\Fields\Medias;

class Image extends Base
{
    public function getForm(): Form
    {
        return Form::make([
            Medias::make()
                ->name('common_image')
                ->label(__('Image'))
                ->max(1)
        ]);
    }

    public static function getCrops(): array
    {
        return [
            'common_image' => [
                'desktop' => [
                    [
                        'name' => 'desktop',
                        'ratio' => 16 / 9,
                        'minValues' => [
                            'width' => 1200,
                            'height' => 675,
                        ],
                    ],
                ],
                'tablet' => [
                    [
                        'name' => 'tablet',
                        'ratio' => 4 / 3,
                    ],
                ],
                'mobile' => [
                    [
                        'name' => 'mobile',
                        'ratio' => 1,
                    ],
                ],
            ],
        ];
    }

    public static function getBlockTitle(): string
    {
        return __('Image');
    }

    public static function getBlockIcon(): string
    {
        return 'image';
    }
}

Preview of the Twill BlockEditor

Then you can edit the crop for each role:

Desktop crop

Vue 3 Block component

/resources/views/Components/Theme/Block/Common/Image.vue

<script setup lang="ts">
import Picture from '@Theme/UI/Picture.vue'

defineOptions({
  name: 'BlockCommonImage',
})

interface Props {
  block: Model.Block & PropsBlock
}

type PropsBlock = {
  medias: {
    common_image?: Model.MediaWithRoles
  }
}

defineProps<Props>()
</script>

<template>
  <div
    v-if="block?.medias?.common_image"
    class="my-4"
  >
    <Picture
      :mediaList="block.medias.common_image"
      class="w-full"
    ></Picture>
  </div>
</template>

Preview of the Inertia Page

Image Unconstrained Block

A more permissive version of the Image Block, with a single role and an unrestricted free ratio for unconstrained image display.

Twill Block component

/app/View/Components/Twill/Blocks/Common/ImageUnconstrained.php

<?php

namespace App\View\Components\Twill\Blocks\Common;

use A17\Twill\Services\Forms\Form;
use A17\Twill\Services\Forms\Fields\Medias;

class ImageUnconstrained extends Base
{
    public function getForm(): Form
    {
        return Form::make([
            Medias::make()
                ->name('common_unconstrained_image')
                ->label(__('Image'))
                ->max(1)
        ]);
    }

    public static function getCrops(): array
    {
        return [
            'common_unconstrained_image' => [
                'default' => [
                    [
                        'name' => 'default',
                        'ratio' => 'free',
                    ],
                ],
            ],
        ];
    }

    public static function getBlockTitle(): string
    {
        return __('Unconstrained image');
    }

    public static function getBlockIcon(): string
    {
        return 'image';
    }
}

Vue 3 Block component

/resources/views/Components/Theme/Block/Common/ImageUnconstrained.vue

<script setup lang="ts">
import Picture from '@Theme/UI/Picture.vue'

defineOptions({
  name: 'BlockCommonImageUnconstrained',
})

interface Props {
  block: Model.Block & PropsBlock
}

type PropsBlock = {
  medias: {
    common_unconstrained_image?: Model.MediaWithRoles
  }
}

defineProps<Props>()
</script>

<template>
  <div
    v-if="block?.medias?.common_unconstrained_image?.default"
    class="my-4"
  >
    <Picture
      :media="block.medias.common_unconstrained_image.default"
      class="w-full"
    ></Picture>
  </div>
</template>

Paragraph Block

Just for fun, a component with conditional title, subtitle and content, that leverages our previous Text component for the content field.

Twill Block component

/app/View/Components/Twill/Blocks/Common/Paragraph.php

<?php

namespace App\View\Components\Twill\Blocks\Common;

use A17\Twill\Services\Forms\Fields\Input;
use A17\Twill\Services\Forms\Form;
use A17\Twill\Services\Forms\Fields\Wysiwyg;

class Paragraph extends Base
{
    public function getForm(): Form
    {
        return Form::make([
            Input::make()
                ->name('title')
                ->label(__('Title'))
                ->translatable(),

            Input::make()
                ->name('subtitle')
                ->label(__('Subtitle'))
                ->translatable(),

            Wysiwyg::make()
                ->name('content')
                ->label(__('Content'))
                ->type(self::fieldWysiwygTypeDefault())
                ->toolbarOptions(self::fieldWysiwygToolbarOptionsDefault())
                ->allowSource()
                ->translatable(),
        ]);
    }

    public static function getBlockTitleField(): ?string
    {
        return 'title';
    }

    public static function getBlockTitle(): string
    {
        return __('Paragraph');
    }

    public static function getBlockIcon(): string
    {
        return 'content-editor';
    }
}

Preview of the Twill BlockEditor

Vue 3 Block component

/resources/views/Components/Theme/Block/Common/Paragraph.vue

<script setup lang="ts">
import Text from './Text.vue'

defineOptions({
  name: 'BlockCommonParagraph',
})

interface Props {
  block: Model.Block & PropsBlock
}

type PropsBlock = {
  content: {
    title?: string | null
    subtitle?: string | null
    content?: string | null
  }
}

defineProps<Props>()
</script>

<template>
  <div
    v-if="block?.content?.title || block?.content?.subtitle || block?.content?.content"
    class="my-4"
  >
    <h2
      v-if="block?.content?.title"
      v-html="block.content.title"
      class="text-2xl font-bold mb-1"
    ></h2>
    <h2
      v-if="block?.content?.subtitle"
      v-html="block.content.subtitle"
      class="text-xl font-semibold mb-1"
    ></h2>
    <Text
      v-if="block?.content?.content"
      :block="block"
    ></Text>
  </div>
</template>

Preview of the Inertia Page

Sandbox - Pricing Plan Block

An example to show the use of the Twill InlineRepeater, enabling the easy creation of dynamic content lists.

For the demonstration, we will create a pricing table.

Twill Block component

/app/View/Components/Twill/Blocks/Sandbox/PricingTable.php

<?php

namespace App\View\Components\Twill\Blocks\Sandbox;

use A17\Twill\Services\Forms\Columns;
use A17\Twill\Services\Forms\Fields\Input;
use A17\Twill\Services\Forms\Form;
use A17\Twill\Services\Forms\Fields\Wysiwyg;
use A17\Twill\Services\Forms\InlineRepeater;

class PricingTable extends Base
{
    public function getForm(): Form
    {
        return Form::make([
            Input::make()
                ->name('title')
                ->label(__('Title'))
                ->translatable(),

            InlineRepeater::make()
                ->name('pricing')
                ->label(__('Plan'))
                ->triggerText(__('Add a Plan'))
                ->fields([
                    Columns::make()
                        ->left([
                            Input::make()
                                ->name('name')
                                ->label(__('Name'))
                                ->translatable()
                                ->required(),
                        ])
                        ->right([
                            Input::make()
                                ->name('price')
                                ->label(__('Price'))
                                ->required()
                                ->type('number')
                                ->min(0),
                        ]),

                    Wysiwyg::make()
                        ->name('description')
                        ->label(__('Description'))
                        ->type(self::fieldWysiwygTypeDefault())
                        ->toolbarOptions(self::fieldWysiwygToolbarOptionsDefault())
                        ->allowSource()
                        ->translatable(),

                ])
        ]);
    }

    public static function getBlockTitle(): string
    {
        return __('Pricing Table');
    }

    public static function getBlockIcon(): string
    {
        return 'b-checklist';
    }
}

Preview of the Twill BlockEditor

Vue 3 Block component

/resources/views/Components/Theme/Block/Sandbox/PricingTable.vue

<script setup lang="ts">
defineOptions({
  name: 'BlockSandboxPricingTable',
})

interface Props {
  block: Model.Block & PropsBlock
}

type PropsBlock = {
  content: {
    title?: string | null
    subtitle?: string | null
    content?: string | null
  }
  childs: PropsChildBlock[]
}

type PropsChildBlock = {
  content: {
    name?: string | null
    price?: string | null
    description?: string | null
  }
}

defineProps<Props>()

/**
 * Tailwind dynamic classes.
 * opacity-60
 * opacity-80
 * opacity-100
 */
</script>

<template>
  <div
    v-if="block?.childs && Array.isArray(block.childs) && block.childs.length > 0"
    class="w-full bg-blue pt-8"
  >
    <h1
      v-if="block.content?.title"
      v-html="block.content.title"
      class="text-center text-4xl font-semibold text-gray-900 mb-8"
    ></h1>

    <div class="flex justify-center">
      <template
        v-for="(child, index) in block.childs"
        :key="index"
      >
        <div
          v-if="child.content?.name && child.content?.price"
          class="w-full px-3"
        >
          <div
            class="bg-teal-500 rounded-lg"
            :class="`opacity-${(3 + index) * 20}`"
          >
            <div class="py-3 block text-3xl text-center font-semibold">
              {{ child.content.name }}
            </div>

            <h2 class="mb-6 font-bold text-center">
              <span class="text-4xl">${{ child.content.price }}</span>
              <span class="text-gray-600"> / month </span>
            </h2>

            <p
              v-if="child.content?.description"
              v-html="child.content.description"
              class="border-t border-stroke py-6 mx-6"
            ></p>
          </div>
        </div>
      </template>
    </div>
  </div>
</template>

Preview of the Inertia Page


We'll do our best to provide source code of the serie on GitHub

0
Subscribe to my newsletter

Read articles from Codivores directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Codivores
Codivores