+ {drag && drag.sides.map((s) => (
+
+ ))}
+ {ALL_SIDES.map((s) => (
+
startDrag(s, e)}
+ onMouseDown={(e) => { e.stopPropagation(); }}
+ />
+ ))}
+ {drag && (
+
{drag.value}px
+ )}
+
+ );
+};
diff --git a/craft/src/editor/spacing-drag.test.ts b/craft/src/editor/spacing-drag.test.ts
new file mode 100644
index 0000000..d7c7264
--- /dev/null
+++ b/craft/src/editor/spacing-drag.test.ts
@@ -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);
+ });
+});
diff --git a/craft/src/editor/spacing-drag.ts b/craft/src/editor/spacing-drag.ts
new file mode 100644
index 0000000..0df1cb9
--- /dev/null
+++ b/craft/src/editor/spacing-drag.ts
@@ -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
= {
+ top: 'paddingTop',
+ bottom: 'paddingBottom',
+ left: 'paddingLeft',
+ right: 'paddingRight',
+};
+export const ALL_SIDES: readonly PaddingSide[] = ['top', 'bottom', 'left', 'right'];
+const OPPOSITE: Record = { 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>;
+}
+
+/** 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> = {};
+ 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): 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, 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;
+}
diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css
index 4dc56d3..89e4667 100644
--- a/craft/src/styles/editor.css
+++ b/craft/src/styles/editor.css
@@ -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;
+}