Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Focus the currently focused node when it mounts #593

Merged
merged 6 commits into from
Nov 20, 2021
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/cmb-toolkit/src/simulate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ export function insertText(text: string) {
// See https://github.com/testing-library/dom-testing-library/pull/235
// for some discussion about this.
fireEvent.input(activeEl, {
target: { innerHTML: text },
target: { innerHTML: activeEl.innerHTML + text },
});
}
}
Expand Down Expand Up @@ -171,7 +171,7 @@ function makeKeyEvent<T extends Record<string, unknown>>(
// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values
function getKeyCode(key: string): number {
// The key code for an (uppercase) letter is that letter's ascii value.
if (key.match(/^[A-Z]$/)) {
if (key.match(/^[A-Za-z0-9]$/)) {
return key.charCodeAt(0);
}
// The key code for a digit is that digit's ascii value.
Expand Down
25 changes: 23 additions & 2 deletions packages/codemirror-blocks/spec/activation-test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import wescheme from "../src/languages/wescheme";
import "codemirror/addon/search/searchcursor.js";

import { screen } from "@testing-library/react";
import {
cmd_ctrl,
teardown,
Expand All @@ -10,6 +9,7 @@ import {
mountCMB,
isNodeEditable,
elementForNode,
keyPress,
} from "../src/toolkit/test-utils";
import { API } from "../src/CodeMirrorBlocks";
import { ASTNode } from "../src/ast";
Expand Down Expand Up @@ -131,6 +131,27 @@ describe("when dealing with node activation,", () => {
});
});

describe("inserting a new node", () => {
let cmb: API;
beforeEach(() => {
cmb = mountCMB(wescheme).cmb;
});
it("should focus the node that was just inserted", () => {
cmb.focus();
// start inserting a new node
keyPress("a");
insertText("BrandNewLiteral");
// confirm the insertion by pressing Enter
keyDown("Enter");
// confirm that the new new was saved to the ast and focused
expect(cmb.getAst().toString()).toBe("aBrandNewLiteral");
expect(cmb.getValue()).toEqual("aBrandNewLiteral");
expect(document.activeElement!.id).toEqual(
screen.getByRole("treeitem", { name: /aBrandNewLiteral/ }).id
);
});
});

describe("cut/copy/paste", () => {
let cmb!: API;
let literal1!: ASTNode;
Expand Down
9 changes: 9 additions & 0 deletions packages/codemirror-blocks/src/components/Node.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,15 @@ const Node = ({ expandable = true, ...props }: Props) => {
}
}, [props.node.id]);

// If this is the currently focused node, then focus the node element
// as soon as we finish rendering.
const focusedNode = useSelector(selectors.getFocusedNode);
useEffect(() => {
if (nodeElementRef.current && focusedNode?.id === props.node.id) {
nodeElementRef.current?.focus();
}
}, [focusedNode?.id, props.node.id]);

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My understanding of line 250 is that this hook "subscribes" to the focusedNode selector and the node id. Does this mean that any time the focusedNode changes, every node asks if the focused node's id is the same as the node's id? If not, what does it mean?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, that's what this means.

if (editable) {
if (!editor) {
throw new Error("can't edit nodes before codemirror has mounted");
Expand Down
258 changes: 141 additions & 117 deletions packages/codemirror-blocks/src/components/NodeEditable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,127 +52,151 @@ type Props = Omit<ContentEditableProps, "value"> & {
editor: CMBEditor;
};

const NodeEditable = (props: Props) => {
const element = useRef<HTMLElement>(null);
const dispatch: AppDispatch = useDispatch();

const ast = useSelector(selectors.getAST);

const { initialValue, isErrored } = useSelector((state: RootState) => {
const nodeId = props.target.node ? props.target.node.id : "editing";
const isErrored = selectors.getErrorId(state) == nodeId;

const initialValue =
props.value === null ? props.target.getText(ast, props.editor) : "";

return { isErrored, initialValue };
});

// select and focus the element on mount
useEffect(() => {
const currentEl = element.current;
if (!currentEl) {
// element has been unmounted already, nothing to do.
return;
}
selectElement(currentEl, props.isInsertion);
}, [props.isInsertion]);

useEffect(() => {
const text = props.value || initialValue || "";
const annt = (props.isInsertion ? "inserting" : "editing") + ` ${text}`;
say(annt + `. Use Enter to save, and Alt-Q to cancel`);
dispatch(actions.setSelectedNodeIds([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const onBlur = (e: React.SyntheticEvent) => {
e.stopPropagation();
const { target } = props;
dispatch((dispatch: AppDispatch, getState: () => RootState) => {
// we grab the value directly from the content editable element
// to deal with this issue:
// https://github.com/lovasoa/react-contenteditable/issues/161
const value = element.current?.textContent;
const focusedNode = selectors.getFocusedNode(getState());
// if there's no insertion value, or the new value is the same as the
// old one, preserve focus on original node and return silently
if (value === initialValue || !value) {
props.onDisableEditable();
const nid = focusedNode && focusedNode.nid;
dispatch(activateByNid(props.editor, nid));
export type NodeEditableHandle = {
/**
* Selects and focuses the text in the node editable component
*/
select: () => void;
};

const NodeEditable = React.forwardRef<NodeEditableHandle, Props>(
(props, ref) => {
const element = useRef<HTMLElement>(null);
const dispatch: AppDispatch = useDispatch();

const ast = useSelector(selectors.getAST);

const { initialValue, isErrored } = useSelector((state: RootState) => {
const nodeId = props.target.node ? props.target.node.id : "editing";
const isErrored = selectors.getErrorId(state) == nodeId;

const initialValue =
props.value === null ? props.target.getText(ast, props.editor) : "";

return { isErrored, initialValue };
});

// select and focus the element on mount
useEffect(() => {
const currentEl = element.current;
if (!currentEl) {
// element has been unmounted already, nothing to do.
return;
}
selectElement(currentEl, props.isInsertion);
}, [props.isInsertion]);

const annt = `${props.isInsertion ? "inserted" : "changed"} ${value}`;
const result = dispatch(insert(value, target, props.editor, annt));
if (result.successful) {
dispatch(activateByNid(props.editor, null, { allowMove: false }));
props.onChange(null);
props.onDisableEditable();
dispatch(actions.clearError());
say(annt);
} else {
console.error(result.exception);
dispatch(actions.setErrorId(target.node ? target.node.id : "editing"));
// expose an imperative handle for selecting the contents
// of the rendered element in case it needs to be done after
// later. This is only necessary in cases where the component
// is rendered to a container that won't be in the document
// until later, as is the case with TopLevelNodeEditable
React.useImperativeHandle(ref, () => ({
select: () => {
if (element.current) {
selectElement(element.current, false);
selectElement(element.current, props.isInsertion);
}
},
}));

useEffect(() => {
const text = props.value || initialValue || "";
const annt = (props.isInsertion ? "inserting" : "editing") + ` ${text}`;
say(annt + `. Use Enter to save, and Alt-Q to cancel`);
dispatch(actions.setSelectedNodeIds([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const onBlur = (e: React.SyntheticEvent) => {
e.stopPropagation();
const { target } = props;
dispatch((dispatch: AppDispatch, getState: () => RootState) => {
// we grab the value directly from the content editable element
// to deal with this issue:
// https://github.com/lovasoa/react-contenteditable/issues/161
const value = element.current?.textContent;
const focusedNode = selectors.getFocusedNode(getState());
// if there's no insertion value, or the new value is the same as the
// old one, preserve focus on original node and return silently
if (value === initialValue || !value) {
props.onDisableEditable();
const nid = focusedNode && focusedNode.nid;
dispatch(activateByNid(props.editor, nid));
return;
}

const annt = `${props.isInsertion ? "inserted" : "changed"} ${value}`;
const result = dispatch(insert(value, target, props.editor, annt));
if (result.successful) {
dispatch(activateByNid(props.editor, null, { allowMove: false }));
props.onChange(null);
props.onDisableEditable();
dispatch(actions.clearError());
say(annt);
} else {
console.error(result.exception);
dispatch(
actions.setErrorId(target.node ? target.node.id : "editing")
);
if (element.current) {
selectElement(element.current, false);
}
}
});
};

const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const el = e.target as HTMLDivElement;
switch (CodeMirror.keyName(e)) {
case "Enter": {
// blur the element to trigger handleBlur
// which will save the edit
element.current?.blur();
return;
}
case "Alt-Q":
case "Esc": {
el.innerHTML = initialValue;
e.stopPropagation();
props.onChange(null);
props.onDisableEditable();
dispatch(actions.clearError());
dispatch(activateByNid(props.editor, null, { allowMove: false }));
return;
}
}
});
};

const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const el = e.target as HTMLDivElement;
switch (CodeMirror.keyName(e)) {
case "Enter": {
// blur the element to trigger handleBlur
// which will save the edit
element.current?.blur();
return;
}
case "Alt-Q":
case "Esc": {
el.innerHTML = initialValue;
e.stopPropagation();
props.onChange(null);
props.onDisableEditable();
dispatch(actions.clearError());
dispatch(activateByNid(props.editor, null, { allowMove: false }));
return;
}
}
};

const { contentEditableProps, extraClasses, value } = props;

const classes = (
[
"blocks-literal",
"quarantine",
"blocks-editing",
"blocks-node",
{ "blocks-error": isErrored },
] as ClassNamesArgument[]
).concat(extraClasses);
const text = value ?? initialValue;
return (
<ContentEditable
{...contentEditableProps}
className={classNames(classes)}
role="textbox"
ref={element}
onChange={props.onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
// trap mousedown, clicks and doubleclicks, to prevent focus change, or
// parent nodes from toggling collapsed state
onMouseDown={suppressEvent}
onClick={suppressEvent}
onDoubleClick={suppressEvent}
aria-label={text}
value={text}
/>
);
};
};

const { contentEditableProps, extraClasses, value } = props;

const classes = (
[
"blocks-literal",
"quarantine",
"blocks-editing",
"blocks-node",
{ "blocks-error": isErrored },
] as ClassNamesArgument[]
).concat(extraClasses);
const text = value ?? initialValue;
return (
<ContentEditable
{...contentEditableProps}
className={classNames(classes)}
role="textbox"
ref={element}
onChange={props.onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
// trap mousedown, clicks and doubleclicks, to prevent focus change, or
// parent nodes from toggling collapsed state
onMouseDown={suppressEvent}
onClick={suppressEvent}
onDoubleClick={suppressEvent}
aria-label={text}
value={text}
/>
);
}
);
export default NodeEditable;
Loading