Merge issue-43 (Refs #43)

# Conflicts:
#	craft/src/editor/CanvasOverlays.tsx
This commit is contained in:
shadowdao committed 2026-10-07 11:41:22 -07:00
commit d756a723de
5 files changed
+242 -8

No files matched your search

+14 -1
View File
@@ -30,7 +30,20 @@ const SitesmithModalMount: React.FC = () => {
export const SaveAwareEditor: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { markDirty } = useSaveGuard();
return (
<Editor resolver={componentResolver} enabled={true} onRender={RenderNode} onNodesChange={markDirty}>
<Editor
resolver={componentResolver}
enabled={true}
onRender={RenderNode}
onNodesChange={markDirty}
indicator={{
className: 'wsb-drop-line',
thickness: 3,
success: 'var(--color-accent)',
error: 'var(--color-danger)',
transition: '0.08s ease-out',
style: { borderRadius: 2, boxShadow: '0 0 0 1px rgba(0,0,0,.25)' },
}}
>
{children}
</Editor>
);
+2
View File
@@ -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 = () => (
<>
<NoticeHost />
<EmptyContainerAdd />
<DropIndicator />
</>
);
+128
View File
@@ -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(<Container><Container /></Container>).toNodeTree();
outerId = tree.rootNodeId;
innerId = tree.nodes[outerId].data.nodes[0];
actions.addNodeTree(tree, 'ROOT');
});
h.mountChild(<><StoreProbe /><DropIndicator /></>);
// 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/);
});
});
+62
View File
@@ -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<HTMLElement | null>(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;
};
+36 -7
View File
@@ -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
-------------------------------------------------------------------------- */