diff --git a/craft/src/App.tsx b/craft/src/App.tsx index c74659c..95563cf 100644 --- a/craft/src/App.tsx +++ b/craft/src/App.tsx @@ -30,7 +30,20 @@ const SitesmithModalMount: React.FC = () => { export const SaveAwareEditor: React.FC<{ children: React.ReactNode }> = ({ children }) => { const { markDirty } = useSaveGuard(); return ( - + {children} ); diff --git a/craft/src/editor/CanvasOverlays.tsx b/craft/src/editor/CanvasOverlays.tsx index 6e255b2..fc0dc8e 100644 --- a/craft/src/editor/CanvasOverlays.tsx +++ b/craft/src/editor/CanvasOverlays.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { NoticeHost } from './NoticeHost'; import { EmptyContainerAdd } from './EmptyContainerAdd'; +import { DropIndicator } from './DropIndicator'; /** * Single mount point for editor-wide overlays rendered next to the shell. @@ -11,5 +12,6 @@ export const CanvasOverlays: React.FC = () => ( <> + ); diff --git a/craft/src/editor/DropIndicator.test.tsx b/craft/src/editor/DropIndicator.test.tsx new file mode 100644 index 0000000..2a0b334 --- /dev/null +++ b/craft/src/editor/DropIndicator.test.tsx @@ -0,0 +1,128 @@ +import { describe, test, expect, afterEach } from 'vitest'; +import React from 'react'; +// Vitest stubs CSS imports (`?raw` on a .css reads back empty), so read the real file +// with Node's fs (no @types/node in this project, hence the untyped builtin lookup). +const nodeFs = (globalThis as any).process.getBuiltinModule('node:fs'); +const cssText: string = nodeFs.readFileSync(`${(globalThis as any).process.cwd()}/src/styles/editor.css`, 'utf8'); +import { useEditor } from '@craftjs/core'; +import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness'; +import { Container } from '../components/layout/Container'; +import { DropIndicator, applyDropTarget, clearDropTarget } from './DropIndicator'; + +let h: EditorHarness; +let setDragged: (ids: string[]) => void; + +// `setNodeEvent` is not on the public useEditor() actions; useEditor().store exposes it +// (it is the same action Craft's own drag handlers call). +const StoreProbe: React.FC = () => { + const { store } = useEditor() as any; + setDragged = (ids) => store.actions.setNodeEvent('dragged', ids); + return null; +}; + +let app: HTMLDivElement; + +afterEach(() => { + h?.unmount(); + app?.remove(); +}); + +/** Mount harness with an `.editor-app` ancestor target and two nested containers: + * ROOT > outer(has child) > inner(empty). Returns ids. */ +function setup() { + app = document.createElement('div'); + app.className = 'editor-app'; + document.body.appendChild(app); + h = renderEditorHarness(); + const { query, actions } = h; + let outerId = ''; + let innerId = ''; + h.act(() => { + const tree = query.parseReactElement().toNodeTree(); + outerId = tree.rootNodeId; + innerId = tree.nodes[outerId].data.nodes[0]; + actions.addNodeTree(tree, 'ROOT'); + }); + h.mountChild(<>); + // jsdom Frame may not connect every node's dom; attach a detached one if missing + [outerId, innerId].forEach((id) => { + if (!query.node(id).get().dom) h.act(() => actions.setDOM(id, document.createElement('div'))); + }); + return { outerId, innerId }; +} + +const place = (h: EditorHarness, parentId: string, error: string | null = null) => + h.act(() => + h.actions.setIndicator({ + placement: { parent: h.query.node(parentId).get(), index: 0, where: 'before', currentNode: null }, + error, + }), + ); + +describe('DropIndicator', () => { + test('marks the target dom and empty state, then clears on setIndicator(null)', () => { + const { outerId, innerId } = setup(); + h.act(() => setDragged([innerId])); + + place(h, innerId); + const innerDom = h.query.node(innerId).get().dom as HTMLElement; + expect(innerDom.hasAttribute('data-drop-target')).toBe(true); + expect(innerDom.hasAttribute('data-drop-empty')).toBe(true); + expect(innerDom.hasAttribute('data-drop-error')).toBe(false); + + // moving to the non-empty outer container moves the marker + place(h, outerId); + const outerDom = h.query.node(outerId).get().dom as HTMLElement; + expect(outerDom.hasAttribute('data-drop-target')).toBe(true); + expect(outerDom.hasAttribute('data-drop-empty')).toBe(false); + expect(innerDom.hasAttribute('data-drop-target')).toBe(false); + expect(innerDom.hasAttribute('data-drop-empty')).toBe(false); + + h.act(() => h.actions.setIndicator(null)); + expect(outerDom.hasAttribute('data-drop-target')).toBe(false); + }); + + test('error placement sets data-drop-error', () => { + const { outerId, innerId } = setup(); + h.act(() => setDragged([innerId])); + place(h, outerId, 'Cannot drop here'); + expect((h.query.node(outerId).get().dom as HTMLElement).hasAttribute('data-drop-error')).toBe(true); + }); + + test('is-dragging toggles on .editor-app with the dragged event', () => { + const { innerId } = setup(); + expect(app.classList.contains('is-dragging')).toBe(false); + h.act(() => setDragged([innerId])); + expect(app.classList.contains('is-dragging')).toBe(true); + h.act(() => setDragged([])); + expect(app.classList.contains('is-dragging')).toBe(false); + }); + + test('applyDropTarget / clearDropTarget are pure DOM helpers', () => { + const el = document.createElement('div'); + applyDropTarget(el, { error: true, empty: true }); + expect(el.hasAttribute('data-drop-error') && el.hasAttribute('data-drop-empty')).toBe(true); + applyDropTarget(el, { error: false, empty: false }); + expect(el.hasAttribute('data-drop-target')).toBe(true); + expect(el.hasAttribute('data-drop-error')).toBe(false); + clearDropTarget(el); + expect(el.hasAttribute('data-drop-target')).toBe(false); + expect(() => applyDropTarget(null, { error: false, empty: false })).not.toThrow(); + }); +}); + +describe('drop indicator CSS (#43)', () => { + test.each([ + '.is-dragging .canvas-device-frame [data-drop-target]', + '[data-drop-target][data-drop-error]', + "[data-drop-empty]::after", + "content: 'Drop here'", + '.wsb-drop-line', + '.editor-app.is-dragging [data-craft-hovered]', + ])('contains %s', (needle) => { + expect(cssText).toContain(needle); + }); + test('dead .drop-indicator rules are gone', () => { + expect(cssText).not.toMatch(/\.drop-indicator\b/); + }); +}); diff --git a/craft/src/editor/DropIndicator.tsx b/craft/src/editor/DropIndicator.tsx new file mode 100644 index 0000000..4df4f69 --- /dev/null +++ b/craft/src/editor/DropIndicator.tsx @@ -0,0 +1,62 @@ +import { useEffect, useRef } from 'react'; +import { useEditor } from '@craftjs/core'; + +/** + * Drag-and-drop visual cues (#43). Craft draws the insertion line itself (restyled + * via the `indicator` prop in App.tsx); this component mirrors the live + * placement from the store onto the DOM so CSS can glow the receiving container + * and label empty ones. Purely visual: nothing here touches the node tree. + */ + +const APP_SELECTOR = '.editor-app'; + +/** Set/clear the target attributes on `el`. Pure DOM, exported for tests. */ +export function applyDropTarget( + el: HTMLElement | null | undefined, + state: { error: boolean; empty: boolean }, +): void { + if (!el) return; + el.setAttribute('data-drop-target', ''); + if (state.error) el.setAttribute('data-drop-error', ''); else el.removeAttribute('data-drop-error'); + if (state.empty) el.setAttribute('data-drop-empty', ''); else el.removeAttribute('data-drop-empty'); +} + +export function clearDropTarget(el: HTMLElement | null | undefined): void { + if (!el) return; + el.removeAttribute('data-drop-target'); + el.removeAttribute('data-drop-error'); + el.removeAttribute('data-drop-empty'); +} + +export const DropIndicator: React.FC = () => { + // Primitives only: state.indicator changes on every mousemove while dragging. + const { dragging, targetId, error, targetEmpty, targetDom } = useEditor((state) => { + const parent = state.indicator?.placement?.parent; + return { + dragging: state.events.dragged.size > 0, + targetId: (parent?.id as string | undefined) ?? null, + error: !!state.indicator?.error, + targetEmpty: parent ? (parent.data.nodes?.length ?? 0) === 0 : false, + targetDom: (parent?.dom as HTMLElement | null | undefined) ?? null, + }; + }); + + useEffect(() => { + const app = document.querySelector(APP_SELECTOR); + if (!app) return; + app.classList.toggle('is-dragging', dragging); + return () => app.classList.remove('is-dragging'); + }, [dragging]); + + const prev = useRef(null); + useEffect(() => { + const el = dragging && targetId ? targetDom : null; + if (prev.current && prev.current !== el) clearDropTarget(prev.current); + if (el) applyDropTarget(el, { error, empty: targetEmpty }); + prev.current = el; + }, [dragging, targetId, targetDom, error, targetEmpty]); + + useEffect(() => () => clearDropTarget(prev.current), []); + + return null; +}; diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css index bca418d..70ce5ed 100644 --- a/craft/src/styles/editor.css +++ b/craft/src/styles/editor.css @@ -1313,21 +1313,50 @@ body { /* -------------------------------------------------------------------------- Drag & Drop Visual Cues -------------------------------------------------------------------------- */ -.drop-indicator { +.editor-app.is-dragging .canvas-device-frame [data-drop-target] { + outline: 2px solid var(--color-accent) !important; + outline-offset: -2px; + background: var(--color-accent-subtle); +} + +.editor-app.is-dragging .canvas-device-frame [data-drop-target][data-drop-error] { + outline-color: var(--color-danger) !important; + background: rgba(239, 68, 68, 0.12); +} + +.editor-app.is-dragging .canvas-device-frame [data-drop-empty] { position: relative; } -.drop-indicator::before { - content: ''; +.editor-app.is-dragging .canvas-device-frame [data-drop-empty]::after { + content: 'Drop here'; position: absolute; inset: 0; - border: 2px dashed var(--color-accent); - border-radius: var(--radius-sm); - background: var(--color-accent-subtle); + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; + color: var(--color-accent); pointer-events: none; - z-index: 5; } +.editor-app.is-dragging .canvas-device-frame [data-drop-error][data-drop-empty]::after { + color: var(--color-danger); +} + +/* Craft's insertion bar (restyled via the Editor `indicator` prop) above content */ +.wsb-drop-line { + z-index: 50; +} + +/* While dragging only the drop target glows */ +.editor-app.is-dragging [data-craft-hovered] { + outline: none !important; +} + +/* #43 */ + /* -------------------------------------------------------------------------- Empty Canvas State -------------------------------------------------------------------------- */