299 lines
10 KiB
TypeScript
299 lines
10 KiB
TypeScript
|
|
import React, { CSSProperties, useState } from 'react';
|
||
|
|
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||
|
|
import { Container } from './Container';
|
||
|
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||
|
|
|
||
|
|
type SplitOption =
|
||
|
|
| '100'
|
||
|
|
| '50-50' | '30-70' | '70-30' | '40-60' | '60-40'
|
||
|
|
| '33-33-33' | '25-50-25'
|
||
|
|
| '25-25-25-25'
|
||
|
|
| '20-20-20-20-20'
|
||
|
|
| '16-16-16-16-16-16'
|
||
|
|
| 'equal';
|
||
|
|
|
||
|
|
interface ColumnLayoutProps {
|
||
|
|
columns?: number;
|
||
|
|
split?: SplitOption;
|
||
|
|
gap?: string;
|
||
|
|
style?: CSSProperties;
|
||
|
|
children?: React.ReactNode;
|
||
|
|
}
|
||
|
|
|
||
|
|
const splitToWidths: Record<string, string[]> = {
|
||
|
|
'100': ['100%'],
|
||
|
|
'50-50': ['50%', '50%'],
|
||
|
|
'30-70': ['30%', '70%'],
|
||
|
|
'70-30': ['70%', '30%'],
|
||
|
|
'40-60': ['40%', '60%'],
|
||
|
|
'60-40': ['60%', '40%'],
|
||
|
|
'33-33-33': ['33.333%', '33.333%', '33.333%'],
|
||
|
|
'25-50-25': ['25%', '50%', '25%'],
|
||
|
|
'25-25-25-25': ['25%', '25%', '25%', '25%'],
|
||
|
|
'20-20-20-20-20': ['20%', '20%', '20%', '20%', '20%'],
|
||
|
|
'16-16-16-16-16-16': ['16.666%', '16.666%', '16.666%', '16.666%', '16.666%', '16.666%'],
|
||
|
|
};
|
||
|
|
|
||
|
|
function getWidths(split: SplitOption, columns: number): string[] {
|
||
|
|
// Check predefined splits first
|
||
|
|
if (split !== 'equal') {
|
||
|
|
const defined = splitToWidths[split];
|
||
|
|
if (defined && defined.length === columns) return defined;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Try parsing custom split string (e.g., "35-65" or "25-50-25")
|
||
|
|
if (split && split !== 'equal' && split.includes('-')) {
|
||
|
|
const parts = split.split('-').map(Number);
|
||
|
|
if (parts.length === columns && parts.every(n => !isNaN(n) && n > 0)) {
|
||
|
|
return parts.map(n => `${n}%`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Fallback: equal widths
|
||
|
|
const w = `${(100 / columns).toFixed(3)}%`;
|
||
|
|
return Array.from({ length: columns }, () => w);
|
||
|
|
}
|
||
|
|
|
||
|
|
export const ColumnLayout: UserComponent<ColumnLayoutProps> = ({
|
||
|
|
columns = 2,
|
||
|
|
split = '50-50',
|
||
|
|
gap = '16px',
|
||
|
|
style = {},
|
||
|
|
}) => {
|
||
|
|
const { connectors: { connect, drag } } = useNode();
|
||
|
|
const widths = getWidths(split, columns);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||
|
|
style={{
|
||
|
|
display: 'flex',
|
||
|
|
flexWrap: 'wrap',
|
||
|
|
gap,
|
||
|
|
width: '100%',
|
||
|
|
minHeight: '60px',
|
||
|
|
...style,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{widths.map((w, i) => (
|
||
|
|
<Element
|
||
|
|
key={`col-${i}`}
|
||
|
|
id={`col-${i}`}
|
||
|
|
is={Container}
|
||
|
|
canvas
|
||
|
|
custom={{ className: 'craft-column' }}
|
||
|
|
style={{ flex: `0 0 calc(${w} - ${gap})`, minHeight: '60px', padding: '8px' }}
|
||
|
|
tag="div"
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
/* ---------- Settings panel ---------- */
|
||
|
|
|
||
|
|
const ColumnLayoutSettings: React.FC = () => {
|
||
|
|
const { actions: { setProp }, props } = useNode((node) => ({
|
||
|
|
props: node.data.props as ColumnLayoutProps,
|
||
|
|
}));
|
||
|
|
|
||
|
|
const [showCustom, setShowCustom] = useState(false);
|
||
|
|
|
||
|
|
/* Preset options -- common splits up to 6 columns */
|
||
|
|
const presetOptions: { columns: number; split: SplitOption; label: string }[] = [
|
||
|
|
{ columns: 1, split: '100', label: '1 Col' },
|
||
|
|
{ columns: 2, split: '50-50', label: '2 Equal' },
|
||
|
|
{ columns: 2, split: '30-70', label: '30/70' },
|
||
|
|
{ columns: 2, split: '70-30', label: '70/30' },
|
||
|
|
{ columns: 2, split: '40-60', label: '40/60' },
|
||
|
|
{ columns: 2, split: '60-40', label: '60/40' },
|
||
|
|
{ columns: 3, split: '33-33-33', label: '3 Equal' },
|
||
|
|
{ columns: 3, split: '25-50-25', label: '25/50/25' },
|
||
|
|
{ columns: 4, split: '25-25-25-25', label: '4 Equal' },
|
||
|
|
{ columns: 5, split: '20-20-20-20-20', label: '5 Equal' },
|
||
|
|
{ columns: 6, split: '16-16-16-16-16-16', label: '6 Equal' },
|
||
|
|
];
|
||
|
|
|
||
|
|
const gapPresets = ['0px', '8px', '16px', '24px', '32px'];
|
||
|
|
|
||
|
|
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||
|
|
const inputStyle: CSSProperties = {
|
||
|
|
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
||
|
|
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||
|
|
{/* Preset layouts */}
|
||
|
|
<div>
|
||
|
|
<label style={labelStyle}>Column Layout</label>
|
||
|
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||
|
|
{presetOptions.map((opt) => (
|
||
|
|
<button
|
||
|
|
key={opt.label}
|
||
|
|
onClick={() => {
|
||
|
|
setProp((p: ColumnLayoutProps) => { p.columns = opt.columns; p.split = opt.split; });
|
||
|
|
setShowCustom(false);
|
||
|
|
}}
|
||
|
|
style={{
|
||
|
|
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||
|
|
border: '1px solid #3f3f46',
|
||
|
|
background: props.split === opt.split && props.columns === opt.columns ? '#3b82f6' : '#27272a',
|
||
|
|
color: '#e4e4e7',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{opt.label}
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Custom column count (7-10) */}
|
||
|
|
<div>
|
||
|
|
<button
|
||
|
|
onClick={() => setShowCustom(!showCustom)}
|
||
|
|
style={{
|
||
|
|
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||
|
|
border: '1px solid #3f3f46',
|
||
|
|
background: showCustom ? '#3b82f6' : '#27272a',
|
||
|
|
color: '#e4e4e7',
|
||
|
|
width: '100%',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{showCustom ? 'Hide Custom' : 'Custom (7-10 columns)'}
|
||
|
|
</button>
|
||
|
|
{showCustom && (
|
||
|
|
<div style={{ marginTop: 8 }}>
|
||
|
|
<label style={labelStyle}>Number of Columns</label>
|
||
|
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||
|
|
<input
|
||
|
|
type="range"
|
||
|
|
min={1}
|
||
|
|
max={10}
|
||
|
|
value={props.columns || 2}
|
||
|
|
onChange={(e) => {
|
||
|
|
const cols = parseInt(e.target.value);
|
||
|
|
setProp((p: ColumnLayoutProps) => { p.columns = cols; p.split = 'equal'; });
|
||
|
|
}}
|
||
|
|
style={{ flex: 1 }}
|
||
|
|
/>
|
||
|
|
<span style={{ fontSize: 12, color: '#e4e4e7', minWidth: 24, textAlign: 'center' }}>{props.columns || 2}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Gap */}
|
||
|
|
<div>
|
||
|
|
<label style={labelStyle}>Gap</label>
|
||
|
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||
|
|
{gapPresets.map((g) => (
|
||
|
|
<button
|
||
|
|
key={g}
|
||
|
|
onClick={() => setProp((p: ColumnLayoutProps) => { p.gap = g; })}
|
||
|
|
style={{
|
||
|
|
padding: '2px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||
|
|
border: '1px solid #3f3f46',
|
||
|
|
background: props.gap === g ? '#3b82f6' : '#27272a',
|
||
|
|
color: '#e4e4e7',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{g}
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Individual Column Widths */}
|
||
|
|
<div>
|
||
|
|
<label style={labelStyle}>Column Widths (%)</label>
|
||
|
|
<p style={{ fontSize: 10, color: '#71717a', marginBottom: 6 }}>
|
||
|
|
Adjust each column's width. Values should roughly total 100%.
|
||
|
|
</p>
|
||
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||
|
|
{Array.from({ length: props.columns || 2 }).map((_, i) => {
|
||
|
|
const currentWidths = getWidths(props.split || 'equal', props.columns || 2);
|
||
|
|
const currentPct = parseFloat(currentWidths[i]) || (100 / (props.columns || 2));
|
||
|
|
return (
|
||
|
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||
|
|
<span style={{ fontSize: 10, color: '#71717a', minWidth: 40 }}>Col {i + 1}</span>
|
||
|
|
<input
|
||
|
|
type="range"
|
||
|
|
min={10}
|
||
|
|
max={90}
|
||
|
|
step={5}
|
||
|
|
value={Math.round(currentPct)}
|
||
|
|
onChange={(e) => {
|
||
|
|
const newPct = parseInt(e.target.value);
|
||
|
|
const cols = props.columns || 2;
|
||
|
|
const widths = getWidths(props.split || 'equal', cols).map(w => parseFloat(w));
|
||
|
|
const oldPct = widths[i];
|
||
|
|
const diff = newPct - oldPct;
|
||
|
|
widths[i] = newPct;
|
||
|
|
// Distribute the difference across other columns proportionally
|
||
|
|
const others = widths.filter((_, j) => j !== i);
|
||
|
|
const otherTotal = others.reduce((a, b) => a + b, 0);
|
||
|
|
if (otherTotal > 0) {
|
||
|
|
for (let j = 0; j < widths.length; j++) {
|
||
|
|
if (j !== i) {
|
||
|
|
widths[j] = widths[j] - (diff * (widths[j] / otherTotal));
|
||
|
|
if (widths[j] < 5) widths[j] = 5;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// Normalize to 100%
|
||
|
|
const total = widths.reduce((a, b) => a + b, 0);
|
||
|
|
const normalized = widths.map(w => ((w / total) * 100).toFixed(1) + '%');
|
||
|
|
const customSplit = normalized.map(w => parseFloat(w).toFixed(0)).join('-') as SplitOption;
|
||
|
|
setProp((p: ColumnLayoutProps) => { p.split = customSplit; });
|
||
|
|
}}
|
||
|
|
style={{ flex: 1 }}
|
||
|
|
/>
|
||
|
|
<span style={{ fontSize: 11, color: '#e4e4e7', minWidth: 35, textAlign: 'right' }}>
|
||
|
|
{Math.round(currentPct)}%
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
/* ---------- Craft config ---------- */
|
||
|
|
|
||
|
|
ColumnLayout.craft = {
|
||
|
|
displayName: 'Columns',
|
||
|
|
props: {
|
||
|
|
columns: 2,
|
||
|
|
split: '50-50',
|
||
|
|
gap: '16px',
|
||
|
|
style: {},
|
||
|
|
},
|
||
|
|
rules: {
|
||
|
|
canDrag: () => true,
|
||
|
|
canMoveIn: () => false,
|
||
|
|
canMoveOut: () => true,
|
||
|
|
},
|
||
|
|
related: {
|
||
|
|
settings: ColumnLayoutSettings,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
/* ---------- HTML export ---------- */
|
||
|
|
|
||
|
|
(ColumnLayout as any).toHtml = (props: ColumnLayoutProps, childrenHtml: string) => {
|
||
|
|
const gap = props.gap || '16px';
|
||
|
|
const outerStyle = cssPropsToString({
|
||
|
|
display: 'flex',
|
||
|
|
flexWrap: 'wrap',
|
||
|
|
gap,
|
||
|
|
width: '100%',
|
||
|
|
...props.style,
|
||
|
|
});
|
||
|
|
return {
|
||
|
|
html: `<div${outerStyle ? ` style="${outerStyle}"` : ''}>${childrenHtml}</div>`,
|
||
|
|
};
|
||
|
|
};
|