Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { LayoutBlock, LayoutSection } from './layout-document'
import type { SectionLayout } from './preview-assets'
import { regionsOf } from './section-layouts'

/**
* Eine Sektion des Canvas mit ihren Blöcken.
Expand All @@ -25,6 +27,8 @@ const props = defineProps<{
sectionIndex: number
/** Registrierte Blöcke nach id. Ein Block, dessen Plugin nicht geladen ist, fehlt hier. */
components: Record<string, unknown>
/** Die Sektionslayouts des Themes — sie sagen, welche Regionen es gibt. */
layouts: readonly SectionLayout[]
/** Der ausgewählte Block, sofern er in dieser Sektion liegt. */
selectedBlockIndex: number | null
}>()
Expand All @@ -37,20 +41,26 @@ interface PlacedBlock {
index: number
}

/**
* Alle Regionen des Layouts, auch die leeren.
*
* Eine leere Region muss sichtbar sein, sonst gibt es keinen Ort, an den man etwas ziehen
* könnte — und eine zweispaltige Sektion mit einer leeren Spalte sähe aus wie eine einspaltige.
*/
const regions = computed(() => {
const grouped = new Map<string, PlacedBlock[]>()
const placed = new Map<string, PlacedBlock[]>()
props.section.blocks.forEach((block, index) => {
const list = grouped.get(block.region) ?? []
const list = placed.get(block.region) ?? []
list.push({ block, index })
grouped.set(block.region, list)
placed.set(block.region, list)
})

return [...grouped.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([region, placed]) => ({
region,
blocks: [...placed].sort((a, b) => a.block.position - b.block.position),
}))
return regionsOf(props.section, props.layouts).map((region) => ({
...region,
blocks: [...(placed.get(region.regionKey) ?? [])].sort(
(a, b) => a.block.position - b.block.position,
),
}))
})

/**
Expand All @@ -77,8 +87,14 @@ function propsFor(block: LayoutBlock): Record<string, unknown> {
:data-cal-spacing="section.spacing"
:data-cal-surface="section.surfaceRole"
>
<div v-for="group in regions" :key="group.region" class="cal-region" :data-cal-region="group.region">
<template v-for="placed in group.blocks" :key="placed.index">
<div
v-for="region in regions"
:key="region.regionKey"
class="cal-region"
:data-cal-region="region.regionKey"
:data-cal-editor-undeclared="region.declared ? undefined : 'true'"
>
<template v-for="placed in region.blocks" :key="placed.index">
<component
:is="components[placed.block.blockId]"
v-if="components[placed.block.blockId]"
Expand All @@ -104,6 +120,16 @@ function propsFor(block: LayoutBlock): Record<string, unknown> {
{{ placed.block.blockId }}
</div>
</template>

<p v-if="region.blocks.length === 0" class="cal-canvas__region-empty">{{ region.label }}</p>
<!--
Eine Region, die das Layout nicht (mehr) deklariert. Ihre Blöcke stehen weiter im
Dokument und kommen zurück, sobald das Theme die Region wieder mitbringt — sie zu
verstecken hieße, Inhalt zu verstecken.
-->
<p v-else-if="!region.declared" class="cal-canvas__region-orphan">
Region „{{ region.regionKey }}" gibt es in diesem Layout nicht.
</p>
</div>
</div>
</template>
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@ vi.mock('@callora/surface', () => ({
}))

const fetchSurfaceStyles = vi.fn(async () => '.cal-header { color: red }')
const fetchThemeTokens = vi.fn(async () => ({ 'color-primary': '#123456' }))
const fetchTheme = vi.fn(async () => ({
valuesByKey: { 'color-primary': '#123456' },
sectionLayouts: [],
}))
vi.mock('./preview-assets', () => ({
fetchSurfaceStyles: (...args: unknown[]) => fetchSurfaceStyles(...args),
fetchThemeTokens: (...args: unknown[]) => fetchThemeTokens(...args),
fetchTheme: (...args: unknown[]) => fetchTheme(...args),
}))

function draftResponse(surfaceKey: string | null) {
Expand Down Expand Up @@ -115,7 +118,7 @@ describe('ComposerAdminPage', () => {
const wrapper = await loadLayout('kiosk')

expect(fetchSurfaceStyles).toHaveBeenCalledWith(['/plugin-assets/voip/app/workspace/main.css'])
expect(fetchThemeTokens).toHaveBeenCalledWith('acme')
expect(fetchTheme).toHaveBeenCalledWith('acme')
const canvas = wrapper.findComponent({ name: 'ComposerCanvas' })
expect(canvas.props('surfaceCss')).toContain('--cal-color-fg')
expect(canvas.props('surfaceCss')).toContain('.cal-header { color: red }')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,17 @@ import {
} from '@callora/surface'
import ComposerCanvas from './ComposerCanvas.vue'
import BlockInspector from './BlockInspector.vue'
import { fetchSurfaceStyles, fetchThemeTokens } from './preview-assets'
import { fetchSurfaceStyles, fetchTheme, type SectionLayout } from './preview-assets'
import { sectionsWithUnknownLayout, themeDeclaresLayouts } from './section-layouts'
import { collectTokenRoles } from './token-roles'
import {
addSection,
blockAt,
clearBlockBinding,
emptyDocument,
readDocument,
setBlockBinding,
setSectionLayout,
type BlockAddress,
type LayoutDocument,
} from './layout-document'
Expand Down Expand Up @@ -48,6 +51,7 @@ const document = ref<LayoutDocument>(emptyDocument())
const changedAtUtc = ref<string | null>(null)
const surfaceCss = ref('')
const tokens = ref<Record<string, string>>({})
const sectionLayouts = ref<SectionLayout[]>([])
const bundleFailures = ref<PluginLoadResult[]>([])
const selected = ref<BlockAddress | null>(null)
const editing = ref(true)
Expand Down Expand Up @@ -132,12 +136,39 @@ async function loadPreview(loaded: LoadedLayout): Promise<void> {

bundleFailures.value = bundles.results.filter((result) => result.status === 'error')

const [css, themeValues] = await Promise.all([
const [css, theme] = await Promise.all([
fetchSurfaceStyles(bundles.styles),
fetchThemeTokens(loaded.workspaceKey),
fetchTheme(loaded.workspaceKey),
])
surfaceCss.value = css
tokens.value = themeValues
tokens.value = theme.valuesByKey
sectionLayouts.value = theme.sectionLayouts
}

/**
* Sektionen, deren Layout dieses Theme nicht kennt (§7.8).
*
* Serverseitig fallen sie beim Rendern auf `single` zurück. Hier zu zeigen, WELCHE es trifft,
* ist der Unterschied zwischen „meine Seite sieht anders aus" und „diese Sektionen hängen an
* einem Layout, das das neue Theme nicht mitbringt".
*/
const strandedSections = computed(() =>
sectionsWithUnknownLayout(document.value, sectionLayouts.value),
)

/** Ob das Theme überhaupt Layouts anbietet — ohne sie lässt sich keine Sektion anlegen. */
const hasLayouts = computed(() => themeDeclaresLayouts(sectionLayouts.value))

function appendSection(layoutKey: string): void {
document.value = addSection(document.value, layoutKey)
}

function changeSectionLayout(sectionIndex: number, layoutKey: string): void {
// Die Blöcke bleiben, wo sie sind. Sie umzuhängen wäre die scheinbar hilfreiche Variante und
// die, die Arbeit vernichtet: Wer ein Layout ausprobiert und zurückwechselt, fände seine
// Seitenspalte im Hauptbereich wieder.
document.value = setSectionLayout(document.value, sectionIndex, layoutKey)
selected.value = null
}

function changeBinding(control: string, binding: Binding<unknown>): void {
Expand Down Expand Up @@ -264,11 +295,72 @@ function draftUrl(key: string): string {
<span class="composer__status">{{ saving ? 'Wird gespeichert …' : '' }}</span>
</div>

<!--
Die Layouts kommen aus dem Theme, nicht aus dem Editor (§7.1). Angeboten wird
ausschließlich, was das Theme stylen kann — so bleibt die Token-Achse die
Design-Autorität, und es steht kein Layout-Name im Core.
-->
<div class="composer__sections">
<label for="composer-new-section">Sektion hinzufügen</label>
<select
id="composer-new-section"
:disabled="!hasLayouts"
@change="appendSection(($event.target as HTMLSelectElement).value)"
>
<option value="">Layout wählen …</option>
<option v-for="layout in sectionLayouts" :key="layout.layoutKey" :value="layout.layoutKey">
{{ layout.label }}
</option>
</select>
<!--
Kein Layout heißt nicht „Editor kaputt", sondern „dieses Theme bringt keine mit". Ohne
diesen Satz sucht jemand den Fehler im Editor.
-->
<!--
Der Server liefert immer mindestens die Basis-Layouts. Leer heißt hier also: Das
Theme war nicht erreichbar — nicht „dieses Theme kann keine".
-->
<p v-if="!hasLayouts" class="composer__status">
Die Sektionslayouts konnten nicht geladen werden.
</p>
</div>

<p v-if="strandedSections.length > 0" class="composer__hint" role="status">
{{ strandedSections.length }} Sektion(en) benutzen ein Layout, das dieses Theme nicht
kennt ({{ [...new Set(strandedSections.map((entry) => entry.section.layout))].join(', ') }}).
Sie werden einspaltig ausgeliefert; der Inhalt bleibt vollständig.
</p>

<ul v-if="document.sections.length > 0" class="composer__section-list">
<li v-for="(section, index) in document.sections" :key="index">
<label :for="`composer-section-${index}`">Sektion {{ index + 1 }}</label>
<select
:id="`composer-section-${index}`"
:value="section.layout"
@change="changeSectionLayout(index, ($event.target as HTMLSelectElement).value)"
>
<!--
Das aktuelle Layout steht auch dann in der Liste, wenn das Theme es nicht kennt.
Sonst zeigte die Auswahl etwas anderes an, als im Dokument steht, und der nächste
Klick irgendwohin änderte still das Layout.
-->
<option v-if="!sectionLayouts.some((l) => l.layoutKey === section.layout)"
:value="section.layout">
{{ section.layout }} (unbekannt)
</option>
<option v-for="layout in sectionLayouts" :key="layout.layoutKey" :value="layout.layoutKey">
{{ layout.label }}
</option>
</select>
</li>
</ul>

<div class="composer__workspace">
<ComposerCanvas
:document="document"
:surface-css="canvasCss"
:tokens="tokens"
:layouts="sectionLayouts"
:selected="selected"
:editing="editing"
@select="selected = $event"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { computed, onMounted, watch } from 'vue'
import CanvasSection from './CanvasSection.vue'
import type { BlockAddress, LayoutDocument } from './layout-document'
import type { SectionLayout } from './preview-assets'
import {
applyScopedSurfaceStyles,
CANVAS_SCOPE,
Expand All @@ -23,6 +24,8 @@ const props = defineProps<{
surfaceCss?: string
/** Die Token des Themes, wie der Server sie rendern würde. */
tokens?: Readonly<Record<string, string>>
/** Die Sektionslayouts des Themes — sie sagen, welche Regionen eine Sektion hat. */
layouts?: readonly SectionLayout[]
/** Der ausgewählte Block. */
selected?: BlockAddress | null
/**
Expand Down Expand Up @@ -81,6 +84,7 @@ watch(() => [props.surfaceCss, props.tokens], applyStyles, { deep: true })
:section="entry.section"
:section-index="entry.index"
:components="components"
:layouts="layouts ?? []"
:selected-block-index="selectedIndexIn(entry.index)"
@select="emit('select', { sectionIndex: entry.index, blockIndex: $event })"
/>
Expand Down
Loading
Loading