# Conflicts: # craft/src/styles/editor.css
This commit is contained in:
commit
ae871c9cdc
11 files changed
+1004
No files matched your search
@@ -0,0 +1,200 @@
|
||||
import { describe, test, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness';
|
||||
import { buildNodeTree } from '../utils/craft-tree';
|
||||
import {
|
||||
registerCommand,
|
||||
registerCommands,
|
||||
getCommands,
|
||||
getCommand,
|
||||
subscribeCommands,
|
||||
useCommandContext,
|
||||
useCommandShortcuts,
|
||||
useRunCommand,
|
||||
useRegisterCommands,
|
||||
__resetCommandsForTests,
|
||||
type CommandContext,
|
||||
type Command,
|
||||
} from './commands';
|
||||
|
||||
let h: EditorHarness;
|
||||
let ctxRef: { current: CommandContext | null };
|
||||
let runRef: { current: ((id: string) => Promise<boolean>) | null };
|
||||
|
||||
const Probe: React.FC = () => {
|
||||
useCommandShortcuts();
|
||||
ctxRef.current = useCommandContext();
|
||||
runRef.current = useRunCommand();
|
||||
return null;
|
||||
};
|
||||
|
||||
function key(init: KeyboardEventInit, target: EventTarget = document.body) {
|
||||
const e = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
|
||||
h.act(() => {
|
||||
target.dispatchEvent(e);
|
||||
});
|
||||
return e;
|
||||
}
|
||||
|
||||
const cmd = (over: Partial<Command> & { id: string }): Command => ({
|
||||
title: over.id,
|
||||
group: 'edit',
|
||||
run: () => {},
|
||||
...over,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
__resetCommandsForTests();
|
||||
ctxRef = { current: null };
|
||||
runRef = { current: null };
|
||||
h = renderEditorHarness();
|
||||
h.mountChild(<Probe />);
|
||||
});
|
||||
afterEach(() => {
|
||||
h.unmount();
|
||||
__resetCommandsForTests();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('registry', () => {
|
||||
test('order, replace by id, unregister, subscribe', () => {
|
||||
const cb = vi.fn();
|
||||
const off = subscribeCommands(cb);
|
||||
const offA = registerCommand(cmd({ id: 'a' }));
|
||||
registerCommands([cmd({ id: 'b' }), cmd({ id: 'c' })]);
|
||||
expect(getCommands().map((c) => c.id)).toEqual(['a', 'b', 'c']);
|
||||
expect(getCommands()).toBe(getCommands()); // stable snapshot
|
||||
const a2 = cmd({ id: 'a', title: 'A2' });
|
||||
registerCommand(a2);
|
||||
expect(getCommand('a')).toBe(a2);
|
||||
expect(getCommands().map((c) => c.id)).toEqual(['a', 'b', 'c']);
|
||||
offA(); // stale unregister must not remove the replacement
|
||||
expect(getCommand('a')).toBe(a2);
|
||||
expect(cb).toHaveBeenCalled();
|
||||
off();
|
||||
});
|
||||
|
||||
test('useRegisterCommands registers on mount and unregisters on unmount', () => {
|
||||
const Feature: React.FC = () => {
|
||||
useRegisterCommands([cmd({ id: 'feat' })], []);
|
||||
return null;
|
||||
};
|
||||
h.mountChild(<><Probe /><Feature /></>);
|
||||
expect(getCommand('feat')).toBeDefined();
|
||||
h.mountChild(<Probe />);
|
||||
expect(getCommand('feat')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCommandShortcuts', () => {
|
||||
test('mod+d runs the matching command once, preventDefault called', () => {
|
||||
const dup = vi.fn();
|
||||
const other = vi.fn();
|
||||
registerCommands([
|
||||
cmd({ id: 'edit.other', shortcut: 'mod+e', run: other }),
|
||||
cmd({ id: 'edit.duplicate', shortcut: 'mod+d', run: dup }),
|
||||
]);
|
||||
const e = key({ key: 'd', ctrlKey: true });
|
||||
expect(dup).toHaveBeenCalledTimes(1);
|
||||
expect(other).not.toHaveBeenCalled();
|
||||
expect(e.defaultPrevented).toBe(true);
|
||||
expect(dup.mock.calls[0][0].query).toBeDefined();
|
||||
});
|
||||
|
||||
test('not run when a focused input is the target', () => {
|
||||
const run = vi.fn();
|
||||
registerCommand(cmd({ id: 'x', shortcut: 'mod+d', run }));
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
const e = key({ key: 'd', ctrlKey: true }, input);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
expect(e.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
test('allowWhileTyping commands still fire in an input', () => {
|
||||
const run = vi.fn();
|
||||
registerCommand(cmd({ id: 'x', shortcut: 'mod+k', allowWhileTyping: true, run }));
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
key({ key: 'k', ctrlKey: true }, input);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('when() false: not run, event not prevented, next match may run', () => {
|
||||
const blocked = vi.fn();
|
||||
const fallback = vi.fn();
|
||||
registerCommands([
|
||||
cmd({ id: 'blocked', shortcut: 'mod+d', when: () => false, run: blocked }),
|
||||
cmd({ id: 'fallback', shortcut: 'mod+d', run: fallback }),
|
||||
]);
|
||||
const e = key({ key: 'd', ctrlKey: true });
|
||||
expect(blocked).not.toHaveBeenCalled();
|
||||
expect(fallback).toHaveBeenCalledTimes(1);
|
||||
expect(e.defaultPrevented).toBe(true);
|
||||
|
||||
__resetCommandsForTests();
|
||||
registerCommand(cmd({ id: 'only', shortcut: 'mod+d', when: () => false, run: blocked }));
|
||||
const e2 = key({ key: 'd', ctrlKey: true });
|
||||
expect(blocked).not.toHaveBeenCalled();
|
||||
expect(e2.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
test('unregister stops it', () => {
|
||||
const run = vi.fn();
|
||||
const off = registerCommand(cmd({ id: 'x', shortcut: 'mod+d', run }));
|
||||
key({ key: 'd', ctrlKey: true });
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
off();
|
||||
const e = key({ key: 'd', ctrlKey: true });
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(e.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
test('run sees the LIVE selection even before the collector re-renders', () => {
|
||||
const tree = buildNodeTree(h.query, { type: { resolvedName: 'Container' }, props: { node_id: 'n1' }, nodes: [] } as any);
|
||||
h.act(() => h.actions.addNodeTree(tree, 'ROOT'));
|
||||
let seen: string[] = [];
|
||||
registerCommand(cmd({ id: 'x', shortcut: 'mod+d', run: (c) => { seen = c.selectedIds; } }));
|
||||
// Same act() batch: React has not re-rendered, so only a live read can see it.
|
||||
h.act(() => {
|
||||
h.actions.selectNode('n1');
|
||||
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', ctrlKey: true, bubbles: true, cancelable: true }));
|
||||
});
|
||||
expect(seen).toEqual(['n1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCommandContext / useRunCommand', () => {
|
||||
test('selectedIds reflects actions.selectNode', () => {
|
||||
const tree = buildNodeTree(h.query, { type: { resolvedName: 'Container' }, props: { node_id: 'n1' }, nodes: [] } as any);
|
||||
h.act(() => h.actions.addNodeTree(tree, 'ROOT'));
|
||||
expect(ctxRef.current!.selectedIds).toEqual([]);
|
||||
h.act(() => h.actions.selectNode('n1'));
|
||||
expect(ctxRef.current!.selectedIds).toEqual(['n1']);
|
||||
h.act(() => h.actions.clearEvents());
|
||||
expect(ctxRef.current!.selectedIds).toEqual([]);
|
||||
});
|
||||
|
||||
test('exposes the composed contexts', () => {
|
||||
const c = ctxRef.current!;
|
||||
expect(c.device).toBe('desktop');
|
||||
for (const fn of ['setDevice', 'notify', 'openSitesmith', 'openTemplates', 'openHeadCode'] as const) {
|
||||
expect(typeof c[fn]).toBe('function');
|
||||
}
|
||||
expect(c.pages).toBeDefined();
|
||||
});
|
||||
|
||||
test('useRunCommand: runs by id; false when missing or when() fails', async () => {
|
||||
const run = vi.fn();
|
||||
registerCommands([
|
||||
cmd({ id: 'ok', run }),
|
||||
cmd({ id: 'off', when: () => false, run }),
|
||||
]);
|
||||
expect(await runRef.current!('ok')).toBe(true);
|
||||
expect(await runRef.current!('off')).toBe(false);
|
||||
expect(await runRef.current!('missing')).toBe(false);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import type { DeviceMode } from '../types';
|
||||
import { useDevice } from '../state/DeviceContext';
|
||||
import { usePages } from '../state/PageContext';
|
||||
import { useSitesmithModal } from '../state/SitesmithContext';
|
||||
import { useNotices } from '../state/NoticeContext';
|
||||
import { useMobileChrome } from '../state/MobileChromeContext';
|
||||
import { isTypingTarget, matchShortcut } from '../utils/shortcuts';
|
||||
|
||||
/**
|
||||
* Command registry: one place where features register what they can do, shared
|
||||
* by the keyboard layer, the Cmd+K palette, the top-bar "More" menu and the
|
||||
* breadcrumb. Module-level (like hooks/clipboard.ts); features register from an
|
||||
* effect via `useRegisterCommands` and never edit TopBar / useKeyboardShortcuts.
|
||||
*/
|
||||
export type CommandGroup = 'edit' | 'select' | 'view' | 'insert' | 'page' | 'site' | 'help';
|
||||
|
||||
export interface CommandContext {
|
||||
query: any;
|
||||
actions: any;
|
||||
selectedIds: string[];
|
||||
device: DeviceMode;
|
||||
setDevice(d: DeviceMode): void;
|
||||
pages: ReturnType<typeof usePages>;
|
||||
notify: ReturnType<typeof useNotices>['notify'];
|
||||
openSitesmith(target?: any): void;
|
||||
openTemplates(): void;
|
||||
openHeadCode(): void;
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
id: string;
|
||||
title: string;
|
||||
group: CommandGroup;
|
||||
keywords?: string[];
|
||||
/** Shortcut spec string, see utils/shortcuts.ts ('mod+d', 'alt+arrowup'). */
|
||||
shortcut?: string;
|
||||
when?(ctx: CommandContext): boolean;
|
||||
run(ctx: CommandContext): void | Promise<void>;
|
||||
/** Not shown in the palette / menu (still runnable by shortcut and id). */
|
||||
hidden?: boolean;
|
||||
/** Fire the shortcut even while an input / contentEditable is focused. */
|
||||
allowWhileTyping?: boolean;
|
||||
}
|
||||
|
||||
const registry = new Map<string, Command>();
|
||||
const listeners = new Set<() => void>();
|
||||
let snapshot: Command[] | null = null;
|
||||
|
||||
function changed() {
|
||||
snapshot = null;
|
||||
listeners.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
export function registerCommand(cmd: Command): () => void {
|
||||
registry.set(cmd.id, cmd);
|
||||
changed();
|
||||
return () => {
|
||||
// Only remove our own registration; a later re-register of the same id wins.
|
||||
if (registry.get(cmd.id) === cmd) {
|
||||
registry.delete(cmd.id);
|
||||
changed();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function registerCommands(cmds: Command[]): () => void {
|
||||
const offs = cmds.map(registerCommand);
|
||||
return () => offs.forEach((off) => off());
|
||||
}
|
||||
|
||||
/** Stable registration order; the same array instance until the registry changes. */
|
||||
export function getCommands(): Command[] {
|
||||
if (!snapshot) snapshot = Array.from(registry.values());
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function getCommand(id: string): Command | undefined {
|
||||
return registry.get(id);
|
||||
}
|
||||
|
||||
export function subscribeCommands(cb: () => void): () => void {
|
||||
listeners.add(cb);
|
||||
return () => {
|
||||
listeners.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
export function __resetCommandsForTests(): void {
|
||||
registry.clear();
|
||||
listeners.clear();
|
||||
snapshot = null;
|
||||
}
|
||||
|
||||
export function useCommandContext(): CommandContext {
|
||||
// Collect a string key (not an array) so the hook only re-renders when the
|
||||
// selection actually changes.
|
||||
const { actions, query, selKey } = useEditor((state) => ({
|
||||
selKey: Array.from(state.events.selected || []).join('\n'),
|
||||
}));
|
||||
const { device, setDevice } = useDevice();
|
||||
const pages = usePages();
|
||||
const { notify } = useNotices();
|
||||
const { open: openSitesmith } = useSitesmithModal();
|
||||
const { setTemplateModalOpen, setHeadCodeModalOpen } = useMobileChrome();
|
||||
|
||||
return useMemo<CommandContext>(
|
||||
() => ({
|
||||
query,
|
||||
actions,
|
||||
selectedIds: selKey ? selKey.split('\n') : [],
|
||||
device,
|
||||
setDevice,
|
||||
pages,
|
||||
notify,
|
||||
openSitesmith,
|
||||
openTemplates: () => setTemplateModalOpen(true),
|
||||
openHeadCode: () => setHeadCodeModalOpen(true),
|
||||
}),
|
||||
[query, actions, selKey, device, setDevice, pages, notify, openSitesmith, setTemplateModalOpen, setHeadCodeModalOpen],
|
||||
);
|
||||
}
|
||||
|
||||
/** Live selection read; collectors are snapshots and can lag a keydown. */
|
||||
function liveContext(ctx: CommandContext): CommandContext {
|
||||
try {
|
||||
return { ...ctx, selectedIds: ctx.query.getEvent('selected').all() };
|
||||
} catch {
|
||||
return ctx;
|
||||
}
|
||||
}
|
||||
|
||||
function isEnabled(cmd: Command, ctx: CommandContext): boolean {
|
||||
try {
|
||||
return cmd.when ? !!cmd.when(ctx) : true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function useRunCommand(): (id: string) => Promise<boolean> {
|
||||
const ctx = useCommandContext();
|
||||
const ref = useRef(ctx);
|
||||
ref.current = ctx;
|
||||
return useCallback(async (id: string) => {
|
||||
const cmd = getCommand(id);
|
||||
if (!cmd) return false;
|
||||
const c = liveContext(ref.current);
|
||||
if (!isEnabled(cmd, c)) return false;
|
||||
try {
|
||||
await cmd.run(c);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`Command ${id} failed:`, err);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Document-level keydown dispatcher: first registered command whose shortcut
|
||||
* matches and whose `when()` passes runs, with preventDefault. NOT mounted by
|
||||
* W0-commands -- issue #39 mounts it (and retires useKeyboardShortcuts) so two
|
||||
* listeners never double-fire.
|
||||
*/
|
||||
export function useCommandShortcuts(): void {
|
||||
const ctx = useCommandContext();
|
||||
const ref = useRef(ctx);
|
||||
ref.current = ctx;
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const typing = isTypingTarget(document.activeElement);
|
||||
const c = liveContext(ref.current);
|
||||
for (const cmd of getCommands()) {
|
||||
if (!cmd.shortcut || !matchShortcut(e, cmd.shortcut)) continue;
|
||||
if (typing && !cmd.allowWhileTyping) continue;
|
||||
if (!isEnabled(cmd, c)) continue;
|
||||
e.preventDefault();
|
||||
try {
|
||||
Promise.resolve(cmd.run(c)).catch((err) => console.error(`Command ${cmd.id} failed:`, err));
|
||||
} catch (err) {
|
||||
console.error(`Command ${cmd.id} failed:`, err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/** Register commands for the lifetime of a component; re-registers when `deps` change. */
|
||||
export function useRegisterCommands(cmds: Command[], deps: unknown[]): void {
|
||||
useEffect(() => registerCommands(cmds), deps); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, test, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness';
|
||||
import { registerCommands, __resetCommandsForTests } from '../../editor/commands';
|
||||
import { TopBarCommandMenu } from './TopBarCommandMenu';
|
||||
|
||||
let h: EditorHarness;
|
||||
const run = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
__resetCommandsForTests();
|
||||
run.mockReset();
|
||||
registerCommands([
|
||||
{ id: 'site.download', title: 'Download site', group: 'site', shortcut: 'mod+shift+e', run },
|
||||
{ id: 'site.disabled', title: 'Disabled thing', group: 'site', when: () => false, run },
|
||||
{ id: 'site.hidden', title: 'Hidden thing', group: 'site', hidden: true, run },
|
||||
{ id: 'edit.duplicate', title: 'Duplicate', group: 'edit', run },
|
||||
]);
|
||||
h = renderEditorHarness();
|
||||
h.mountChild(<TopBarCommandMenu />);
|
||||
});
|
||||
afterEach(() => {
|
||||
h.unmount();
|
||||
__resetCommandsForTests();
|
||||
});
|
||||
|
||||
const button = () => h.container.querySelector('.topbar-command-btn') as HTMLButtonElement;
|
||||
const items = () => Array.from(h.container.querySelectorAll('.topbar-command-item')) as HTMLButtonElement[];
|
||||
|
||||
describe('TopBarCommandMenu', () => {
|
||||
test('lists only visible site commands, with shortcut labels and disabled state', () => {
|
||||
expect(items()).toHaveLength(0);
|
||||
h.act(() => button().click());
|
||||
const titles = items().map((i) => i.querySelector('.topbar-command-title')!.textContent);
|
||||
expect(titles).toEqual(['Download site', 'Disabled thing']);
|
||||
expect(items()[0].querySelector('.topbar-command-shortcut')!.textContent).toMatch(/Shift\+E$|⇧E$/);
|
||||
expect(items()[0].disabled).toBe(false);
|
||||
expect(items()[1].disabled).toBe(true);
|
||||
});
|
||||
|
||||
test('click runs the command and closes the menu', async () => {
|
||||
h.act(() => button().click());
|
||||
await h.act(async () => {
|
||||
items()[0].click();
|
||||
});
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(items()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('disabled item does not run', async () => {
|
||||
h.act(() => button().click());
|
||||
await h.act(async () => {
|
||||
items()[1].click();
|
||||
});
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Escape closes; outside mousedown closes', () => {
|
||||
h.act(() => button().click());
|
||||
expect(items().length).toBeGreaterThan(0);
|
||||
h.act(() => {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
});
|
||||
expect(items()).toHaveLength(0);
|
||||
h.act(() => button().click());
|
||||
h.act(() => {
|
||||
document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
});
|
||||
expect(items()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('late registration shows up without reopening', () => {
|
||||
h.act(() => button().click());
|
||||
h.act(() => {
|
||||
registerCommands([{ id: 'site.late', title: 'Late one', group: 'site', run }]);
|
||||
});
|
||||
expect(items().map((i) => i.textContent)).toContain('Late one');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import React, { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { getCommands, subscribeCommands, useCommandContext, useRunCommand } from '../../editor/commands';
|
||||
import { formatShortcut } from '../../utils/shortcuts';
|
||||
|
||||
/**
|
||||
* Top-bar "⋯" menu listing every registered `site` command. Features add their
|
||||
* entry with `registerCommand({group: 'site', ...})` instead of editing the
|
||||
* TopBar. Closes on Escape / outside click (same pattern as TopBarOverflowMenu).
|
||||
*/
|
||||
export const TopBarCommandMenu: React.FC = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const commands = useSyncExternalStore(subscribeCommands, getCommands, getCommands);
|
||||
const ctx = useCommandContext();
|
||||
const run = useRunCommand();
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) close();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') close();
|
||||
};
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open, close]);
|
||||
|
||||
const items = commands.filter((c) => c.group === 'site' && !c.hidden);
|
||||
|
||||
return (
|
||||
<div className="topbar-command-menu" ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="topbar-command-btn"
|
||||
aria-label="More actions"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title="More actions"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<i className="fa fa-ellipsis-h" aria-hidden="true" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="topbar-command-dropdown" role="menu">
|
||||
{items.length === 0 && <div className="topbar-command-empty">No actions available</div>}
|
||||
{items.map((c) => {
|
||||
let enabled = true;
|
||||
try {
|
||||
enabled = c.when ? !!c.when(ctx) : true;
|
||||
} catch {
|
||||
enabled = false;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="topbar-command-item"
|
||||
disabled={!enabled}
|
||||
onClick={() => {
|
||||
close();
|
||||
void run(c.id);
|
||||
}}
|
||||
>
|
||||
<span className="topbar-command-title">{c.title}</span>
|
||||
{c.shortcut && <span className="topbar-command-shortcut">{formatShortcut(c.shortcut)}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import React, { createContext, useContext, useMemo, useState } from 'react';
|
||||
import type { DeviceMode } from '../types';
|
||||
|
||||
/**
|
||||
* MINIMAL STUB added by W0-commands so the command registry can code against
|
||||
* the `{device, setDevice}` interface. W0-breakpoints owns the real
|
||||
* implementation of this file; on merge, take theirs (the API is the same).
|
||||
*/
|
||||
interface DeviceContextValue {
|
||||
device: DeviceMode;
|
||||
setDevice: (d: DeviceMode) => void;
|
||||
}
|
||||
|
||||
const DeviceCtx = createContext<DeviceContextValue>({ device: 'desktop', setDevice: () => {} });
|
||||
|
||||
export const useDevice = () => useContext(DeviceCtx);
|
||||
|
||||
export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [device, setDevice] = useState<DeviceMode>('desktop');
|
||||
const value = useMemo(() => ({ device, setDevice }), [device]);
|
||||
return <DeviceCtx.Provider value={value}>{children}</DeviceCtx.Provider>;
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
/**
|
||||
* MINIMAL STUB added by W0-commands so the command registry can code against
|
||||
* the `useNotices().notify` interface. W0-registry-notices owns the real
|
||||
* implementation (provider, host, timeouts); on merge, take theirs. The
|
||||
* default (no provider) is a console-only no-op, matching their brief.
|
||||
*/
|
||||
export interface Notice {
|
||||
id?: string;
|
||||
kind: 'info' | 'success' | 'error';
|
||||
message: string;
|
||||
actionLabel?: string;
|
||||
onAction?(): void;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface NoticeContextValue {
|
||||
notify(n: Notice): string;
|
||||
dismiss(id: string): void;
|
||||
notices: Array<Notice & { id: string }>;
|
||||
}
|
||||
|
||||
const NoticeCtx = createContext<NoticeContextValue>({
|
||||
notify: (n) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[notice:${n.kind}] ${n.message}`);
|
||||
return n.id ?? '';
|
||||
},
|
||||
dismiss: () => {},
|
||||
notices: [],
|
||||
});
|
||||
|
||||
export const useNotices = () => useContext(NoticeCtx);
|
||||
@@ -2100,3 +2100,46 @@ body {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
/* W0-commands: topbar command menu */
|
||||
.topbar-command-menu { position: relative; display: inline-flex; }
|
||||
.topbar-command-btn {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: inherit;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.topbar-command-btn:hover, .topbar-command-btn[aria-expanded="true"] { border-color: var(--color-border); }
|
||||
.topbar-command-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: var(--z-overflow, 1000);
|
||||
min-width: 240px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.topbar-command-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-md, 6px);
|
||||
cursor: pointer;
|
||||
}
|
||||
.topbar-command-item:hover:not(:disabled) { background: var(--color-bg-hover); }
|
||||
.topbar-command-item:disabled { opacity: 0.45; cursor: default; }
|
||||
.topbar-command-shortcut { font-size: 12px; opacity: 0.6; }
|
||||
.topbar-command-empty { padding: 8px 10px; font-size: 12px; opacity: 0.6; }
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, test, expect, afterEach, beforeEach } from 'vitest';
|
||||
import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness';
|
||||
import { buildNodeTree } from './craft-tree';
|
||||
import {
|
||||
getParentId,
|
||||
getChildIds,
|
||||
getSiblingIds,
|
||||
getAncestorIds,
|
||||
getIndexInParent,
|
||||
getNodeLabel,
|
||||
} from './craft-helpers';
|
||||
|
||||
let h: EditorHarness;
|
||||
|
||||
// ROOT > sec (Section) > [section-inner] > cols (ColumnLayout) > [col-0 = c1 > btn, col-1 = c2]
|
||||
beforeEach(() => {
|
||||
h = renderEditorHarness();
|
||||
const tree = buildNodeTree(h.query, {
|
||||
type: { resolvedName: 'Section' },
|
||||
props: { node_id: 'sec' },
|
||||
nodes: [
|
||||
{
|
||||
type: { resolvedName: 'ColumnLayout' },
|
||||
props: { node_id: 'cols', columns: 2 },
|
||||
nodes: [
|
||||
{
|
||||
type: { resolvedName: 'Container' },
|
||||
props: { node_id: 'c1' },
|
||||
nodes: [{ type: { resolvedName: 'ButtonLink' }, props: { node_id: 'btn', aiName: 'Hero CTA' }, nodes: [] }],
|
||||
},
|
||||
{ type: { resolvedName: 'Container' }, props: { node_id: 'c2' }, nodes: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
h.act(() => h.actions.addNodeTree(tree, 'ROOT'));
|
||||
});
|
||||
afterEach(() => h.unmount());
|
||||
|
||||
describe('craft tree walkers (real editor)', () => {
|
||||
test('getParentId', () => {
|
||||
expect(getParentId(h.query, 'btn')).toBe('c1');
|
||||
expect(getParentId(h.query, 'c1')).toBe('cols');
|
||||
expect(getParentId(h.query, 'sec')).toBe('ROOT');
|
||||
expect(getParentId(h.query, 'ROOT')).toBeNull();
|
||||
expect(getParentId(h.query, 'nope')).toBeNull();
|
||||
});
|
||||
|
||||
test('getChildIds returns nodes then linked nodes', () => {
|
||||
expect(getChildIds(h.query, 'c1')).toEqual(['btn']);
|
||||
expect(getChildIds(h.query, 'cols')).toEqual(['c1', 'c2']); // linked col-0, col-1
|
||||
const inner = h.query.node('sec').get().data.linkedNodes['section-inner'];
|
||||
expect(getChildIds(h.query, 'sec')).toEqual([inner]);
|
||||
expect(getChildIds(h.query, 'ROOT')).toEqual(['sec']);
|
||||
expect(getChildIds(h.query, 'nope')).toEqual([]);
|
||||
});
|
||||
|
||||
test('getSiblingIds for normal and linked nodes', () => {
|
||||
expect(getSiblingIds(h.query, 'btn')).toEqual(['btn']);
|
||||
expect(getSiblingIds(h.query, 'c1')).toEqual(['c1', 'c2']);
|
||||
expect(getSiblingIds(h.query, 'c2')).toEqual(['c1', 'c2']);
|
||||
expect(getSiblingIds(h.query, 'sec')).toEqual(['sec']);
|
||||
expect(getSiblingIds(h.query, 'ROOT')).toEqual([]);
|
||||
});
|
||||
|
||||
test('getIndexInParent', () => {
|
||||
expect(getIndexInParent(h.query, 'c1')).toBe(0);
|
||||
expect(getIndexInParent(h.query, 'c2')).toBe(1);
|
||||
expect(getIndexInParent(h.query, 'sec')).toBe(0);
|
||||
expect(getIndexInParent(h.query, 'ROOT')).toBe(-1);
|
||||
});
|
||||
|
||||
test('getAncestorIds nearest first, excluding ROOT', () => {
|
||||
const inner = h.query.node('sec').get().data.linkedNodes['section-inner'];
|
||||
expect(getAncestorIds(h.query, 'btn')).toEqual(['c1', 'cols', inner, 'sec']);
|
||||
expect(getAncestorIds(h.query, 'sec')).toEqual([]);
|
||||
expect(getAncestorIds(h.query, 'ROOT')).toEqual([]);
|
||||
});
|
||||
|
||||
test('getNodeLabel precedence and ROOT', () => {
|
||||
expect(getNodeLabel(h.query, 'ROOT')).toBe('Canvas');
|
||||
expect(getNodeLabel(h.query, 'btn')).toBe('Hero CTA'); // props.aiName
|
||||
expect(getNodeLabel(h.query, 'c2')).toBe('Container'); // displayName
|
||||
h.act(() => h.actions.setCustom('c2', (c: any) => { c.label = 'Right column'; }));
|
||||
expect(getNodeLabel(h.query, 'c2')).toBe('Right column'); // custom.label wins
|
||||
expect(getNodeLabel(h.query, 'nope')).toBe('Node');
|
||||
});
|
||||
});
|
||||
@@ -26,3 +26,62 @@ export function findDeletableTarget(query: any, nodeId: string | null | undefine
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Tree walkers (shared by commands, breadcrumb, palette) ----
|
||||
|
||||
function nodeData(query: any, id: string): any {
|
||||
try {
|
||||
return query.node(id).get()?.data ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getParentId(query: any, id: string): string | null {
|
||||
if (!id || id === 'ROOT') return null;
|
||||
return nodeData(query, id)?.parent ?? null;
|
||||
}
|
||||
|
||||
/** Children in order: `nodes` first, then linked nodes (Section inner, columns). */
|
||||
export function getChildIds(query: any, id: string): string[] {
|
||||
const d = nodeData(query, id);
|
||||
if (!d) return [];
|
||||
return [...((d.nodes as string[] | undefined) || []), ...Object.values((d.linkedNodes || {}) as Record<string, string>)];
|
||||
}
|
||||
|
||||
/** Siblings including the node itself, in order. For a linked node these are
|
||||
* the parent's linked values; empty for ROOT / unknown nodes. */
|
||||
export function getSiblingIds(query: any, id: string): string[] {
|
||||
const parentId = getParentId(query, id);
|
||||
if (!parentId) return [];
|
||||
const pd = nodeData(query, parentId);
|
||||
if (!pd) return [];
|
||||
const nodes: string[] = pd.nodes || [];
|
||||
if (nodes.includes(id)) return [...nodes];
|
||||
const linked = Object.values((pd.linkedNodes || {}) as Record<string, string>);
|
||||
return linked.includes(id) ? linked : [];
|
||||
}
|
||||
|
||||
/** Nearest first, ROOT excluded. */
|
||||
export function getAncestorIds(query: any, id: string): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>([id]);
|
||||
let cur = getParentId(query, id);
|
||||
while (cur && cur !== 'ROOT' && !seen.has(cur)) {
|
||||
out.push(cur);
|
||||
seen.add(cur);
|
||||
cur = getParentId(query, cur);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function getIndexInParent(query: any, id: string): number {
|
||||
return getSiblingIds(query, id).indexOf(id);
|
||||
}
|
||||
|
||||
export function getNodeLabel(query: any, id: string): string {
|
||||
if (id === 'ROOT') return 'Canvas';
|
||||
const d = nodeData(query, id);
|
||||
if (!d) return 'Node';
|
||||
return d.custom?.label || d.props?.aiName || d.displayName || 'Node';
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, test, expect, afterEach, vi } from 'vitest';
|
||||
import { parseShortcut, matchShortcut, formatShortcut, isTypingTarget } from './shortcuts';
|
||||
|
||||
function setPlatform(platform: string, ua = '') {
|
||||
vi.stubGlobal('navigator', { platform, userAgent: ua });
|
||||
}
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
function ev(init: KeyboardEventInit): KeyboardEvent {
|
||||
return new KeyboardEvent('keydown', init);
|
||||
}
|
||||
|
||||
describe('parseShortcut', () => {
|
||||
test.each([
|
||||
['mod+d', { key: 'd', mod: true }],
|
||||
['mod+shift+z', { key: 'z', mod: true, shift: true }],
|
||||
['alt+arrowup', { key: 'arrowup', alt: true }],
|
||||
['escape', { key: 'escape' }],
|
||||
['shift+tab', { key: 'tab', shift: true }],
|
||||
['1', { key: '1' }],
|
||||
['mod+/', { key: '/', mod: true }],
|
||||
['mod++', { key: '+', mod: true }],
|
||||
])('%s', (spec, expected) => {
|
||||
expect(parseShortcut(spec)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchShortcut', () => {
|
||||
test('non-mac: mod is ctrl only, exact modifiers', () => {
|
||||
setPlatform('Linux x86_64');
|
||||
expect(matchShortcut(ev({ key: 'd', ctrlKey: true }), 'mod+d')).toBe(true);
|
||||
expect(matchShortcut(ev({ key: 'D', ctrlKey: true }), 'mod+d')).toBe(true);
|
||||
expect(matchShortcut(ev({ key: 'd', metaKey: true }), 'mod+d')).toBe(false);
|
||||
expect(matchShortcut(ev({ key: 'd' }), 'mod+d')).toBe(false);
|
||||
expect(matchShortcut(ev({ key: 'd', ctrlKey: true, shiftKey: true }), 'mod+d')).toBe(false);
|
||||
expect(matchShortcut(ev({ key: 'Z', ctrlKey: true, shiftKey: true }), 'mod+shift+z')).toBe(true);
|
||||
expect(matchShortcut(ev({ key: 'z', ctrlKey: true }), 'mod+shift+z')).toBe(false);
|
||||
});
|
||||
|
||||
test('mac: mod is cmd, ctrl also accepted', () => {
|
||||
setPlatform('MacIntel');
|
||||
expect(matchShortcut(ev({ key: 'd', metaKey: true }), 'mod+d')).toBe(true);
|
||||
expect(matchShortcut(ev({ key: 'd', ctrlKey: true }), 'mod+d')).toBe(true);
|
||||
expect(matchShortcut(ev({ key: 'd' }), 'mod+d')).toBe(false);
|
||||
});
|
||||
|
||||
test('modifier-less specs reject ctrl/meta; plain keys match', () => {
|
||||
setPlatform('Linux x86_64');
|
||||
expect(matchShortcut(ev({ key: 'Escape' }), 'escape')).toBe(true);
|
||||
expect(matchShortcut(ev({ key: 'Escape', ctrlKey: true }), 'escape')).toBe(false);
|
||||
expect(matchShortcut(ev({ key: '1' }), '1')).toBe(true);
|
||||
expect(matchShortcut(ev({ key: 'Tab', shiftKey: true }), 'shift+tab')).toBe(true);
|
||||
expect(matchShortcut(ev({ key: 'Tab' }), 'shift+tab')).toBe(false);
|
||||
expect(matchShortcut(ev({ key: '/', ctrlKey: true }), 'mod+/')).toBe(true);
|
||||
});
|
||||
|
||||
test('alt uses physical code when key is a composed glyph (mac)', () => {
|
||||
setPlatform('MacIntel');
|
||||
expect(matchShortcut(ev({ key: '∂', code: 'KeyD', altKey: true }), 'alt+d')).toBe(true);
|
||||
expect(matchShortcut(ev({ key: 'ArrowUp', altKey: true }), 'alt+arrowup')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatShortcut', () => {
|
||||
test('non-mac', () => {
|
||||
setPlatform('Linux x86_64');
|
||||
expect(formatShortcut('mod+d')).toBe('Ctrl+D');
|
||||
expect(formatShortcut('mod+shift+z')).toBe('Ctrl+Shift+Z');
|
||||
expect(formatShortcut('alt+arrowup')).toBe('Alt+↑');
|
||||
expect(formatShortcut('escape')).toBe('Esc');
|
||||
expect(formatShortcut('shift+tab')).toBe('Shift+Tab');
|
||||
expect(formatShortcut('mod+/')).toBe('Ctrl+/');
|
||||
});
|
||||
test('mac', () => {
|
||||
setPlatform('MacIntel');
|
||||
expect(formatShortcut('mod+d')).toBe('⌘D');
|
||||
expect(formatShortcut('mod+shift+z')).toBe('⌘⇧Z');
|
||||
expect(formatShortcut('alt+arrowup')).toBe('⌥↑');
|
||||
expect(formatShortcut('escape')).toBe('Esc');
|
||||
expect(formatShortcut('shift+tab')).toBe('⇧Tab');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTypingTarget', () => {
|
||||
test('inputs, textarea, select, contentEditable', () => {
|
||||
expect(isTypingTarget(null)).toBe(false);
|
||||
expect(isTypingTarget(document.createElement('input'))).toBe(true);
|
||||
expect(isTypingTarget(document.createElement('textarea'))).toBe(true);
|
||||
expect(isTypingTarget(document.createElement('select'))).toBe(true);
|
||||
expect(isTypingTarget(document.createElement('div'))).toBe(false);
|
||||
expect(isTypingTarget(document.createElement('button'))).toBe(false);
|
||||
// jsdom does not implement isContentEditable; model the real browser getter.
|
||||
const ce = document.createElement('div');
|
||||
Object.defineProperty(ce, 'isContentEditable', { value: true });
|
||||
expect(isTypingTarget(ce)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Keyboard shortcut spec helpers shared by the command registry, the palette
|
||||
* and the menus. A spec is a '+'-joined string: modifiers (`mod`, `shift`,
|
||||
* `alt`) then a key, all lower-case -- 'mod+d', 'mod+shift+z', 'alt+arrowup',
|
||||
* 'escape', 'shift+tab', '1', 'mod+/'.
|
||||
*/
|
||||
export interface ShortcutSpec {
|
||||
/** KeyboardEvent.key lower-cased ('d', 'arrowup', 'escape', 'tab', '/', '1'). */
|
||||
key: string;
|
||||
mod?: boolean;
|
||||
shift?: boolean;
|
||||
alt?: boolean;
|
||||
}
|
||||
|
||||
export function isMac(): boolean {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
const p = (navigator as any).platform || '';
|
||||
const ua = navigator.userAgent || '';
|
||||
return /Mac|iPhone|iPad|iPod/i.test(p) || /Mac OS X|iPhone|iPad|iPod/i.test(ua);
|
||||
}
|
||||
|
||||
export function parseShortcut(spec: string): ShortcutSpec {
|
||||
const out: ShortcutSpec = { key: '' };
|
||||
// A trailing '+' is the plus key itself ('mod++').
|
||||
let body = spec.trim();
|
||||
let key = '';
|
||||
if (body.endsWith('+')) {
|
||||
key = '+';
|
||||
body = body.slice(0, -1).replace(/\+$/, '');
|
||||
}
|
||||
const parts = body ? body.split('+') : [];
|
||||
if (!key) key = (parts.pop() || '').toLowerCase();
|
||||
for (const raw of parts) {
|
||||
const m = raw.trim().toLowerCase();
|
||||
if (m === 'mod' || m === 'cmd' || m === 'ctrl' || m === 'meta') out.mod = true;
|
||||
else if (m === 'shift') out.shift = true;
|
||||
else if (m === 'alt' || m === 'option') out.alt = true;
|
||||
}
|
||||
out.key = key === 'esc' ? 'escape' : key === 'space' ? ' ' : key;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The key a keydown event represents. With Alt held on macOS `e.key` is a
|
||||
* composed glyph ('∂' for Alt+D), so fall back to the physical `code`. */
|
||||
function eventKey(e: KeyboardEvent): string {
|
||||
const code = e.code || '';
|
||||
if (e.altKey) {
|
||||
const letter = /^Key([A-Z])$/.exec(code);
|
||||
if (letter) return letter[1].toLowerCase();
|
||||
const digit = /^Digit([0-9])$/.exec(code);
|
||||
if (digit) return digit[1];
|
||||
}
|
||||
return (e.key || '').toLowerCase();
|
||||
}
|
||||
|
||||
export function matchShortcut(e: KeyboardEvent, spec: string): boolean {
|
||||
const s = parseShortcut(spec);
|
||||
if (!s.key || eventKey(e) !== s.key) return false;
|
||||
if (!!s.shift !== e.shiftKey || !!s.alt !== e.altKey) return false;
|
||||
if (s.mod) {
|
||||
// mac: Cmd is the modifier; Ctrl also counts so existing habits keep working.
|
||||
return isMac() ? (e.metaKey || e.ctrlKey) : e.ctrlKey && !e.metaKey;
|
||||
}
|
||||
return !e.ctrlKey && !e.metaKey;
|
||||
}
|
||||
|
||||
const KEY_LABELS: Record<string, [string, string]> = {
|
||||
// [mac glyph form, plain form]
|
||||
arrowup: ['↑', '↑'],
|
||||
arrowdown: ['↓', '↓'],
|
||||
arrowleft: ['←', '←'],
|
||||
arrowright: ['→', '→'],
|
||||
escape: ['Esc', 'Esc'],
|
||||
tab: ['Tab', 'Tab'],
|
||||
delete: ['Del', 'Del'],
|
||||
backspace: ['⌫', 'Backspace'],
|
||||
enter: ['↩', 'Enter'],
|
||||
' ': ['Space', 'Space'],
|
||||
};
|
||||
|
||||
export function formatShortcut(spec: string): string {
|
||||
const s = parseShortcut(spec);
|
||||
const mac = isMac();
|
||||
const label = KEY_LABELS[s.key]?.[mac ? 0 : 1] ?? (s.key.length === 1 ? s.key.toUpperCase() : s.key);
|
||||
const mods: string[] = [];
|
||||
if (mac) {
|
||||
if (s.mod) mods.push('⌘');
|
||||
if (s.alt) mods.push('⌥');
|
||||
if (s.shift) mods.push('⇧');
|
||||
return mods.join('') + label;
|
||||
}
|
||||
if (s.mod) mods.push('Ctrl');
|
||||
if (s.alt) mods.push('Alt');
|
||||
if (s.shift) mods.push('Shift');
|
||||
return [...mods, label].join('+');
|
||||
}
|
||||
|
||||
/** True when keystrokes there belong to text entry (input/textarea/select/contentEditable). */
|
||||
export function isTypingTarget(el: Element | null): boolean {
|
||||
if (!el) return false;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
|
||||
if ((el as HTMLElement).isContentEditable) return true;
|
||||
return false;
|
||||
}
|
||||
Reference in new issue
Block a user