update at 2026-02-13 17:44:25

This commit is contained in:
douboer@gmail.com
2026-02-13 17:44:25 +08:00
parent b98da08f83
commit 2fe45888ba
6 changed files with 660 additions and 124 deletions

View File

@@ -92,8 +92,8 @@
<main class="content">
<section class="left-pane">
<article class="panel block-panel">
<h2>源数据</h2>
<article class="panel block-panel select-panel">
<img :src="iconSelectTitle" alt="数据选择器" class="panel-title-svg panel-title-select" />
<div class="field-block">
<button class="field-title-wrap" type="button" @click="toggleSection('sourceData')">
@@ -102,7 +102,7 @@
:alt="sectionVisible.sourceData ? '折叠' : '展开'"
class="expand-icon"
/>
<h3>数据</h3>
<h3>数据(link value)</h3>
</button>
<template v-if="sectionVisible.sourceData">
@@ -136,7 +136,7 @@
:alt="sectionVisible.sourceDesc ? '折叠' : '展开'"
class="expand-icon"
/>
<h3>描述列</h3>
<h3>源标签(Source label)</h3>
</button>
<template v-if="sectionVisible.sourceDesc">
@@ -146,7 +146,7 @@
:key="`source-desc-${index}`"
class="column-row"
>
<img :src="iconDescription" alt="描述列" class="column-icon" />
<img :src="iconDescription" alt="源标签列" class="column-icon" />
<span class="column-label">{{ header }}</span>
<button class="select-btn" type="button" @click="toggleSourceDescription(index)">
<img
@@ -162,10 +162,7 @@
</div>
</template>
</div>
</article>
<article class="panel block-panel target-panel">
<h2>目标数据</h2>
<div class="field-block">
<button class="field-title-wrap" type="button" @click="toggleSection('targetDesc')">
<img
@@ -173,7 +170,7 @@
:alt="sectionVisible.targetDesc ? '折叠' : '展开'"
class="expand-icon"
/>
<h3>描述列</h3>
<h3>目标标签(target label)</h3>
</button>
<template v-if="sectionVisible.targetDesc">
@@ -183,7 +180,7 @@
:key="`target-desc-${index}`"
class="column-row"
>
<img :src="iconDescription" alt="描述列" class="column-icon" />
<img :src="iconDescription" alt="目标标签列" class="column-icon" />
<span class="column-label">{{ header }}</span>
<button class="select-btn" type="button" @click="toggleTargetDescription(index)">
<img
@@ -200,54 +197,104 @@
</template>
</div>
</article>
<article class="panel info-panel">
<img :src="iconInformationTitle" alt="信息日志" class="panel-title-svg panel-title-info" />
<ul v-if="infoLogs.length > 0" class="info-log-list">
<li
v-for="(log, index) in infoLogs"
:key="`${log.level}-${index}-${log.text}`"
:class="['info-log-item', `level-${log.level}`]"
>
{{ log.text }}
</li>
</ul>
<p v-else class="info-log-empty">暂无日志</p>
</article>
</section>
<section class="panel preview-panel">
<div class="preview-head">
<h2>桑基图预览</h2>
<img :src="iconSankeyViewTitle" alt="桑基图预览" class="panel-title-svg panel-title-preview" />
<div class="preview-controls">
<label class="slider-label">
<img :src="iconGap" alt="间距" class="slider-icon" />
<div class="slider-track-wrap">
<span class="slider-value" :style="getSliderValueStyle(nodeGap, 0, 30)">{{
nodeGap
}}</span>
<input
v-model.number="nodeGap"
class="slider-input"
:style="getSliderTrackStyle(nodeGap, 0, 30)"
type="range"
min="0"
max="30"
/>
</div>
</label>
<label class="slider-label">
<img :src="iconPadding" alt="边距" class="slider-icon" />
<div class="slider-track-wrap">
<span class="slider-value" :style="getSliderValueStyle(chartPadding, 0, 80)">
{{ chartPadding }}
<template v-if="isPhoneViewport">
<label class="compact-control mobile-only">
<img :src="iconGap" alt="间距" class="slider-icon" />
<select v-model.number="nodeGap" class="compact-select">
<option v-for="value in mobileGapOptions" :key="`gap-${value}`" :value="value">
{{ value }}
</option>
</select>
</label>
<label class="compact-control mobile-only">
<img :src="iconPadding" alt="边距" class="slider-icon" />
<select v-model.number="chartPadding" class="compact-select">
<option
v-for="value in mobilePaddingOptions"
:key="`padding-${value}`"
:value="value"
>
{{ value }}
</option>
</select>
</label>
<label class="compact-control mobile-only">
<span class="compact-label">方向</span>
<select v-model="direction" class="compact-select">
<option
v-for="option in directionOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</label>
</template>
<template v-else>
<label class="slider-label desktop-only">
<img :src="iconGap" alt="间距" class="slider-icon" />
<div class="slider-track-wrap">
<span class="slider-value" :style="getSliderValueStyle(nodeGap, 0, 30)">{{
nodeGap
}}</span>
<input
v-model.number="nodeGap"
class="slider-input"
:style="getSliderTrackStyle(nodeGap, 0, 30)"
type="range"
min="0"
max="30"
/>
</div>
</label>
<label class="slider-label desktop-only">
<img :src="iconPadding" alt="边距" class="slider-icon" />
<div class="slider-track-wrap">
<span class="slider-value" :style="getSliderValueStyle(chartPadding, 0, 80)">
{{ chartPadding }}
</span>
<input
v-model.number="chartPadding"
class="slider-input"
:style="getSliderTrackStyle(chartPadding, 0, 80)"
type="range"
min="0"
max="80"
/>
</div>
</label>
<button class="direction-control desktop-only" type="button" @click="toggleDirection">
<span class="direction-label">方向</span>
<span class="direction-switch" :class="{ on: direction === 'source-to-target' }">
<span class="direction-switch-text">
{{ direction === 'source-to-target' ? '源' : '目标' }}
</span>
<span class="direction-switch-thumb" />
</span>
<input
v-model.number="chartPadding"
class="slider-input"
:style="getSliderTrackStyle(chartPadding, 0, 80)"
type="range"
min="0"
max="80"
/>
</div>
</label>
<button class="direction-control" type="button" @click="toggleDirection">
<span class="direction-label">方向</span>
<span class="direction-switch" :class="{ on: direction === 'source-to-target' }">
<span class="direction-switch-text">
{{ direction === 'source-to-target' ? 'source' : 'target' }}
</span>
<span class="direction-switch-thumb" />
</span>
</button>
</button>
</template>
<label class="label-position-control">
<span class="label-position-label">标签位置</span>
<select v-model="labelPositionMode" class="label-position-select">
@@ -275,17 +322,7 @@
</div>
</div>
<div v-if="buildError" class="error-text">{{ buildError }}</div>
<div v-if="parseError" class="error-text">{{ parseError }}</div>
<div ref="chartRef" class="chart-area" />
<div v-if="buildWarnings.length > 0" class="warning-area">
<strong>解析告警 8 </strong>
<ul>
<li v-for="warning in buildWarnings" :key="warning">{{ warning }}</li>
</ul>
</div>
</section>
</main>
@@ -337,6 +374,9 @@ import iconExpand from '../assets/icons/expand.svg';
import iconZhedie from '../assets/icons/zhedie.svg';
import iconGap from '../assets/icons/gap.svg';
import iconPadding from '../assets/icons/padding.svg';
import iconSankeyViewTitle from '../assets/icons/sankeyview.svg';
import iconInformationTitle from '../assets/icons/information.svg';
import iconSelectTitle from '../assets/icons/select.svg';
echarts.use([SankeyChart, TooltipComponent, CanvasRenderer, SVGRenderer]);
@@ -368,6 +408,13 @@ interface PersistedWorkspace {
}>;
}
type LogLevel = 'info' | 'warn' | 'error';
interface InfoLogEntry {
level: LogLevel;
text: string;
}
const themes = THEME_PRESETS;
const selectedThemeId = ref<string>('figma-violet');
const showThemePicker = ref(false);
@@ -383,6 +430,7 @@ const themeTriggerRef = ref<HTMLDivElement | null>(null);
const themePopoverRef = ref<HTMLDivElement | null>(null);
let chartInstance: echarts.EChartsType | null = null;
let themeWheelRafId = 0;
let chartResizeObserver: ResizeObserver | null = null;
const themePopoverStyle = ref<CSSProperties>({});
const THEME_ROW_HEIGHT = 42;
@@ -391,6 +439,7 @@ const rawTable = ref<RawTable | null>(null);
const buildResult = ref<SankeyBuildResult | null>(null);
const uploadedFileSnapshot = ref<PersistedUploadedFile | null>(null);
const isRestoringWorkspace = ref(false);
const isPhoneViewport = ref(false);
const mapping = reactive<MappingConfig>({
sourceDataColumn: null,
@@ -402,20 +451,31 @@ const mapping = reactive<MappingConfig>({
const direction = ref<'source-to-target' | 'target-to-source'>('source-to-target');
const nodeGap = ref(5);
const chartPadding = ref(24);
/**
* 桑基图上下额外留白,避免 gap 变大时首尾节点/标签轻微溢出。
*/
const CHART_TOP_SAFE_PADDING = 8;
const CHART_BOTTOM_SAFE_PADDING = 8;
const labelPositionMode = ref<LabelPositionMode>('inner');
const labelPositionOptions: Array<{ value: LabelPositionMode; label: string }> = [
{ value: 'inner', label: '内' },
{ value: 'outer', label: '外' },
{ value: 'left', label: '左' },
{ value: 'right', label: '右' }
{ value: 'inner', label: '内' },
{ value: 'outer', label: '外' },
{ value: 'left', label: '左' },
{ value: 'right', label: '右' }
];
const targetAlignMode = ref<TargetAlignMode>('between');
const targetAlignOptions: Array<{ value: TargetAlignMode; label: string }> = [
{ value: 'between', label: 'Between' },
{ value: 'middle', label: 'Middle' },
{ value: 'top', label: 'Top' },
{ value: 'bottom', label: 'Bottom' }
{ value: 'between', label: '两端' },
{ value: 'middle', label: '中间' },
{ value: 'top', label: '顶部' },
{ value: 'bottom', label: '底部' }
];
const directionOptions: Array<{ value: 'source-to-target' | 'target-to-source'; label: string }> = [
{ value: 'source-to-target', label: '源' },
{ value: 'target-to-source', label: '目标' }
];
const mobileGapOptions = [0, 5, 10, 15, 20, 25, 30];
const mobilePaddingOptions = [0, 10, 20, 30, 40, 50, 60, 70, 80];
/**
* 左侧字段区块的展开/折叠状态。
* true 表示展开false 表示折叠。
@@ -443,6 +503,98 @@ const columnHeaders = computed(() => {
});
const buildWarnings = computed(() => buildResult.value?.meta.warnings.slice(0, 8) ?? []);
const selectedSourceDataHeader = computed(() => {
if (mapping.sourceDataColumn === null) {
return '未选择';
}
return columnHeaders.value[mapping.sourceDataColumn] ?? `${mapping.sourceDataColumn + 1}`;
});
const selectedSourceLabelHeaders = computed(() => {
if (mapping.sourceDescriptionColumns.length === 0) {
return '未选择';
}
return mapping.sourceDescriptionColumns
.map((index) => columnHeaders.value[index] ?? `${index + 1}`)
.join('');
});
const selectedTargetLabelHeaders = computed(() => {
if (mapping.targetDescriptionColumns.length === 0) {
return '未选择';
}
return mapping.targetDescriptionColumns
.map((index) => columnHeaders.value[index] ?? `${index + 1}`)
.join('');
});
/**
* 左下“信息日志”统一输出解析信息、运行告警、错误和关键配置快照。
* 这样告警不再挤在预览底部,且用户可以持续看到当前映射状态。
*/
const infoLogs = computed<InfoLogEntry[]>(() => {
const logs: InfoLogEntry[] = [];
const table = rawTable.value;
const result = buildResult.value;
if (table) {
logs.push({
level: 'info',
text: `解析信息: 已加载 ${table.rows.length} 行,${table.headers.length}`
});
} else {
logs.push({
level: 'info',
text: '解析信息: 尚未加载数据文件'
});
}
if (result) {
logs.push({
level: 'info',
text: `解析信息: 已生成 ${result.nodes.length} 个节点,${result.links.length} 条连线`
});
if (result.meta.droppedRows > 0) {
logs.push({
level: 'warn',
text: `告警: 已跳过 ${result.meta.droppedRows} 行异常数据`
});
}
}
logs.push({
level: 'info',
text: `日志: 源数据列=${selectedSourceDataHeader.value}`
});
logs.push({
level: 'info',
text: `日志: 源标签列=${selectedSourceLabelHeaders.value}`
});
logs.push({
level: 'info',
text: `日志: 目标标签列=${selectedTargetLabelHeaders.value}`
});
buildWarnings.value.forEach((warning) => {
logs.push({
level: 'warn',
text: `告警: ${warning}`
});
});
if (parseError.value) {
logs.push({
level: 'error',
text: `错误: ${parseError.value}`
});
}
if (buildError.value) {
logs.push({
level: 'error',
text: `错误: ${buildError.value}`
});
}
return logs;
});
/**
* 根据“标签位置”下拉框值,计算左右端节点的标签朝向。
@@ -484,7 +636,13 @@ function buildTargetLocalYMap(
links: Array<{ source: string; target: string; value: number }>
): Map<string, number> {
const map = new Map<string, number>();
const layoutHeight = Math.max(0, chartClientHeight.value - chartPadding.value * 2);
const layoutHeight = Math.max(
0,
chartClientHeight.value -
chartPadding.value * 2 -
CHART_TOP_SAFE_PADDING -
CHART_BOTTOM_SAFE_PADDING
);
if (layoutHeight <= 0 || links.length === 0) {
return map;
}
@@ -619,9 +777,9 @@ const chartOption = computed<EChartsOption>(() => {
{
type: 'sankey',
left: chartPadding.value,
top: chartPadding.value,
top: chartPadding.value + CHART_TOP_SAFE_PADDING,
right: chartPadding.value,
bottom: chartPadding.value,
bottom: chartPadding.value + CHART_BOTTOM_SAFE_PADDING,
nodeAlign: 'justify',
nodeGap: nodeGap.value,
nodeWidth: 14,
@@ -717,10 +875,50 @@ watch(
);
function syncChartSize(): void {
updateAppViewportHeight();
updatePhoneViewportFlag();
chartClientHeight.value = chartRef.value?.clientHeight ?? chartClientHeight.value;
chartInstance?.resize();
}
/**
* iOS Safari 的 100vh 会受到地址栏显隐影响,使用 visualViewport 实时回写 CSS 变量,
* 让页面高度和 preview 面板高度始终跟随可视区域。
*/
function updateAppViewportHeight(): void {
if (typeof window === 'undefined') {
return;
}
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
document.documentElement.style.setProperty('--app-vh', `${viewportHeight * 0.01}px`);
}
function updatePhoneViewportFlag(): void {
if (typeof window === 'undefined') {
return;
}
isPhoneViewport.value = window.matchMedia('(max-width: 640px)').matches;
}
/**
* 监听图表容器尺寸变化(不仅是 window resize
* 解决 iOS 下地址栏收起/展开或弹性布局变化时,图表尺寸不同步的问题。
*/
function observeChartContainerSize(): void {
const container = chartRef.value;
if (!container || typeof ResizeObserver === 'undefined') {
return;
}
chartResizeObserver?.disconnect();
chartResizeObserver = new ResizeObserver(() => {
syncChartSize();
});
chartResizeObserver.observe(container);
}
function closeThemePicker(): void {
showThemePicker.value = false;
}
@@ -1372,9 +1570,13 @@ onMounted(() => {
return;
}
updateAppViewportHeight();
updatePhoneViewportFlag();
chartInstance = echarts.init(container, undefined, { renderer: 'canvas' });
chartClientHeight.value = container.clientHeight;
chartInstance.setOption(chartOption.value);
observeChartContainerSize();
syncChartSize();
void (async () => {
const { restoredUploadedFile, restoredMapping } = await restoreWorkspaceFromStorage();
if (restoredUploadedFile) {
@@ -1387,6 +1589,8 @@ onMounted(() => {
}
})();
window.addEventListener('resize', syncChartSize);
window.visualViewport?.addEventListener('resize', syncChartSize);
window.visualViewport?.addEventListener('scroll', syncChartSize);
window.addEventListener('resize', updateThemePopoverPosition);
window.addEventListener('scroll', updateThemePopoverPosition, true);
window.addEventListener('pointerdown', handleGlobalPointerDown);
@@ -1394,9 +1598,13 @@ onMounted(() => {
onBeforeUnmount(() => {
window.removeEventListener('resize', syncChartSize);
window.visualViewport?.removeEventListener('resize', syncChartSize);
window.visualViewport?.removeEventListener('scroll', syncChartSize);
window.removeEventListener('resize', updateThemePopoverPosition);
window.removeEventListener('scroll', updateThemePopoverPosition, true);
window.removeEventListener('pointerdown', handleGlobalPointerDown);
chartResizeObserver?.disconnect();
chartResizeObserver = null;
chartInstance?.dispose();
chartInstance = null;
if (themeWheelRafId !== 0) {

View File

@@ -31,6 +31,34 @@ function parseNumericValue(text: string): number | null {
return parsed;
}
/**
* 将单元格值格式化为可读文本,用于告警输出。
* 空字符串会明确展示为“(空)”,便于定位“看起来没填值”的问题行。
*/
function formatCellValueForWarning(value: string): string {
if (value.length === 0) {
return '(空)';
}
return value;
}
/**
* 组装“列位置 + 列名 + 原始值”的告警片段,便于用户快速定位问题字段。
*/
function buildColumnDebugText(row: string[], headers: string[], columns: number[]): string {
if (columns.length === 0) {
return '未选择列';
}
return columns
.map((columnIndex) => {
const headerName = headers[columnIndex] ?? `${columnIndex + 1}`;
const rawValue = row[columnIndex] ?? '';
return `${columnIndex + 1} 列(${headerName}="${formatCellValueForWarning(rawValue)}"`;
})
.join('');
}
/**
* 按照配置生成 source 名称。
* 若未选择描述列,则回退为数据列文本。
@@ -87,30 +115,48 @@ export function buildSankeyData(table: RawTable, config: MappingConfig): SankeyB
const linkValueMap = new Map<string, number>();
const warnings: string[] = [];
let droppedRows = 0;
const sourceDataColumnIndex = config.sourceDataColumn;
const sourceDataColumnName =
table.headers[sourceDataColumnIndex] ?? `${sourceDataColumnIndex + 1}`;
const lastNonEmptyTargetValueByColumn = new Map<number, string>();
table.rows.forEach((row, rowIndex) => {
const excelRow = rowIndex + 2;
const sourceRaw = normalizeText(row[config.sourceDataColumn as number] ?? '');
const sourceCellRaw = row[sourceDataColumnIndex] ?? '';
const sourceRaw = normalizeText(sourceCellRaw);
const sourceValue = parseNumericValue(sourceRaw);
if (sourceValue === null) {
warnings.push(`${excelRow} 行: 源数据不是有效数字,已跳过`);
warnings.push(
`${excelRow} 行, 第 ${sourceDataColumnIndex + 1} 列(${sourceDataColumnName}: 源数据不是有效数字,原始值="${formatCellValueForWarning(sourceCellRaw)}",已跳过`
);
droppedRows += 1;
return;
}
const sourceName = buildSourceName(row, config);
if (!sourceName) {
warnings.push(`${excelRow} 行: 源描述为空,已跳过`);
const sourceDescDebugText = buildColumnDebugText(
row,
table.headers,
config.sourceDescriptionColumns
);
warnings.push(`${excelRow} 行: 源描述为空,字段=${sourceDescDebugText},已跳过`);
droppedRows += 1;
return;
}
const targetName = buildTargetName(row, config, lastNonEmptyTargetValueByColumn);
if (!targetName) {
warnings.push(`${excelRow} 行: 目标描述为空,已跳过`);
const targetDescDebugText = buildColumnDebugText(
row,
table.headers,
config.targetDescriptionColumns
);
warnings.push(
`${excelRow} 行: 目标描述为空,字段=${targetDescDebugText},且无可继承的上方值,已跳过`
);
droppedRows += 1;
return;
}

View File

@@ -31,8 +31,11 @@ body {
.page {
position: relative;
width: 100%;
min-height: 100vh;
height: calc(var(--app-vh, 1vh) * 100);
padding: 16px 16px 10px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.top-bar {
@@ -280,13 +283,15 @@ body {
display: grid;
grid-template-columns: 360px 1fr;
gap: 8px;
min-height: calc(100vh - 140px);
flex: 1;
min-height: 0;
}
.left-pane {
display: flex;
flex-direction: column;
gap: 8px;
gap: 10px;
min-height: 0;
}
.panel {
@@ -296,22 +301,35 @@ body {
}
.block-panel {
padding: 8px;
padding: 8px 8px 10px;
}
.target-panel {
.select-panel {
flex: 1;
min-height: 0;
overflow: auto;
}
.block-panel h2 {
margin: 0;
font-size: 24px;
font-weight: 400;
.panel-title-svg {
display: block;
max-width: 100%;
height: auto;
}
.panel-title-select {
width: 189px;
}
.panel-title-preview {
width: 238px;
}
.panel-title-info {
width: 186px;
}
.field-block {
margin-top: 12px;
margin-top: 14px;
position: relative;
}
@@ -417,6 +435,7 @@ body {
padding: 8px;
display: flex;
flex-direction: column;
min-height: 0;
}
.preview-head {
@@ -428,20 +447,20 @@ body {
margin-bottom: 8px;
}
.preview-head h2 {
margin: 0;
width: 223px;
font-size: 24px;
font-weight: 600;
line-height: 1;
}
.preview-controls {
display: flex;
align-items: center;
gap: 24px;
}
.desktop-only {
display: inline-flex;
}
.mobile-only {
display: none;
}
.preview-controls .slider-label {
display: inline-flex;
align-items: center;
@@ -635,6 +654,32 @@ body {
line-height: 22px;
}
.compact-control {
display: inline-flex;
align-items: center;
gap: 4px;
}
.compact-label {
font-size: 12px;
font-weight: 600;
color: #000;
line-height: 1;
white-space: nowrap;
}
.compact-select {
height: 24px;
min-width: 52px;
border: 1px solid #c9aee0;
border-radius: 2px;
background: #fff;
color: #1d2129;
font-size: 12px;
padding: 0 16px 0 4px;
line-height: 22px;
}
.example-line {
margin: 4px 0 8px;
color: var(--text-4);
@@ -644,29 +689,53 @@ body {
.chart-area {
background: var(--fill-1);
border-radius: 8px;
min-height: 480px;
flex: 1;
min-height: 0;
}
.warning-area {
margin-top: 8px;
.info-panel {
padding: 8px;
min-height: 120px;
max-height: 220px;
display: flex;
flex-direction: column;
gap: 8px;
overflow: hidden;
}
.info-log-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
overflow: auto;
}
.info-log-item {
font-size: 12px;
line-height: 1.45;
color: var(--text-4);
}
.warning-area ul {
margin: 6px 0 0;
padding-left: 18px;
.info-log-item.level-warn {
color: #8d5b00;
}
.error-text {
.info-log-item.level-error {
color: #cb272d;
}
.info-log-empty {
margin: 0;
font-size: 12px;
margin-bottom: 6px;
color: var(--text-3);
}
.footer {
margin-top: 6px;
flex-shrink: 0;
color: var(--text-3);
font-size: 16px;
}
@@ -679,49 +748,90 @@ body {
.toolbar {
justify-content: flex-start;
flex-wrap: wrap;
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: hidden;
gap: 8px;
scrollbar-width: none;
}
.toolbar::-webkit-scrollbar {
display: none;
}
.tool-item,
.export-box,
.upload-area {
flex-shrink: 0;
}
.content {
grid-template-columns: 1fr;
display: flex;
flex-direction: column;
overflow: hidden;
}
.preview-panel {
order: 1;
flex: 1;
min-height: 0;
}
.left-pane {
flex-direction: row;
flex-wrap: wrap;
order: 2;
min-height: 0;
max-height: 42%;
overflow: auto;
}
.block-panel {
flex: 1;
min-width: 280px;
min-height: auto;
}
.chart-area {
min-height: 360px;
min-height: 0;
}
.preview-head {
min-height: auto;
align-items: flex-start;
flex-direction: column;
}
.preview-head h2 {
width: auto;
font-size: 28px;
align-items: center;
flex-direction: row;
gap: 8px;
}
.preview-controls {
flex-wrap: wrap;
gap: 12px;
flex-wrap: nowrap;
gap: 8px;
overflow-x: auto;
overflow-y: hidden;
justify-content: flex-start;
scrollbar-width: none;
}
.preview-controls::-webkit-scrollbar {
display: none;
}
.preview-controls .slider-label {
width: 120px;
}
.slider-track-wrap {
width: 96px;
}
.label-position-control {
margin-left: 0;
flex-shrink: 0;
}
.target-align-control {
margin-left: 0;
flex-shrink: 0;
}
.info-panel {
max-height: 180px;
}
.footer {
@@ -749,18 +859,107 @@ body {
}
.upload-area {
min-width: 220px;
display: none;
}
.block-panel h2 {
font-size: 20px;
.top-bar {
padding: 0;
gap: 6px;
}
.preview-head h2 {
font-size: 24px;
.toolbar {
width: 100%;
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: hidden;
justify-content: flex-start;
gap: 6px;
scrollbar-width: none;
}
.toolbar::-webkit-scrollbar {
display: none;
}
.tool-item,
.export-box {
flex-shrink: 0;
}
.content {
gap: 6px;
}
.left-pane {
max-height: 46%;
}
.field-title-wrap h3 {
font-size: 16px;
}
.panel-title-preview {
width: 190px;
}
.preview-head {
gap: 4px;
}
.preview-controls {
width: 100%;
flex-wrap: nowrap;
gap: 4px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
justify-content: flex-start;
}
.preview-controls::-webkit-scrollbar {
display: none;
}
.desktop-only {
display: none;
}
.mobile-only {
display: inline-flex;
}
.preview-controls .slider-label {
width: 74px;
}
.slider-track-wrap {
width: 61px;
}
.label-position-control,
.target-align-control {
gap: 4px;
flex-shrink: 0;
}
.label-position-label,
.target-align-label {
font-size: 12px;
}
.label-position-select {
min-width: 46px;
font-size: 12px;
padding: 0 14px 0 4px;
}
.target-align-select {
min-width: 58px;
font-size: 12px;
padding: 0 14px 0 4px;
}
.direction-control {
flex-shrink: 0;
}
}