Merge issue-56 (Refs #56)
# Conflicts: # craft/src/editor/CanvasOverlays.tsx # craft/src/styles/editor.css
This commit is contained in:
commit
d39e1ecc31
6 files changed
+529
No files matched your search
@@ -5,6 +5,7 @@ import { DropIndicator } from './DropIndicator';
|
||||
import { RichTextToolbar } from './RichTextToolbar';
|
||||
import { CommandPalette } from '../panels/command-palette/CommandPalette';
|
||||
import { HistoryHost } from '../panels/history/HistoryModal';
|
||||
import { SpacingHandles } from './SpacingHandles';
|
||||
|
||||
/**
|
||||
* Single mount point for editor-wide overlays rendered next to the shell.
|
||||
@@ -19,5 +20,6 @@ export const CanvasOverlays: React.FC = () => (
|
||||
<RichTextToolbar />
|
||||
<CommandPalette />
|
||||
<HistoryHost />
|
||||
<SpacingHandles />
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, test, expect, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness';
|
||||
import { DeviceProvider } from '../state/DeviceContext';
|
||||
import { SpacingHandles } from './SpacingHandles';
|
||||
import { insertBlock } from '../panels/left/blocks-registry';
|
||||
import { closeContinuousBurst } from '../panels/right/styles/shared';
|
||||
import type { DeviceMode } from '../types';
|
||||
|
||||
let h: EditorHarness;
|
||||
afterEach(() => { h?.unmount(); closeContinuousBurst(); });
|
||||
|
||||
function seed(style: Record<string, string>, device: DeviceMode = 'desktop') {
|
||||
h = renderEditorHarness({ wrap: (tree) => <DeviceProvider initialDevice={device}>{tree}</DeviceProvider> });
|
||||
h.mountChild(<SpacingHandles />);
|
||||
let id = '';
|
||||
h.act(() => { id = insertBlock(h.query, h.actions, 'section', { parentId: 'ROOT', select: false })!; });
|
||||
h.act(() => { h.actions.setProp(id, (p: any) => { p.style = { ...p.style, ...style }; }); });
|
||||
// Start from a clean history so the drag's undo steps are countable.
|
||||
h.act(() => { h.actions.history.clear(); });
|
||||
h.act(() => { h.actions.selectNode(id); });
|
||||
return id;
|
||||
}
|
||||
const props = (id: string) => h.query.node(id).get().data.props;
|
||||
const handle = (side: string) => document.querySelector(`.spacing-handle[data-side="${side}"]`) as HTMLElement | null;
|
||||
const ptr = (type: string, init: Record<string, unknown> = {}) =>
|
||||
new MouseEvent(type, { bubbles: true, cancelable: true, button: 0, ...init });
|
||||
const down = (side: string, y: number, x = 0) => h.act(() => { handle(side)!.dispatchEvent(ptr('pointerdown', { clientY: y, clientX: x })); });
|
||||
const move = (y: number, init: Record<string, unknown> = {}, x = 0) => h.act(() => { document.body.dispatchEvent(ptr('pointermove', { clientY: y, clientX: x, ...init })); });
|
||||
const up = () => h.act(() => { document.body.dispatchEvent(ptr('pointerup')); });
|
||||
const esc = () => h.act(() => { document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); });
|
||||
/** Undo steps recorded since history.clear(). */
|
||||
const undoAll = () => { let n = 0; while (h.query.history.canUndo()) { h.act(() => { h.actions.history.undo(); }); n++; if (n > 50) break; } return n; };
|
||||
|
||||
describe('SpacingHandles (real editor)', () => {
|
||||
test('renders four handles for a selected Section, none when nothing/non-container is selected', () => {
|
||||
const id = seed({ paddingTop: '40px' });
|
||||
expect(['top', 'bottom', 'left', 'right'].every((s) => handle(s))).toBe(true);
|
||||
h.act(() => { h.actions.clearEvents(); });
|
||||
expect(document.querySelector('.spacing-handles')).toBeNull();
|
||||
let hid = '';
|
||||
h.act(() => { hid = insertBlock(h.query, h.actions, 'heading', { parentId: 'ROOT', select: true })!; });
|
||||
expect(document.querySelector('.spacing-handles')).toBeNull();
|
||||
expect(id).toBeTruthy();
|
||||
});
|
||||
|
||||
test('top handle +24px => paddingTop 64px in ONE undo step', () => {
|
||||
const id = seed({ paddingTop: '40px' });
|
||||
down('top', 100);
|
||||
move(110); move(120); move(124);
|
||||
expect(document.querySelector('.spacing-readout')?.textContent).toBe('64px');
|
||||
up();
|
||||
expect(props(id).style.paddingTop).toBe('64px');
|
||||
expect(document.querySelector('.spacing-readout')).toBeNull();
|
||||
expect(undoAll()).toBe(1);
|
||||
expect(props(id).style.paddingTop).toBe('40px');
|
||||
});
|
||||
|
||||
test('bottom handle dragged up grows paddingBottom', () => {
|
||||
const id = seed({ paddingBottom: '10px' });
|
||||
down('bottom', 100); move(80); up();
|
||||
expect(props(id).style.paddingBottom).toBe('30px');
|
||||
});
|
||||
|
||||
test('Alt changes top and bottom; Shift changes all four', () => {
|
||||
let id = seed({ paddingTop: '40px', paddingBottom: '40px', paddingLeft: '10px', paddingRight: '10px' });
|
||||
down('top', 100); move(110, { altKey: true }); up();
|
||||
expect(props(id).style).toMatchObject({ paddingTop: '50px', paddingBottom: '50px', paddingLeft: '10px', paddingRight: '10px' });
|
||||
h.unmount();
|
||||
id = seed({ paddingTop: '40px', paddingBottom: '40px', paddingLeft: '10px', paddingRight: '10px' });
|
||||
down('top', 100); move(110, { shiftKey: true }); up();
|
||||
expect(props(id).style).toMatchObject({ paddingTop: '50px', paddingBottom: '50px', paddingLeft: '50px', paddingRight: '50px' });
|
||||
expect(undoAll()).toBe(1);
|
||||
});
|
||||
|
||||
test('left handle uses the X axis', () => {
|
||||
const id = seed({ paddingLeft: '10px' });
|
||||
down('left', 0, 100); move(0, {}, 120); up();
|
||||
expect(props(id).style.paddingLeft).toBe('30px');
|
||||
});
|
||||
|
||||
test('mobile device: write lands in styleMobile, desktop style untouched', () => {
|
||||
const id = seed({ paddingTop: '40px' }, 'mobile');
|
||||
down('top', 100); move(124); up();
|
||||
expect(props(id).styleMobile.paddingTop).toBe('64px');
|
||||
expect(props(id).style.paddingTop).toBe('40px');
|
||||
expect(undoAll()).toBe(1);
|
||||
});
|
||||
|
||||
test('legacy shorthand is expanded to longhands and cleared in the same single step', () => {
|
||||
const id = seed({ padding: '40px 0', paddingTop: '', paddingBottom: '', paddingLeft: '', paddingRight: '' });
|
||||
down('top', 100); move(110); move(124); up();
|
||||
expect(props(id).style).toMatchObject({ padding: '', paddingTop: '64px', paddingBottom: '40px', paddingLeft: '0', paddingRight: '0' });
|
||||
expect(undoAll()).toBe(1);
|
||||
expect(props(id).style.padding).toBe('40px 0');
|
||||
});
|
||||
|
||||
test('Escape mid-drag restores the original and ignores further moves', () => {
|
||||
const id = seed({ paddingTop: '40px', paddingBottom: '40px' });
|
||||
down('top', 100); move(130, { altKey: true });
|
||||
expect(props(id).style.paddingBottom).toBe('70px');
|
||||
esc();
|
||||
expect(props(id).style).toMatchObject({ paddingTop: '40px', paddingBottom: '40px' });
|
||||
move(200); up();
|
||||
expect(props(id).style.paddingTop).toBe('40px');
|
||||
expect(document.querySelector('.spacing-readout')).toBeNull();
|
||||
});
|
||||
|
||||
test('Ctrl snaps to the 8px grid', () => {
|
||||
const id = seed({ paddingTop: '40px' });
|
||||
down('top', 100); move(113, { ctrlKey: true }); up();
|
||||
expect(props(id).style.paddingTop).toBe('56px');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { useDevice } from '../state/DeviceContext';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { resolveStyleForDevice, overrideFor } from '../utils/responsive-style';
|
||||
import { setResponsiveStyleProp, closeContinuousBurst } from '../panels/right/styles/shared';
|
||||
import { expandPaddingShorthand } from '../panels/right/styles/containerBoxModel';
|
||||
import {
|
||||
ALL_SIDES, PADDING_KEY, PaddingKey, PaddingSide,
|
||||
computePadding, isLegacyShorthand, parsePx, readPaddingPx, sidesFor,
|
||||
} from './spacing-drag';
|
||||
|
||||
/** Canvas containers that get padding handles (Section's inner wrapper is a Container). */
|
||||
const CONTAINER_NAMES = new Set(['Section', 'Container', 'Columns', 'Background Section']);
|
||||
const CONTROL_KEY = 'canvas-padding';
|
||||
|
||||
interface DragState {
|
||||
side: PaddingSide;
|
||||
/** Sides currently written + their live px value, for the band/readout. */
|
||||
sides: PaddingSide[];
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface Geometry {
|
||||
rect: { left: number; top: number; width: number; height: number };
|
||||
pad: Record<PaddingSide, number>;
|
||||
}
|
||||
|
||||
const SIDE_AXIS: Record<PaddingSide, 'x' | 'y'> = { top: 'y', bottom: 'y', left: 'x', right: 'x' };
|
||||
|
||||
/**
|
||||
* On-canvas padding handles (#56) for the selected Section / Container /
|
||||
* Columns. Drag an edge handle to change that side's padding; Alt also moves
|
||||
* the opposite side, Shift moves all four, Ctrl/Cmd snaps to 8px, Escape
|
||||
* cancels. Writes go through the #45 per-device setter as one continuous
|
||||
* burst (= one undo step), so tablet/mobile drags land in that device's
|
||||
* override. Mounted from CanvasOverlays.
|
||||
*/
|
||||
export const SpacingHandles: React.FC = () => {
|
||||
const { device } = useDevice();
|
||||
const isMobile = useIsMobile();
|
||||
const { query, actions, nodeId, isDragging, props } = useEditor((state) => {
|
||||
const id = [...state.events.selected][0] as string | undefined;
|
||||
const n = id ? state.nodes[id] : undefined;
|
||||
const ok = !!n && CONTAINER_NAMES.has(n.data.displayName);
|
||||
return {
|
||||
nodeId: ok ? id! : null,
|
||||
isDragging: state.events.dragged.size > 0,
|
||||
// New object per edit (immer): re-measure after a padding change.
|
||||
props: ok ? n!.data.props : null,
|
||||
};
|
||||
});
|
||||
|
||||
const dom: HTMLElement | null = nodeId ? query.node(nodeId).get()?.dom ?? null : null;
|
||||
const [geo, setGeo] = useState<Geometry | null>(null);
|
||||
const [drag, setDrag] = useState<DragState | null>(null);
|
||||
const cancelRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
if (!dom) return setGeo(null);
|
||||
const r = dom.getBoundingClientRect();
|
||||
const cs = typeof getComputedStyle === 'function' ? getComputedStyle(dom) : null;
|
||||
const px = (v: string | undefined) => parsePx(v) ?? 0;
|
||||
setGeo({
|
||||
rect: { left: r.left, top: r.top, width: r.width, height: r.height },
|
||||
pad: {
|
||||
top: px(cs?.paddingTop), bottom: px(cs?.paddingBottom),
|
||||
left: px(cs?.paddingLeft), right: px(cs?.paddingRight),
|
||||
},
|
||||
});
|
||||
}, [dom]);
|
||||
|
||||
// Fixed overlay: follow the node through canvas scroll (the canvas scrolls
|
||||
// inside .editor-canvas, not the window -> capture phase), resize and edits.
|
||||
useLayoutEffect(() => {
|
||||
measure();
|
||||
if (!dom) return;
|
||||
window.addEventListener('resize', measure);
|
||||
document.addEventListener('scroll', measure, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', measure);
|
||||
document.removeEventListener('scroll', measure, true);
|
||||
};
|
||||
}, [dom, measure, props, device]);
|
||||
|
||||
// Selection changed / unmounted mid-drag: abandon it without a revert.
|
||||
useEffect(() => () => { cancelRef.current?.(); }, [nodeId]);
|
||||
|
||||
if (!nodeId || !dom || !geo || isMobile || isDragging) return null;
|
||||
|
||||
const startDrag = (side: PaddingSide, e: React.PointerEvent) => {
|
||||
if (e.button !== 0 && e.button !== undefined) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const axis = SIDE_AXIS[side];
|
||||
const startPos = axis === 'y' ? e.clientY : e.clientX;
|
||||
const nodeProps: any = query.node(nodeId).get().data.props;
|
||||
const effective = resolveStyleForDevice(nodeProps, device) as Record<string, any>;
|
||||
const cs = getComputedStyle(dom);
|
||||
const computedOf = (s: PaddingSide) => cs[PADDING_KEY[s]];
|
||||
const startValue = readPaddingPx(effective, side, computedOf(side));
|
||||
|
||||
// Originals (raw stored values of this device's layer) for Escape/alt-toggle revert.
|
||||
const layer: Record<string, any> = device === 'desktop' ? { ...(nodeProps.style || {}) } : { ...overrideFor(nodeProps, device) };
|
||||
const original = (k: string) => (layer[k] === undefined || layer[k] === null ? '' : layer[k]);
|
||||
|
||||
// A legacy `padding` shorthand with empty longhands: on first write expand
|
||||
// it to the four longhands and clear it in the same burst (as the panel does).
|
||||
const legacy = isLegacyShorthand(effective);
|
||||
const expandAll = legacy && (device === 'desktop' || (layer.padding !== undefined && layer.padding !== ''));
|
||||
const expanded = expandAll ? expandPaddingShorthand(effective.padding) : {};
|
||||
|
||||
const touched = new Set<PaddingKey>();
|
||||
let shorthandCleared = false;
|
||||
let finished = false;
|
||||
|
||||
const write = (key: string, value: string) =>
|
||||
setResponsiveStyleProp(actions, nodeId, device, key, value, { continuous: true, controlKey: CONTROL_KEY });
|
||||
|
||||
const apply = (ev: PointerEvent) => {
|
||||
const cur = axis === 'y' ? ev.clientY : ev.clientX;
|
||||
const { value, sides } = computePadding({
|
||||
start: startPos, current: cur, startValue, side,
|
||||
alt: ev.altKey, shift: ev.shiftKey, snap: ev.ctrlKey || ev.metaKey,
|
||||
});
|
||||
if (expandAll && !shorthandCleared) {
|
||||
write('padding', '');
|
||||
shorthandCleared = true;
|
||||
}
|
||||
const next: Partial<Record<PaddingKey, string>> = { ...sides };
|
||||
// Sides written earlier but not part of this modifier set go back to where they began.
|
||||
touched.forEach((k) => { if (!next[k]) next[k] = expandAll ? (expanded[k.slice(7).toLowerCase() as PaddingSide] ?? '') : original(k); });
|
||||
if (expandAll) {
|
||||
// Untouched sides keep their shorthand-derived value as a longhand.
|
||||
ALL_SIDES.forEach((s) => { const k = PADDING_KEY[s]; if (!next[k]) next[k] = expanded[s] ?? ''; });
|
||||
}
|
||||
(Object.keys(next) as PaddingKey[]).forEach((k) => { write(k, next[k]!); });
|
||||
(Object.keys(sides) as PaddingKey[]).forEach((k) => touched.add(k));
|
||||
setDrag({ side, sides: sidesFor(side, ev.altKey, ev.shiftKey), value });
|
||||
};
|
||||
|
||||
const teardown = () => {
|
||||
finished = true;
|
||||
window.removeEventListener('pointermove', onMove, true);
|
||||
window.removeEventListener('pointerup', onUp, true);
|
||||
window.removeEventListener('pointercancel', onUp, true);
|
||||
window.removeEventListener('keydown', onKey, true);
|
||||
cancelRef.current = null;
|
||||
setDrag(null);
|
||||
measure();
|
||||
};
|
||||
const onMove = (ev: PointerEvent) => { if (!finished) apply(ev); };
|
||||
const onUp = () => { teardown(); };
|
||||
const onKey = (ev: KeyboardEvent) => {
|
||||
if (ev.key !== 'Escape') return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
// Restore every key this drag touched (still inside the open burst), then close it.
|
||||
touched.forEach((k) => write(k, original(k)));
|
||||
if (shorthandCleared) write('padding', original('padding'));
|
||||
closeContinuousBurst();
|
||||
teardown();
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', onMove, true);
|
||||
window.addEventListener('pointerup', onUp, true);
|
||||
window.addEventListener('pointercancel', onUp, true);
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
cancelRef.current = () => { if (!finished) teardown(); };
|
||||
try { (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); } catch { /* not supported */ }
|
||||
};
|
||||
|
||||
const { rect, pad } = geo;
|
||||
// During a drag the live value is authoritative for the dragged sides.
|
||||
const live = (s: PaddingSide) => (drag && drag.sides.includes(s) ? drag.value : pad[s]);
|
||||
const bandStyle = (s: PaddingSide): React.CSSProperties => {
|
||||
const t = live(s);
|
||||
switch (s) {
|
||||
case 'top': return { left: rect.left, top: rect.top, width: rect.width, height: t };
|
||||
case 'bottom': return { left: rect.left, top: rect.top + rect.height - t, width: rect.width, height: t };
|
||||
case 'left': return { left: rect.left, top: rect.top, width: t, height: rect.height };
|
||||
default: return { left: rect.left + rect.width - t, top: rect.top, width: t, height: rect.height };
|
||||
}
|
||||
};
|
||||
const handleStyle = (s: PaddingSide): React.CSSProperties => {
|
||||
const t = live(s);
|
||||
switch (s) {
|
||||
case 'top': return { left: rect.left + rect.width / 2, top: rect.top + t };
|
||||
case 'bottom': return { left: rect.left + rect.width / 2, top: rect.top + rect.height - t };
|
||||
case 'left': return { left: rect.left + t, top: rect.top + rect.height / 2 };
|
||||
default: return { left: rect.left + rect.width - t, top: rect.top + rect.height / 2 };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="spacing-handles" data-testid="spacing-handles">
|
||||
{drag && drag.sides.map((s) => (
|
||||
<div key={`band-${s}`} className="spacing-band" style={bandStyle(s)} />
|
||||
))}
|
||||
{ALL_SIDES.map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className={`spacing-handle spacing-handle-${s}${drag?.side === s ? ' is-active' : ''}`}
|
||||
data-side={s}
|
||||
style={handleStyle(s)}
|
||||
title={`Padding ${s} (Alt: opposite side, Shift: all sides, Ctrl/Cmd: snap 8px)`}
|
||||
onPointerDown={(e) => startDrag(s, e)}
|
||||
onMouseDown={(e) => { e.stopPropagation(); }}
|
||||
/>
|
||||
))}
|
||||
{drag && (
|
||||
<div className="spacing-readout" style={handleStyle(drag.side)}>{drag.value}px</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { computePadding, sidesFor, readPaddingPx, parsePx, isLegacyShorthand } from './spacing-drag';
|
||||
|
||||
describe('sidesFor', () => {
|
||||
test('plain / alt / shift', () => {
|
||||
expect(sidesFor('top', false, false)).toEqual(['top']);
|
||||
expect(sidesFor('top', true, false)).toEqual(['top', 'bottom']);
|
||||
expect(sidesFor('left', true, false)).toEqual(['left', 'right']);
|
||||
expect(sidesFor('right', false, true).sort()).toEqual(['bottom', 'left', 'right', 'top']);
|
||||
expect(sidesFor('bottom', true, true)).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computePadding', () => {
|
||||
const base = { start: 100, startValue: 40 };
|
||||
test('top/left grow with positive delta, bottom/right with negative', () => {
|
||||
expect(computePadding({ ...base, current: 124, side: 'top' }).sides).toEqual({ paddingTop: '64px' });
|
||||
expect(computePadding({ ...base, current: 124, side: 'left' }).sides).toEqual({ paddingLeft: '64px' });
|
||||
expect(computePadding({ ...base, current: 76, side: 'bottom' }).sides).toEqual({ paddingBottom: '64px' });
|
||||
expect(computePadding({ ...base, current: 76, side: 'right' }).sides).toEqual({ paddingRight: '64px' });
|
||||
expect(computePadding({ ...base, current: 124, side: 'bottom' }).sides).toEqual({ paddingBottom: '16px' });
|
||||
});
|
||||
test('clamps to 0..400', () => {
|
||||
expect(computePadding({ ...base, current: -500, side: 'top' }).value).toBe(0);
|
||||
expect(computePadding({ ...base, current: 9999, side: 'top' }).value).toBe(400);
|
||||
});
|
||||
test('alt writes both opposite sides, shift all four, with the same value', () => {
|
||||
expect(computePadding({ ...base, current: 110, side: 'top', alt: true }).sides).toEqual({ paddingTop: '50px', paddingBottom: '50px' });
|
||||
const all = computePadding({ ...base, current: 110, side: 'left', shift: true }).sides;
|
||||
expect(Object.keys(all).sort()).toEqual(['paddingBottom', 'paddingLeft', 'paddingRight', 'paddingTop']);
|
||||
expect(new Set(Object.values(all))).toEqual(new Set(['50px']));
|
||||
});
|
||||
test('snap rounds to the 8px grid; default is 1px', () => {
|
||||
expect(computePadding({ ...base, current: 113, side: 'top' }).value).toBe(53);
|
||||
expect(computePadding({ ...base, current: 113, side: 'top', snap: true }).value).toBe(56);
|
||||
expect(computePadding({ ...base, current: 110.4, side: 'top' }).value).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readPaddingPx', () => {
|
||||
test('longhand wins over everything', () => {
|
||||
expect(readPaddingPx({ paddingTop: '12px', padding: '40px 0' }, 'top', '99px')).toBe(12);
|
||||
});
|
||||
test('shorthand token when longhand empty (CSS order)', () => {
|
||||
expect(readPaddingPx({ padding: '40px 0' }, 'top')).toBe(40);
|
||||
expect(readPaddingPx({ padding: '40px 0' }, 'left')).toBe(0);
|
||||
expect(readPaddingPx({ padding: '1px 2px 3px 4px' }, 'bottom')).toBe(3);
|
||||
});
|
||||
test('falls back to the stubbed computed value, then 0', () => {
|
||||
expect(readPaddingPx({}, 'top', '17px')).toBe(17);
|
||||
expect(readPaddingPx({ paddingTop: '5%' }, 'top', '23px')).toBe(23);
|
||||
expect(readPaddingPx({ padding: '2em' }, 'top', '32px')).toBe(32);
|
||||
expect(readPaddingPx({}, 'top', '')).toBe(0);
|
||||
expect(readPaddingPx({}, 'top')).toBe(0);
|
||||
});
|
||||
test('parsePx / isLegacyShorthand', () => {
|
||||
expect(parsePx('12px')).toBe(12);
|
||||
expect(parsePx('auto')).toBeNull();
|
||||
expect(isLegacyShorthand({ padding: '4px' })).toBe(true);
|
||||
expect(isLegacyShorthand({ padding: '4px', paddingTop: '1px' })).toBe(false);
|
||||
expect(isLegacyShorthand({ padding: '', paddingTop: '' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { expandPaddingShorthand } from '../panels/right/styles/containerBoxModel';
|
||||
|
||||
/**
|
||||
* Pure maths for the on-canvas padding handles (#56). No React, no DOM: the
|
||||
* component feeds in pointer coordinates and gets back style strings.
|
||||
*/
|
||||
|
||||
export type PaddingSide = 'top' | 'bottom' | 'left' | 'right';
|
||||
export type PaddingKey = 'paddingTop' | 'paddingBottom' | 'paddingLeft' | 'paddingRight';
|
||||
|
||||
export const PADDING_KEY: Record<PaddingSide, PaddingKey> = {
|
||||
top: 'paddingTop',
|
||||
bottom: 'paddingBottom',
|
||||
left: 'paddingLeft',
|
||||
right: 'paddingRight',
|
||||
};
|
||||
export const ALL_SIDES: readonly PaddingSide[] = ['top', 'bottom', 'left', 'right'];
|
||||
const OPPOSITE: Record<PaddingSide, PaddingSide> = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' };
|
||||
|
||||
export const PADDING_MIN = 0;
|
||||
export const PADDING_MAX = 400;
|
||||
/** Grid used while Ctrl/Cmd is held. */
|
||||
export const PADDING_SNAP = 8;
|
||||
|
||||
/** Sides a drag on `side` writes: Shift = all four, Alt = it + its opposite. */
|
||||
export function sidesFor(side: PaddingSide, alt: boolean, shift: boolean): PaddingSide[] {
|
||||
if (shift) return [...ALL_SIDES];
|
||||
if (alt) return [side, OPPOSITE[side]];
|
||||
return [side];
|
||||
}
|
||||
|
||||
export interface ComputePaddingInput {
|
||||
/** Pointer coordinate on the drag axis (clientY for top/bottom, clientX for left/right) at pointerdown. */
|
||||
start: number;
|
||||
current: number;
|
||||
/** Padding of the dragged side, in px, when the drag began. */
|
||||
startValue: number;
|
||||
side: PaddingSide;
|
||||
alt?: boolean;
|
||||
shift?: boolean;
|
||||
/** Snap to the 8px grid (Ctrl/Cmd held). Default: 1px steps. */
|
||||
snap?: boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export interface ComputePaddingResult {
|
||||
value: number;
|
||||
sides: Partial<Record<PaddingKey, string>>;
|
||||
}
|
||||
|
||||
/** Dragging a handle toward the content grows the padding: top/left handles
|
||||
* grow with +delta, bottom/right with -delta. */
|
||||
export function computePadding(i: ComputePaddingInput): ComputePaddingResult {
|
||||
const min = i.min ?? PADDING_MIN;
|
||||
const max = i.max ?? PADDING_MAX;
|
||||
const sign = i.side === 'top' || i.side === 'left' ? 1 : -1;
|
||||
let value = i.startValue + sign * (i.current - i.start);
|
||||
value = i.snap ? Math.round(value / PADDING_SNAP) * PADDING_SNAP : Math.round(value);
|
||||
value = Math.min(max, Math.max(min, value));
|
||||
const sides: Partial<Record<PaddingKey, string>> = {};
|
||||
for (const s of sidesFor(i.side, !!i.alt, !!i.shift)) sides[PADDING_KEY[s]] = `${value}px`;
|
||||
return { value, sides };
|
||||
}
|
||||
|
||||
const hasValue = (v: unknown) => v !== undefined && v !== null && v !== '';
|
||||
|
||||
/** "12px" / "12" / 12 → 12; anything else (%, em, calc, auto) → null. */
|
||||
export function parsePx(v: unknown): number | null {
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||
const m = /^\s*(-?\d*\.?\d+)(px)?\s*$/.exec(String(v ?? ''));
|
||||
return m ? parseFloat(m[1]) : null;
|
||||
}
|
||||
|
||||
/** True when the effective style holds a `padding` shorthand and no longhand. */
|
||||
export function isLegacyShorthand(style: Record<string, any>): boolean {
|
||||
return hasValue(style.padding) && ALL_SIDES.every((s) => !hasValue(style[PADDING_KEY[s]]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Current padding of `side` in px: the effective longhand, else the matching
|
||||
* token of an effective `padding` shorthand, else `computed` (the
|
||||
* getComputedStyle value, for percent/em/unset), else 0.
|
||||
*/
|
||||
export function readPaddingPx(style: Record<string, any>, side: PaddingSide, computed?: string | null): number {
|
||||
const long = parsePx(style[PADDING_KEY[side]]);
|
||||
if (hasValue(style[PADDING_KEY[side]]) && long !== null) return long;
|
||||
if (hasValue(style.padding) && !hasValue(style[PADDING_KEY[side]])) {
|
||||
const short = parsePx(expandPaddingShorthand(style.padding)[side]);
|
||||
if (short !== null) return short;
|
||||
}
|
||||
return parsePx(computed) ?? 0;
|
||||
}
|
||||
@@ -2971,3 +2971,44 @@ body {
|
||||
.publish-check-info .publish-check-msg::before { content: '\2139\00a0'; color: #7dd3fc; }
|
||||
.publish-check-fix { flex: none; background: none; border: 1px solid #94a3b8; border-radius: 4px; color: inherit; cursor: pointer; padding: 2px 10px; }
|
||||
.publish-checklist-note { margin: 8px 0 0; color: #94a3b8; font-size: 12px; }
|
||||
|
||||
/* #56 on-canvas padding handles */
|
||||
.spacing-handles {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 40;
|
||||
}
|
||||
.spacing-handle {
|
||||
position: fixed;
|
||||
pointer-events: auto;
|
||||
background: var(--color-accent);
|
||||
opacity: 0.35;
|
||||
border-radius: 3px;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.spacing-handle-top,
|
||||
.spacing-handle-bottom { width: 44px; height: 6px; cursor: ns-resize; }
|
||||
.spacing-handle-left,
|
||||
.spacing-handle-right { width: 6px; height: 44px; cursor: ew-resize; }
|
||||
.spacing-handle:hover,
|
||||
.spacing-handle.is-active { opacity: 0.9; }
|
||||
.spacing-band {
|
||||
position: fixed;
|
||||
background: var(--color-accent);
|
||||
opacity: 0.2;
|
||||
pointer-events: none;
|
||||
}
|
||||
.spacing-readout {
|
||||
position: fixed;
|
||||
transform: translate(-50%, -140%);
|
||||
padding: 1px 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: var(--color-accent);
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
Reference in new issue
Block a user