Custom Editor Panels
You can easily customize the editor by using the panel-layout slot. Its slotProps include the blueprints configured for the editor. You can either implement your own panels or use the existing ones.
<CraftEditorPanelLayers>: Panel showing the layers of the editor.<CraftEditorPanelBlueprints>: Panel showing available blueprints and support drag 'n' drop.<CraftEditorPanelSettings>: Panel showing a form which is generated from the component resolver's scheme.
For more information on how to create custom Panels see the Panel components code.
Show code
<template>
<CraftEditor :config="config">
<template #panel-layout="{ blueprints }">
<CraftEditorBlueprintsList :blueprints="blueprints">
<template #blueprint-group="{ group, resolver }">
<h4 class="mb-2">{{ group.label }}</h4>
<div class="flex gap-3">
<CraftEditorBlueprint
v-for="(craftNode, key) in Utils.blueprintsWithDefaults(
group,
resolver
)"
:craftNode="craftNode"
:key="key"
>
<div class="v-craft-blueprint">
<div class="v-craft-blueprint-label">
{{ craftNode.label }}
</div>
</div>
</CraftEditorBlueprint>
</div>
</template>
</CraftEditorBlueprintsList>
<hr />
<CraftFrame>
<slot />
</CraftFrame>
<hr />
<CraftEditorPanelSettings>
<template
#panel-content="{
schema,
selectedNode,
handlePropsUpdate,
eventsSchema,
handleEventsUpdate,
handleSlotsPropsPropsMapUpdate,
handleNodeDataUpdate,
deleteable,
removeNode,
}"
>
<div class="flex justify-around">
<div v-if="schema" class="w-auto">
<CraftEditorPanelNodeSettings
:craftNode="selectedNode"
:schema="schema"
@update:props="handlePropsUpdate"
/>
</div>
<div v-if="eventsSchema" class="w-auto">
<CraftEditorPanelNodeEventsSettings
:craftNode="selectedNode"
:schema="eventsSchema"
@update:events="handleEventsUpdate"
/>
</div>
<div class="w-auto">
<CraftEditorPanelNodeSlotPropsSettings
:craftNode="selectedNode"
@update:slotsPropsPropsMap="handleSlotsPropsPropsMapUpdate"
/>
</div>
<div class="w-auto">
<LoremDataPanel
:craftNode="selectedNode"
:handleNodeDataUpdate="handleNodeDataUpdate"
/>
</div>
<div class="w-auto" data-type="button">
<button
class="formkit-input v-craft-delete"
v-if="deleteable"
@click.prevent="removeNode"
>
Delete
</button>
</div>
</div>
</template>
</CraftEditorPanelSettings>
</template>
</CraftEditor>
<div class="editor-switch">
<label for="editorEnabled">Preview Content: </label>
<input
id="editorEnabled"
type="checkbox"
name="editorEnabled"
v-model="previewContent"
/>
<div class="text-sm">(disables editor drag and drop)</div>
</div>
</template>
<script lang="ts" setup>
import { CraftEditorConfig, useEditor, Utils } from "@versa-stack/v-craft";
import LoremDataPanel from "./LoremDataPanel.vue";
import { onBeforeMount, ref, watch } from "vue";
import blueprintsLibrary from "./blueprints";
import { resolverMap } from "./resolvermap";
const editor = useEditor();
onBeforeMount(() => {
if (!editor.hasNodes) {
editor.setNodes([
{
componentName: "CraftCanvas",
props: {
componentName: "div",
},
slots: {
default: [
{
label: "HTML <header>",
componentName: "CraftCanvas",
props: {
componentName: "header",
},
slots: {
default: [
{
label: "Text",
componentName: "CraftComponentSimpleText",
props: {
content: "Lorem ipsum dolor sit amet,",
componentName: "h1",
},
slots: {},
uuid: "eafd2ed2-9e86-44b9-9ea7-b7cd0649f237",
parentUuid: "9544c8fd-aacc-4143-9d9e-0dae9d84074f",
},
],
},
uuid: "9544c8fd-aacc-4143-9d9e-0dae9d84074f",
parentUuid: "f0e246b7-73df-4fcb-bf37-b7df25e50e14",
},
{
label: "HTML <main>",
componentName: "CraftCanvas",
props: {
componentName: "main",
},
slots: {
default: [
{
label: "HTML <section>",
componentName: "CraftCanvas",
props: {
componentName: "section",
},
slots: {
default: [
{
label: "HTML <article>",
componentName: "CraftCanvas",
props: {
componentName: "article",
class: "",
},
slots: {
default: [
{
label: "Text",
componentName: "CraftComponentSimpleText",
props: {
content:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras ut turpis justo. Curabitur id dapibus justo. Donec quis condimentum nulla. Sed in lectus nisi. Nullam sit amet neque erat. Integer metus turpis, vestibulum ut leo ac, tincidunt mollis nulla. Ut eu nisl id sapien eleifend dapibus id fringilla neque. Vivamus dapibus quam at diam mollis, eget eleifend sem laoreet.",
componentName: "p",
},
slots: {},
uuid: "ab5ddb6e-2605-4bcd-b713-0d4f135e492e",
parentUuid: "e1b1fd9e-1195-4a03-81de-2d8bac88170d",
},
{
label: "Text",
componentName: "CraftComponentSimpleText",
props: {
content:
"Fusce et placerat lacus. Aenean at eros tempus, congue erat vel, blandit tellus. Etiam eu ex quis nunc semper varius. Morbi feugiat viverra eros. Nulla ut nisi dolor. Maecenas eget lectus quis justo sodales sodales. Vestibulum consequat tincidunt lorem eu consequat. Proin nunc lorem, tristique in mattis sed, imperdiet id ex. Quisque vulputate risus ac rhoncus viverra. Ut at felis eu sapien dictum dapibus.",
componentName: "p",
},
slots: {},
uuid: "44b654ef-bf54-43ac-a9dd-976f5204bdbb",
parentUuid: "e1b1fd9e-1195-4a03-81de-2d8bac88170d",
},
{
label: "Text",
componentName: "CraftComponentSimpleText",
props: {
content:
"Suspendisse ultrices mi est, in gravida mi laoreet sit amet. Aenean dapibus nulla ut placerat scelerisque. Maecenas venenatis vitae elit at vestibulum. Nunc vitae pharetra tortor. Ut augue felis, suscipit sed nisl sit amet, feugiat tincidunt massa. Nunc risus nulla, finibus nec sodales ut, condimentum id leo. Nam placerat eu purus vel aliquet. Mauris sollicitudin ligula malesuada lectus luctus, id consectetur odio hendrerit.",
componentName: "p",
},
slots: {},
uuid: "46369c18-a6ac-4592-aec0-3ac471df963d",
parentUuid: "e1b1fd9e-1195-4a03-81de-2d8bac88170d",
},
{
label: "HTML <ul>",
componentName: "CraftCanvas",
props: {
componentName: "ul",
},
slots: {
default: [
{
label: "HTML <li>",
componentName: "CraftCanvas",
props: {
componentName: "li",
},
slots: {
default: [
{
label: "Text",
componentName: "CraftComponentSimpleText",
props: {
content:
"Aenean luctus arcu eu justo cursus faucibus. Phasellus non ex ac massa aliquet suscipit non eget odio.",
componentName: "span",
},
slots: {},
uuid: "dc1f9b0a-d5e6-41bd-84cb-7581200da453",
parentUuid:
"e6e65cef-3478-420e-923f-70243cc052b1",
},
],
},
uuid: "e6e65cef-3478-420e-923f-70243cc052b1",
parentUuid: "364a3066-dd08-48ed-bc08-992d5a167414",
},
],
},
uuid: "364a3066-dd08-48ed-bc08-992d5a167414",
parentUuid: "e1b1fd9e-1195-4a03-81de-2d8bac88170d",
},
],
},
uuid: "e1b1fd9e-1195-4a03-81de-2d8bac88170d",
parentUuid: "6404a1d8-3078-478e-91b7-e5609153a3ea",
},
],
},
uuid: "6404a1d8-3078-478e-91b7-e5609153a3ea",
parentUuid: "61258907-a77d-4c6b-bda9-845f6973ebf0",
},
],
},
uuid: "61258907-a77d-4c6b-bda9-845f6973ebf0",
parentUuid: "f0e246b7-73df-4fcb-bf37-b7df25e50e14",
},
{
label: "Lorem API Demo",
componentName: "CraftCanvas",
props: {
componentName: "section",
class: "lorem-api-demo-section",
},
slots: {
default: [
{
label: "Text (mapped from Lorem API)",
componentName: "CraftComponentSimpleText",
props: {
content:
"Select this section, set a paragraph count, and click \"Fetch from Lorem API\" - this placeholder text will be replaced by the mapped result.",
componentName: "p",
},
slots: {},
// The mapping is pre-wired: once the parent section has
// a "data" datasource (via the Lorem API panel), this
// node's own content prop is filled from $.text - and,
// per the precedence rule, the mapping wins over the
// static placeholder above.
slotsPropsPropsMap: {
data: { content: "$.text" },
},
uuid: "7f9a9d3e-6f2b-4b3a-9f1a-4a5b6c7d8e9f",
parentUuid: "2c1e6a3d-8b4f-4a9c-b2d1-9e3f5a6c7d8b",
},
],
},
uuid: "2c1e6a3d-8b4f-4a9c-b2d1-9e3f5a6c7d8b",
parentUuid: "f0e246b7-73df-4fcb-bf37-b7df25e50e14",
},
{
label: "HTML <footer>",
componentName: "CraftCanvas",
props: {
componentName: "footer",
},
slots: {},
uuid: "5f76c3b0-4af1-4979-9b22-cb8d2ef0bcc3",
parentUuid: "f0e246b7-73df-4fcb-bf37-b7df25e50e14",
},
],
},
parentUuid: null,
uuid: "f0e246b7-73df-4fcb-bf37-b7df25e50e14",
},
]);
}
});
const previewContent = ref(false);
if (!previewContent.value) {
editor.enable();
}
watch(
() => previewContent.value,
(disabled) => {
if (!disabled) {
editor.enable();
}
if (disabled) {
editor.disable();
}
}
);
const config: CraftEditorConfig = {
blueprintsLibrary,
resolverMap,
};
</script>
<style lang="scss" scoped>
.editor-switch {
display: inline-block;
}
:deep(.v-craft-blueprint) {
text-align: center;
padding: 0.25em;
position: relative;
border: 1px solid var(--v-craft-gray-medium);
border-radius: 3px;
align-items: center;
cursor: pointer;
background-color: var(--v-craft-background-color-lighter-10);
}
:deep(.v-craft-blueprint-label) {
font-size: 0.8em;
text-transform: uppercase;
font-weight: bold;
color: var(--v-craft-gray-darker);
}
:deep(.lorem-api-demo-section) {
display: block;
padding: 1em;
margin: 0.5em 0;
border: 1px dashed var(--v-craft-gray-medium);
border-radius: 4px;
}
</style>Example: A Custom Service Data Panel
Panels aren't limited to editing a node's own props — the panel-content slot on <CraftEditorPanelSettings> also exposes handleNodeDataUpdate, the same handler backing the built-in "Data Source" panel. That's enough to build a panel that talks to an external service and feeds the result straight into the node-data mechanism, without touching anything else in the editor.
The demo above includes a working example: select the "Lorem API Demo" section (dashed border, near the bottom of the canvas) and use the "Lorem API" panel on the right to fetch real placeholder paragraphs from lorem-api.com and watch them replace the section's child text.
The panel itself is a small, self-contained component:
<!-- LoremDataPanel.vue -->
<template>
<fieldset v-if="craftNode">
<legend>Lorem API</legend>
<FormKit
type="number"
label="Paragraphs"
min="1"
max="10"
:value="paragraphCount"
@input="(value) => (paragraphCount = Number(value) || 1)"
/>
<button type="button" :disabled="loading" @click.prevent="fetchLorem">
{{ loading ? "Fetching…" : "Fetch from Lorem API" }}
</button>
</fieldset>
</template>
<script setup lang="ts">
import { ref } from "vue";
import type { CraftNode, CraftNodeDatasource } from "@versa-stack/v-craft";
const props = defineProps<{
craftNode?: CraftNode;
handleNodeDataUpdate: (data: CraftNodeDatasource | null) => void;
}>();
const paragraphCount = ref(3);
const loading = ref(false);
const fetchLorem = async () => {
loading.value = true;
try {
const response = await fetch(
`https://lorem-api.com/api/lorem?paragraphs=${paragraphCount.value}`,
);
// The API returns plain text, one paragraph per line - no JSON to
// parse, just split it and give each line a field name to map from.
const paragraphs = (await response.text())
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
props.handleNodeDataUpdate({
type: "list",
list: paragraphs.map((text) => ({ text })),
});
} finally {
loading.value = false;
}
};
</script>Show full source (with loading/error state)
<template>
<fieldset
v-if="craftNode"
class="v-craft-panel-settings formkit-fieldset v-craft-scrollable-content"
>
<legend class="formkit-legend">Lorem API</legend>
<p class="formkit-help">
Fetch placeholder paragraphs from
<a href="https://lorem-api.com/" target="_blank" rel="noopener">lorem-api.com</a>
and feed them into this node as a list datasource. Children read fields
out of it via <code>slotsPropsPropsMap</code>'s reserved
<code>data</code> bucket, exactly like any other node datasource.
</p>
<FormKit
type="number"
label="Paragraphs"
min="1"
max="10"
:value="paragraphCount"
@input="(value) => (paragraphCount = Math.min(10, Math.max(1, Number(value) || 1)))"
/>
<button
type="button"
class="formkit-input"
:disabled="loading"
@click.prevent="fetchLorem"
>
{{ loading ? "Fetching…" : "Fetch from Lorem API" }}
</button>
<p v-if="error" class="formkit-help v-craft-lorem-error">{{ error }}</p>
</fieldset>
</template>
<script lang="ts" setup>
import { FormKit } from "@formkit/vue";
import { ref, watch } from "vue";
import type { CraftNode, CraftNodeDatasource } from "@versa-stack/v-craft";
const props = defineProps<{
craftNode?: CraftNode;
handleNodeDataUpdate: (data: CraftNodeDatasource | null) => void;
}>();
const paragraphCount = ref(3);
const loading = ref(false);
const error = ref("");
// Selecting a different node shouldn't keep showing a stale error from the
// previous one.
watch(
() => props.craftNode?.uuid,
() => {
error.value = "";
},
);
const fetchLorem = async () => {
loading.value = true;
error.value = "";
try {
const response = await fetch(
`https://lorem-api.com/api/lorem?paragraphs=${paragraphCount.value}`,
);
if (!response.ok) {
throw new Error(`Request failed (${response.status})`);
}
// lorem-api.com's /lorem endpoint returns plain text, one paragraph
// per line - no JSON parsing needed, just split and wrap each line so
// it has a field ("text") for slotsPropsPropsMap to target.
const raw = await response.text();
const paragraphs = raw
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
props.handleNodeDataUpdate({
type: "list",
list: paragraphs.map((text) => ({ text })),
});
} catch (e) {
error.value = e instanceof Error ? e.message : "Failed to fetch";
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.v-craft-lorem-error {
color: var(--v-craft-red, #e5484d);
}
</style>There's nothing v-craft-specific about the fetch itself — it's an ordinary fetch() call. The only integration point is the last line: calling handleNodeDataUpdate with a { type: "list", list } datasource. Everything downstream — cloning the section's child once per paragraph, and filling in whichever prop a child targets via slotsPropsPropsMap: { data: { content: "$.text" } } — is handled by the mechanism already in place; the panel doesn't need to know anything about who's listening.
Wiring it in is the same panel-content override pattern as above, just with one more field destructured and one more component rendered:
<CraftEditorPanelSettings>
<template
#panel-content="{
selectedNode, handleNodeDataUpdate, /* ...other slot props */
}"
>
<LoremDataPanel
:craftNode="selectedNode"
:handleNodeDataUpdate="handleNodeDataUpdate"
/>
</template>
</CraftEditorPanelSettings>This is the general recipe for any "service data configuration" panel — a weather API, a CMS, a search index, your own backend: build a small form for whatever parameters the service needs, call it, and hand the result to handleNodeDataUpdate (or editor.setNodeData() directly, if the panel isn't nested inside CraftEditorPanelSettings). See Data Wrappers for the equivalent pattern when the fetch should happen automatically (e.g. on mount) rather than from a user-triggered panel action.