diff --git a/craft/src/editor/CanvasOverlays.tsx b/craft/src/editor/CanvasOverlays.tsx index f379427..810feeb 100644 --- a/craft/src/editor/CanvasOverlays.tsx +++ b/craft/src/editor/CanvasOverlays.tsx @@ -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 = () => ( + ); diff --git a/craft/src/editor/SpacingHandles.test.tsx b/craft/src/editor/SpacingHandles.test.tsx new file mode 100644 index 0000000..15676bb --- /dev/null +++ b/craft/src/editor/SpacingHandles.test.tsx @@ -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, device: DeviceMode = 'desktop') { + h = renderEditorHarness({ wrap: (tree) => {tree} }); + h.mountChild(); + 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 = {}) => + 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 = {}, 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'); + }); +}); diff --git a/craft/src/editor/SpacingHandles.tsx b/craft/src/editor/SpacingHandles.tsx new file mode 100644 index 0000000..70dedee --- /dev/null +++ b/craft/src/editor/SpacingHandles.tsx @@ -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; +} + +const SIDE_AXIS: Record = { 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(null); + const [drag, setDrag] = useState(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; + 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 = 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(); + 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> = { ...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 ( +
+ {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; +}