Merge issue-43 (Refs #43)
# Conflicts: # craft/src/editor/CanvasOverlays.tsx
This commit is contained in:
commit
d756a723de
5 files changed
+242
-8
No files matched your search
+14
-1
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
Reference in new issue
Block a user