663 lines
21 KiB
Vue
663 lines
21 KiB
Vue
<script setup>
|
|
// Banner editor tab: per-page banner HTML and banner design, previewed into
|
|
// the real #page-banner region, plus the page's card image (Node.image,
|
|
// inherited by the subtree — the effective one previews, dimmed when
|
|
// inherited). Close and tab switching live in EditorShell.
|
|
import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
import { EditorView, basicSetup } from 'codemirror'
|
|
import { Compartment, EditorState } from '@codemirror/state'
|
|
import { keymap } from '@codemirror/view'
|
|
import { indentWithTab } from '@codemirror/commands'
|
|
import { html } from '@codemirror/lang-html'
|
|
import { cmHighlight, cmTheme } from './cmtheme'
|
|
import ConnNote from './ConnNote.vue'
|
|
import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
|
|
import { dropPageCache, loadPlain, runScripts } from './swapdoc'
|
|
import { apiFetch, apiJson } from 'paskia'
|
|
|
|
const props = defineProps({
|
|
pagePath: { type: String, default: '' },
|
|
})
|
|
// close/path-change are wired by EditorShell; this tab never emits them.
|
|
defineEmits(['close', 'pathChange'])
|
|
|
|
const path = ref('')
|
|
const banner = ref('')
|
|
const saveError = ref('')
|
|
const fileInput = ref(null)
|
|
const imageInput = ref(null)
|
|
const bannerEl = ref(null)
|
|
|
|
let ws = null
|
|
let pendingSave = null
|
|
let reconnectTimer = null
|
|
let connectWatchdog = null
|
|
// Reconnection pacing lives in ./reconnect (shared with the other sockets).
|
|
const reconnects = reconnectPolicy()
|
|
let everConnected = false
|
|
// Connection state drives the note at the top (ConnNote), and locks input
|
|
// until the banner's document has arrived (typing before it would be
|
|
// clobbered by the doc accept).
|
|
const conn = ref('connecting') // connecting | open | waiting
|
|
const retryIn = ref(0)
|
|
const docReady = ref(false)
|
|
const editable = new Compartment()
|
|
const connNote = computed(() =>
|
|
conn.value === 'connecting' ? 'connecting to the server…'
|
|
: conn.value === 'waiting' ? `connection lost — reconnecting in ~${retryIn.value} s…`
|
|
: docReady.value ? '' : 'loading the banner…',
|
|
)
|
|
let view = null // CodeMirror for the banner HTML
|
|
let syncing = false // set while replacing the document programmatically
|
|
|
|
// Banner design selector options come from the backend via settings.
|
|
const theme = ref('')
|
|
const bannerDesign = ref(null)
|
|
const bannerDesignFrom = ref(null)
|
|
const bannerDesigns = ref([])
|
|
// Where an empty banner code field falls back to (null = nowhere: the
|
|
// banner is just the design artwork), shown as a caption under the field.
|
|
const bannerFrom = ref(null)
|
|
// The design that "inherit" resolves to and where it comes from
|
|
// (bannerDesignFrom: an ancestor path, "" = the front page, null = the
|
|
// active theme's default).
|
|
const bannerDesignInherited = ref('')
|
|
// One-shot callback run on the next save ack (set by onBannerDesignChange,
|
|
// whose re-render must not race the save it triggers).
|
|
let refreshOnSave = null
|
|
|
|
// --- Card image (Node.image, '' = inherit, like the banner design) ------
|
|
// The node's own setting, the effective image after inheritance ("" =
|
|
// none) and which node supplied an inherited one ("" = the front page,
|
|
// "" also when own/none — mirrors bannerFrom).
|
|
const image = ref('')
|
|
const imageResolved = ref('')
|
|
const imageSource = ref('')
|
|
// The image the server would mine from the article itself — the card
|
|
// previews fall back to it when no node image resolves (mirrors og:image).
|
|
const imageMined = ref('')
|
|
// Whether the page has children (from the doc message): an own share
|
|
// image is inherited by the whole section.
|
|
const hasChildren = ref(false)
|
|
// The block label states which image is currently in use.
|
|
const imageLabel = computed(() => {
|
|
if (image.value) {
|
|
return hasChildren.value
|
|
? `card image: set for this article — used in /${path.value}/*`
|
|
: 'card image: set for this article'
|
|
}
|
|
if (imageResolved.value) {
|
|
const where = imageSource.value === '' ? 'the front page' : `/${imageSource.value}`
|
|
return `card image: inherited from ${where}`
|
|
}
|
|
if (imageMined.value) return 'card image: from the article'
|
|
return 'card image: none'
|
|
})
|
|
// The page title and description (from the doc message) feed the mock card
|
|
// previews; empty shows placeholder bars / text instead.
|
|
const pageTitle = ref('')
|
|
const pageDesc = ref('')
|
|
// The image the Twitter cards preview with: the resolved node card image,
|
|
// else the mined article image (what og:image would use). image_resolved is
|
|
// a bare store hash; image_mined is already a src path.
|
|
const cardImage = computed(() =>
|
|
imageResolved.value ? `/_f/${imageResolved.value}` : imageMined.value,
|
|
)
|
|
// Card-mode override (Node.large, per-article, NOT inherited):
|
|
// null = automatic, false = small, true = large.
|
|
const large = ref(null)
|
|
// Approximation of the server's automatic pick for the "automatic"
|
|
// marker: the real check probes image dimensions (>= 600px wide,
|
|
// landscape-ish AR) server-side, unavailable here — presence of an
|
|
// effective image stands in for "large".
|
|
const autoLarge = computed(() => !!cardImage.value)
|
|
const effectiveLarge = computed(() => large.value ?? autoLarge.value)
|
|
|
|
function toggleCard(forced) {
|
|
// Clicking the already-selected card deselects back to automatic.
|
|
const msg = {
|
|
type: 'save',
|
|
path: normPath(path.value),
|
|
large: large.value === forced ? null : forced,
|
|
}
|
|
large.value = msg.large
|
|
pendingSave = msg
|
|
send(msg)
|
|
// twitter:card is part of the page head: re-render on ack.
|
|
refreshOnSave = rerender
|
|
}
|
|
function saveImage(hash) {
|
|
const msg = { type: 'save', path: normPath(path.value), image: hash }
|
|
pendingSave = msg
|
|
send(msg)
|
|
// The card image feeds the card previews, card covers and social meta:
|
|
// on ack re-open the doc (fresh image/image_resolved/image_source — the
|
|
// banner itself saves in real time, so nothing is lost) and re-render.
|
|
refreshOnSave = () => {
|
|
openPath(normPath(path.value))
|
|
rerender()
|
|
}
|
|
}
|
|
|
|
async function uploadCardImage(ev) {
|
|
// Card images go to the shared content store, like banner media.
|
|
const file = ev.target.files[0]
|
|
ev.target.value = '' // allow re-picking the same file
|
|
if (!file || !file.type.startsWith('image/')) return
|
|
const name = file.name.replace(/[^\w.-]/g, '-')
|
|
const res = await apiFetch(`/_api/files/${encodeURIComponent(name)}`, { method: 'PUT', body: file })
|
|
if (!res.ok) return
|
|
const { path: stored } = await res.json() // "/_f/<hash>[.ext]"
|
|
saveImage(stored.split('/').pop().split('.')[0])
|
|
}
|
|
|
|
// The inherit option names the design actually in effect and its source.
|
|
const inheritLabel = computed(() => {
|
|
if (bannerDesignFrom.value === null) {
|
|
return `— (from ${theme.value || 'none'})`
|
|
}
|
|
const where = bannerDesignFrom.value === ''
|
|
? 'the front page'
|
|
: `/${bannerDesignFrom.value}`
|
|
return `— (${bannerDesignInherited.value || 'none'} from ${where})`
|
|
})
|
|
|
|
function normPath(p) {
|
|
return p.trim().replace(/^\/+|\/+$/g, '')
|
|
}
|
|
|
|
function send(msg) {
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify(msg))
|
|
} else {
|
|
ensureConnected()
|
|
}
|
|
}
|
|
|
|
function ensureConnected() {
|
|
if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
|
|
connect()
|
|
}
|
|
}
|
|
|
|
const timers = {}
|
|
function debounce(key, fn, ms = 600) {
|
|
clearTimeout(timers[key])
|
|
timers[key] = setTimeout(fn, ms)
|
|
}
|
|
|
|
function save() {
|
|
const msg = { type: 'save', path: normPath(path.value), banner: banner.value }
|
|
pendingSave = msg
|
|
send(msg)
|
|
}
|
|
|
|
function openPath(p) {
|
|
path.value = p
|
|
// Lock input until the doc arrives (typing would be clobbered by it).
|
|
docReady.value = false
|
|
view?.dispatch({ effects: editable.reconfigure(EditorView.editable.of(false)) })
|
|
send({ type: 'open', path: p })
|
|
}
|
|
watch(() => props.pagePath, (p) => { openPath(normPath(p)) })
|
|
|
|
function updateWindowTitle() {
|
|
document.title = `banner: /${path.value} 🖊️`
|
|
}
|
|
|
|
onActivated(() => {
|
|
updateWindowTitle()
|
|
if (banner.value.trim()) previewBanner()
|
|
})
|
|
|
|
// The shell stays mounted while hidden: when it is re-shown with this tab
|
|
// active, restore the window title and banner preview.
|
|
function onEditorShown() {
|
|
if (document.body.dataset.editorMode !== 'banner') return
|
|
updateWindowTitle()
|
|
if (banner.value.trim()) previewBanner()
|
|
}
|
|
|
|
async function loadSettings() {
|
|
try {
|
|
const s = await apiJson('/_api/settings')
|
|
theme.value = s.theme || ''
|
|
bannerDesigns.value = s.banner_designs || []
|
|
} catch { /* keep default */ }
|
|
}
|
|
|
|
// Re-render the page from the server (after banner design changes), then
|
|
// re-overlay the page's own banner code if one is being edited.
|
|
async function rerender() {
|
|
if (await loadPlain(path.value)) {
|
|
if (banner.value.trim()) previewBanner()
|
|
}
|
|
}
|
|
|
|
// --- Banner design ---------------------------------------------------------
|
|
function onBannerDesignChange() {
|
|
const msg = {
|
|
type: 'save',
|
|
path: normPath(path.value),
|
|
banner_design: bannerDesign.value,
|
|
}
|
|
pendingSave = msg
|
|
send(msg)
|
|
refreshOnSave = rerender
|
|
}
|
|
|
|
// --- Banner editing ------------------------------------------------------
|
|
function setDocument(text) {
|
|
syncing = true
|
|
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } })
|
|
syncing = false
|
|
banner.value = text
|
|
}
|
|
|
|
function previewBanner() {
|
|
const el = document.getElementById('page-banner')
|
|
if (!el) return
|
|
if (banner.value.trim()) {
|
|
// Own banner code supplements the design: the inlined design artwork
|
|
// (marked with [data-design]) is detached while the author code is
|
|
// swapped in (so runScripts never re-runs the design's own scripts),
|
|
// then put back first — author code stays last so its styles win.
|
|
const artwork = [...el.querySelectorAll('[data-design]')]
|
|
for (const a of artwork) a.remove()
|
|
el.innerHTML = banner.value
|
|
runScripts(el)
|
|
el.prepend(...artwork)
|
|
} else {
|
|
// No banner code of its own: the region must show the inherited/design
|
|
// banner — re-render from the server (an empty write here would wipe it).
|
|
loadPlain(path.value)
|
|
}
|
|
}
|
|
|
|
function onBannerInput() {
|
|
previewBanner()
|
|
debounce('banner-html', save, 400)
|
|
}
|
|
|
|
function stripBannerMedia(html) {
|
|
// A banner has one piece of media: uploading replaces earlier img/video
|
|
// tags instead of stacking them. (Other HTML, e.g. canvas+script, stays.)
|
|
const doc = new DOMParser().parseFromString(html, 'text/html')
|
|
for (const el of doc.querySelectorAll('img, video')) el.remove()
|
|
return doc.body.innerHTML.trim()
|
|
}
|
|
|
|
async function uploadBannerMedia(file) {
|
|
// Banner media goes to the shared content store, like article images.
|
|
if (!file || !/^(image|video)\//.test(file.type)) return
|
|
const name = file.name.replace(/[^\w.-]/g, '-')
|
|
const res = await apiFetch(`/_api/files/${encodeURIComponent(name)}`, { method: 'PUT', body: file })
|
|
if (!res.ok) return
|
|
const { path: stored } = await res.json()
|
|
const tag = file.type.startsWith('video/')
|
|
? `<video src="${stored}" autoplay muted loop playsinline></video>`
|
|
: `<img src="${stored}" alt="">`
|
|
const rest = stripBannerMedia(banner.value)
|
|
setDocument(rest ? `${tag}\n${rest}` : tag)
|
|
previewBanner()
|
|
save()
|
|
}
|
|
|
|
function onBannerPaste(ev) {
|
|
const file = [...(ev.clipboardData?.files || [])]
|
|
.find((f) => /^(image|video)\//.test(f.type))
|
|
if (file) {
|
|
ev.preventDefault()
|
|
uploadBannerMedia(file)
|
|
}
|
|
}
|
|
|
|
function onMessage(ev) {
|
|
const msg = JSON.parse(ev.data)
|
|
if (msg.type === 'doc' && msg.path === path.value) {
|
|
setDocument(msg.banner ?? '')
|
|
docReady.value = true
|
|
view.dispatch({ effects: editable.reconfigure(EditorView.editable.of(true)) })
|
|
bannerDesign.value = msg.banner_design ?? null
|
|
bannerDesignFrom.value = msg.banner_design_from ?? null
|
|
bannerDesignInherited.value = msg.banner_design_inherited ?? ''
|
|
bannerFrom.value = msg.banner_from ?? null
|
|
image.value = msg.image ?? ''
|
|
imageResolved.value = msg.image_resolved ?? ''
|
|
imageMined.value = msg.image_mined ?? ''
|
|
imageSource.value = msg.image_source ?? ''
|
|
hasChildren.value = msg.has_children ?? false
|
|
large.value = msg.large ?? null
|
|
pageTitle.value = msg.title ?? ''
|
|
pageDesc.value = msg.description ?? ''
|
|
if (banner.value.trim()) previewBanner()
|
|
} else if (msg.type === 'saved') {
|
|
saveError.value = ''
|
|
pendingSave = null
|
|
// Banner HTML/design changes affect the rendered page; invalidate prefetches.
|
|
dropPageCache()
|
|
refreshOnSave?.()
|
|
refreshOnSave = null
|
|
} else if (msg.type === 'error') {
|
|
saveError.value = '⚠️ changes could not be saved'
|
|
}
|
|
}
|
|
|
|
function onKeydown(ev) {
|
|
if ((ev.ctrlKey || ev.metaKey) && ev.key === 's') {
|
|
ev.preventDefault()
|
|
save()
|
|
}
|
|
}
|
|
|
|
function connect() {
|
|
clearTimeout(reconnectTimer)
|
|
conn.value = 'connecting'
|
|
if (ws) {
|
|
// Replacing a stale socket: detach its handlers so its close is silent.
|
|
ws.onopen = ws.onmessage = ws.onclose = ws.onerror = null
|
|
if (ws.readyState !== WebSocket.CLOSED) ws.close()
|
|
}
|
|
ws = new WebSocket(
|
|
`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_api/ws/editor`,
|
|
)
|
|
ws.onmessage = onMessage
|
|
clearTimeout(connectWatchdog)
|
|
connectWatchdog = watchConnecting(ws, 'banner')
|
|
ws.onopen = () => {
|
|
conn.value = 'open'
|
|
reconnects.opened()
|
|
if (everConnected) {
|
|
if (pendingSave) send(pendingSave)
|
|
} else {
|
|
openPath(normPath(props.pagePath))
|
|
}
|
|
everConnected = true
|
|
}
|
|
ws.onclose = () => {
|
|
// The wait is the policy's: doubling backoff with jitter (./reconnect),
|
|
// reset only by a healthy connection — rapid retries trip the browser's
|
|
// WebSocket throttling (sockets stuck "pending" for minutes).
|
|
const wait = reconnects.closed()
|
|
retryIn.value = Math.max(1, Math.round(wait / 1000))
|
|
conn.value = 'waiting'
|
|
reconnectTimer = setTimeout(connect, wait)
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
// The first connection takes a staggered slot (see ./reconnect).
|
|
reconnectTimer = setTimeout(connect, socketSlot())
|
|
view = new EditorView({
|
|
state: EditorState.create({
|
|
doc: '',
|
|
extensions: [
|
|
basicSetup,
|
|
// Tab/Shift-Tab indent and dedent instead of moving focus.
|
|
keymap.of([indentWithTab]),
|
|
html(),
|
|
cmTheme,
|
|
cmHighlight,
|
|
EditorView.lineWrapping,
|
|
// Locked until the banner's document arrives (docReady/ConnNote).
|
|
editable.of(EditorView.editable.of(false)),
|
|
EditorView.updateListener.of((u) => {
|
|
if (u.docChanged && !syncing) {
|
|
banner.value = view.state.doc.toString()
|
|
onBannerInput()
|
|
}
|
|
}),
|
|
],
|
|
}),
|
|
parent: bannerEl.value,
|
|
})
|
|
addEventListener('keydown', onKeydown)
|
|
addEventListener('pagerite:editor-shown', onEditorShown)
|
|
await loadSettings()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
clearTimeout(reconnectTimer)
|
|
clearTimeout(connectWatchdog)
|
|
for (const t of Object.values(timers)) clearTimeout(t)
|
|
if (ws) {
|
|
ws.onclose = null // intentional close, no reconnect
|
|
ws.close()
|
|
}
|
|
view?.destroy()
|
|
removeEventListener('keydown', onKeydown)
|
|
removeEventListener('pagerite:editor-shown', onEditorShown)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="banner-editor">
|
|
<div v-if="saveError">{{ saveError }}</div>
|
|
<ConnNote :text="connNote" />
|
|
|
|
<section class="block card-image">
|
|
<div class="block-head">
|
|
<span class="block-label">{{ imageLabel }}</span>
|
|
<button
|
|
v-if="image"
|
|
type="button"
|
|
class="icon-btn del"
|
|
title="clear the card image (back to inherit)"
|
|
@click="saveImage('')"
|
|
>❌</button>
|
|
<button
|
|
type="button"
|
|
class="icon-btn"
|
|
title="upload card image (og:image / card covers) — the subtree inherits it"
|
|
@click="imageInput.click()"
|
|
>🖼︎</button>
|
|
<input
|
|
ref="imageInput"
|
|
type="file"
|
|
accept="image/*"
|
|
hidden
|
|
@change="uploadCardImage"
|
|
/>
|
|
</div>
|
|
<!-- The site's own cards double as the card-mode selector: rendered
|
|
with the real .card styles from pagerite.css (theme variables
|
|
and all — they ARE the site's look). Clicking one forces that
|
|
mode (Node.large), clicking the selected one returns to
|
|
automatic. The description only exists in the small format,
|
|
like the backend's _card. -->
|
|
<div class="site-previews">
|
|
<button
|
|
type="button"
|
|
class="card compact preview"
|
|
:class="{ selected: large === false, auto: large === null && !effectiveLarge }"
|
|
title="small card — click to force it, click again for automatic"
|
|
@click="toggleCard(false)"
|
|
>
|
|
<span class="top">
|
|
<img v-if="cardImage" class="cover" :src="cardImage" alt="" />
|
|
<span class="title">{{ pageTitle || 'page title' }}</span>
|
|
</span>
|
|
<span class="bottom">
|
|
<span v-if="pageDesc" class="desc">{{ pageDesc }}</span>
|
|
</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="card preview"
|
|
:class="{ selected: large === true, auto: large === null && effectiveLarge }"
|
|
title="large card — click to force it, click again for automatic"
|
|
@click="toggleCard(true)"
|
|
>
|
|
<span class="cover" :style="cardImage ? `background-image: url('${cardImage}')` : null" />
|
|
<span class="title">{{ pageTitle || 'page title' }}</span>
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="block banner-block" @paste="onBannerPaste">
|
|
<div class="block-head">
|
|
<select
|
|
v-model="bannerDesign"
|
|
class="text-input design-select"
|
|
title="Banner design (artwork + its own styles)"
|
|
@change="onBannerDesignChange"
|
|
>
|
|
<option :value="null">{{ inheritLabel }}</option>
|
|
<option value="">none</option>
|
|
<option v-for="d in bannerDesigns" :key="d" :value="d">{{ d }}</option>
|
|
</select>
|
|
<button
|
|
type="button"
|
|
class="icon-btn"
|
|
title="upload banner image/video (replaces existing media) — pasting works too"
|
|
@click="fileInput.click()"
|
|
>🖼︎</button>
|
|
<input
|
|
ref="fileInput"
|
|
type="file"
|
|
accept="image/*,video/*"
|
|
hidden
|
|
@change="(ev) => { uploadBannerMedia(ev.target.files[0]); ev.target.value = '' }"
|
|
/>
|
|
</div>
|
|
<div v-if="bannerFrom !== null" class="note">
|
|
left empty, the banner code is inherited from /{{ bannerFrom }}
|
|
</div>
|
|
<div ref="bannerEl" class="banner-cm" />
|
|
</section>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.banner-editor {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.block {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.4rem;
|
|
padding: 0.5rem 1rem;
|
|
background: var(--surface);
|
|
}
|
|
|
|
.banner-block {
|
|
flex: 1;
|
|
min-height: 0;
|
|
}
|
|
|
|
/* The card-image section: a real preview of the effective image (the
|
|
node's own or the inherited one, dimmed then), with upload/clear in the
|
|
head row like the banner media button. */
|
|
.card-image {
|
|
flex: 0 0 auto;
|
|
border-bottom: 1px solid var(--line);
|
|
}
|
|
|
|
.block-label {
|
|
color: var(--muted);
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
/* The site's own card previews: real .card markup/styles from pagerite.css,
|
|
scaled down via font-size (the card internals are all em, so the layout
|
|
proportions match the real cards exactly). They double as the card-mode
|
|
selector: thin outlines only (no border changes, so selecting never
|
|
shifts the layout) — solid accent for a forced mode, dashed muted for
|
|
the mode "automatic" currently resolves to (approximated from image
|
|
presence). */
|
|
.site-previews {
|
|
display: flex;
|
|
gap: 0.8rem;
|
|
align-items: flex-start;
|
|
flex-wrap: wrap;
|
|
margin-top: 0.8rem;
|
|
}
|
|
|
|
.site-previews .card.preview {
|
|
font: inherit;
|
|
font-size: 0.67rem;
|
|
width: 24em;
|
|
max-width: 100%;
|
|
padding: 0;
|
|
text-align: start;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.site-previews .card.preview.selected {
|
|
outline: 1px solid var(--accent);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.site-previews .card.preview.auto:not(.selected) {
|
|
outline: 1px dashed var(--muted);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.block-head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
}
|
|
|
|
.note {
|
|
color: var(--muted);
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.block-head .icon-btn {
|
|
padding: 0 0.2rem;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
/* The first icon button pushes itself (and any siblings after it, like
|
|
the card-image clear button) to the end of the row. */
|
|
.block-head .icon-btn:first-of-type {
|
|
margin-left: auto;
|
|
}
|
|
|
|
/* The banner design selector stays compact; the upload button is pushed
|
|
right by its auto margin. */
|
|
.design-select {
|
|
flex: 0 1 auto;
|
|
width: auto;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.text-input {
|
|
flex: 1;
|
|
min-width: 4rem;
|
|
font: inherit;
|
|
font-size: 0.9rem;
|
|
padding: 0.2rem 0.5rem;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
border: 1px solid var(--line);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
/* CodeMirror window for the banner HTML; fills the tab and scrolls
|
|
internally. */
|
|
.banner-cm {
|
|
flex: 1;
|
|
min-height: 0;
|
|
border: 1px solid var(--line);
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.banner-cm :deep(.cm-editor) {
|
|
height: 100%;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.banner-cm :deep(.cm-scroller) {
|
|
overflow: auto;
|
|
}
|
|
|
|
.banner-cm :deep(.cm-gutters) {
|
|
display: none;
|
|
}
|
|
</style>
|