Skip to content

Commit

Permalink
Feat: Scope cut-copy-paste (#215)
Browse files Browse the repository at this point in the history
* fix: generate mul pods

* just record head nodes for pasting

* sync to db

* fix yjs drag

* add cut btn

* fix: clean up & pane black border

* fix: check insertion state

* add "extent: parent" for copied scope

* fix: limit pod move range

---------

Co-authored-by: Hebi Li <lihebi.com@gmail.com>
  • Loading branch information
li-xin-yi and lihebi authored Feb 22, 2023
1 parent 5b39efe commit 6f0e840
Show file tree
Hide file tree
Showing 13 changed files with 431 additions and 298 deletions.
2 changes: 2 additions & 0 deletions api/src/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
getVisibility,
updateVisibility,
addCollaborator,
addPods,
pod,
repo,
repos,
Expand Down Expand Up @@ -73,6 +74,7 @@ export const resolvers = {
deleteRepo,
clearUser: () => {},
updatePod,
addPods,
deletePod,
addCollaborator,
updateVisibility,
Expand Down
84 changes: 35 additions & 49 deletions api/src/resolver_repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,56 +296,42 @@ export async function updatePod(_, { id, repoId, input }, { userId }) {
},
},
});
if (pod_found) {
// if repoId doesn't have id, create it IF input.parent exists
const pod = await prisma.pod.update({
where: {
id,
},
data: {
...input,
parent:
input.parent && input.parent !== "ROOT"
? {
connect: {
id: input.parent,
},
}
: undefined,
children: {
connect: input.children?.map((id) => ({ id })),
},
},
});
} else {
// if repoId doesn't have id, create it IF input.parent exists, otherwise throw error.
await prisma.pod.create({
data: {
id,
...input,
// Dummy index because it is a required field for historical reasons.
index: 0,
parent:
input.parent && input.parent !== "ROOT"
? {
connect: {
id: input.parent,
},
}
: undefined,
// In case of [], create will throw an error. Thus I have to pass undefined.
// children: input.children.length > 0 ? input.children : undefined,
children: {
connect: input.children?.map((id) => ({ id })),
},
repo: {
connect: {
id: repoId,
},
},
// or, return false and leave it dirty
if (!pod_found) return false;
const pod = await prisma.pod.update({
where: {
id,
},
data: {
...input,
parent:
input.parent && input.parent !== "ROOT"
? {
connect: {
id: input.parent,
},
}
: undefined,
children: {
connect: input.children?.map((id) => ({ id })),
},
});
}
},
});
return true;
}

export async function addPods(_, { repoId, pods }, { userId }) {
if (!userId) throw new Error("Not authenticated.");
await ensureRepoEditAccess({ repoId, userId });
// notice: we keep the field "children", "parent", "repo" empty when first insertion the repo. Because if we insist on filling them, we must specify children, parent and repo by prisma.create.pod. Regardless of what order we insert them, we can't make sure both children and parent exist in the DB, the insertion must fail.
// Here, we first insert all pods and ignore their any relationship, then the relationship will be updated by updateAllPods because we don't clean the dirty tag of them next.
await prisma.pod.createMany({
data: pods.map((pod) => {
const res = { ...pod, id: pod.id, index: 0, parent: undefined, repoId };
if (res.children) delete res.children;
return res;
}),
});

return true;
}
Expand Down
1 change: 1 addition & 0 deletions api/src/typedefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export const typeDefs = gql`
updateRepo(id: ID, name: String): Boolean
deleteRepo(id: ID): Boolean
deletePod(id: String, toDelete: [String]): Boolean
addPods(repoId: String, pods: [PodInput]): Boolean
updatePod(id: String, repoId: String, input: PodInput): Boolean
clearUser: Boolean
clearRepo: Boolean
Expand Down
68 changes: 43 additions & 25 deletions ui/src/components/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ function store2nodes(id: string, { getId2children, getPod }) {
// position: { x: 100, y: 100 },
position: { x: pod.x, y: pod.y },
parentNode: pod.parent !== "ROOT" ? pod.parent : undefined,
extent: pod.parent !== "ROOT" ? "parent" : undefined,
style: {
width: pod.width || undefined,
height: pod.height || undefined,
Expand Down Expand Up @@ -211,8 +212,9 @@ function usePaste(reactFlowWrapper) {
const pasteEnd = useStore(store, (state) => state.pasteEnd);
const cancelPaste = useStore(store, (state) => state.cancelPaste);
const isPasting = useStore(store, (state) => state.isPasting);
const isCutting = useStore(store, (state) => state.isCutting);
const isGuest = useStore(store, (state) => state.role === "GUEST");

const isPaneFocused = useStore(store, (state) => state.isPaneFocused);
const resetSelection = useStore(store, (state) => state.resetSelection);

useEffect(() => {
Expand All @@ -228,12 +230,17 @@ function usePaste(reactFlowWrapper) {
onPasteMove(position);
};
const mouseClick = (event) => {
pasteEnd();
const reactFlowBounds = reactFlowWrapper.current.getBoundingClientRect();
const position = reactFlowInstance.project({
x: event.clientX - reactFlowBounds.left,
y: event.clientY - reactFlowBounds.top,
});
pasteEnd(position, false);
};
const keyDown = (event) => {
if (event.key !== "Escape") return;
// delete the temporary node
cancelPaste();
cancelPaste(false);
//clear the pasting state
event.preventDefault();
};
Expand All @@ -259,20 +266,14 @@ function usePaste(reactFlowWrapper) {
const handlePaste = useCallback(
(event) => {
// avoid duplicated pastes
if (isPasting || isGuest) return;

// only paste when the pane is focused
if (
event.target?.className !== "react-flow__pane" &&
document.activeElement?.className !== "react-flow__pane"
)
return;
// check if the pane is focused
if (isPasting || isCutting || isGuest || !isPaneFocused) return;

try {
// the user clipboard data is unpreditable, may have application/json
// from other source that can't be parsed by us, use try-catch here.
const playload = event.clipboardData.getData("application/json");
const data = JSON.parse(playload);
const payload = event.clipboardData.getData("application/json");
const data = JSON.parse(payload);
if (data?.type !== "pod") {
return;
}
Expand All @@ -288,7 +289,7 @@ function usePaste(reactFlowWrapper) {
];

const position = reactFlowInstance.project({ x: posX, y: posY });
pasteBegin(position, data.data);
pasteBegin(position, data.data, false);
} catch (e) {
console.log("paste error", e);
}
Expand All @@ -300,6 +301,7 @@ function usePaste(reactFlowWrapper) {
reactFlowInstance,
reactFlowWrapper,
resetSelection,
isPaneFocused,
]
);

Expand All @@ -321,6 +323,9 @@ function useCut(reactFlowWrapper) {
const onCutMove = useStore(store, (state) => state.onCutMove);
const cancelCut = useStore(store, (state) => state.cancelCut);
const isCutting = useStore(store, (state) => state.isCutting);
const isPasting = useStore(store, (state) => state.isPasting);
const isGuest = useStore(store, (state) => state.role === "GUEST");
const apolloClient = useApolloClient();

useEffect(() => {
if (!reactFlowWrapper.current) return;
Expand All @@ -335,7 +340,12 @@ function useCut(reactFlowWrapper) {
onCutMove(position);
};
const mouseClick = (event) => {
cutEnd();
const reactFlowBounds = reactFlowWrapper.current.getBoundingClientRect();
const position = reactFlowInstance.project({
x: event.clientX - reactFlowBounds.left,
y: event.clientY - reactFlowBounds.top,
});
cutEnd(position, reactFlowInstance);
};
const keyDown = (event) => {
if (event.key !== "Escape") return;
Expand All @@ -358,6 +368,8 @@ function useCut(reactFlowWrapper) {
cancelCut,
cutEnd,
isCutting,
isPasting,
apolloClient,
onCutMove,
reactFlowInstance,
reactFlowWrapper,
Expand Down Expand Up @@ -431,33 +443,41 @@ function CanvasImpl() {
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
const shareOpen = useStore(store, (state) => state.shareOpen);
const setShareOpen = useStore(store, (state) => state.setShareOpen);
const setPaneFocus = useStore(store, (state) => state.setPaneFocus);
const setPaneBlur = useStore(store, (state) => state.setPaneBlur);

const [showContextMenu, setShowContextMenu] = useState(false);
const [points, setPoints] = useState({ x: 0, y: 0 });
const [client, setClient] = useState({ x: 0, y: 0 });

const onPaneContextMenu = (event) => {
console.log("onPaneContextMenu", event);
event.preventDefault();
setShowContextMenu(true);
setPoints({ x: event.pageX, y: event.pageY });
setClient({ x: event.clientX, y: event.clientY });
};

useEffect(() => {
const handleClick = (e) => {
const handleClick = (event) => {
setShowContextMenu(false);
const target = event.target;
// set the pane focused only when the clicked target is pane or the copy buttons on a pod
// then we can paste right after click on the copy buttons
if (
target.className === "react-flow__pane" ||
target.classList?.contains("copy-button") ||
target.parentElement?.classList?.contains("copy-button")
) {
setPaneFocus();
} else {
setPaneBlur();
}
};
document.addEventListener("click", handleClick);
return () => {
document.removeEventListener("click", handleClick);
};
}, [setShowContextMenu]);

const onPaneClick = (event) => {
// focus
event.target.tabIndex = 0;
};
}, [setShowContextMenu, setPaneFocus, setPaneBlur]);

const getScopeAtPos = useStore(store, (state) => state.getScopeAtPos);

Expand Down Expand Up @@ -504,8 +524,6 @@ function CanvasImpl() {
removeDragHighlight();
}
}}
onPaneClick={onPaneClick}
// onPaneMouseMove={onPaneMouseMove}
attributionPosition="top-right"
maxZoom={10}
minZoom={0.1}
Expand Down
8 changes: 4 additions & 4 deletions ui/src/components/CanvasContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,30 +46,30 @@ export function CanvasContextMenu(props) {
<MenuList className="paneContextMenu">
{!isGuest && (
<MenuItem onClick={props.addCode} sx={ItemStyle}>
<ListItemIcon>
<ListItemIcon sx={{ color: "inherit" }}>
<CodeIcon />
</ListItemIcon>
<ListItemText>New Code</ListItemText>
</MenuItem>
)}
{!isGuest && (
<MenuItem onClick={props.addRich} sx={ItemStyle}>
<ListItemIcon>
<ListItemIcon sx={{ color: "inherit" }}>
<NoteIcon />
</ListItemIcon>
<ListItemText>New Note</ListItemText>
</MenuItem>
)}
{!isGuest && (
<MenuItem onClick={props.addScope} sx={ItemStyle}>
<ListItemIcon>
<ListItemIcon sx={{ color: "inherit" }}>
<PostAddIcon />
</ListItemIcon>
<ListItemText>New Scope</ListItemText>
</MenuItem>
)}
<MenuItem onClick={flipShowLineNumbers} sx={ItemStyle}>
<ListItemIcon>
<ListItemIcon sx={{ color: "inherit" }}>
<FormatListNumberedIcon />
</ListItemIcon>
<ListItemText>
Expand Down
22 changes: 8 additions & 14 deletions ui/src/components/nodes/Code.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ export const CodeNode = memo<NodeProps>(function ({
const setPodName = useStore(store, (state) => state.setPodName);
const setPodGeo = useStore(store, (state) => state.setPodGeo);
const getPod = useStore(store, (state) => state.getPod);
const clonePod = useStore(store, (state) => state.clonePod);
const pod = getPod(id);
const isGuest = useStore(store, (state) => state.role === "GUEST");
const isPodFocused = useStore(store, (state) => state.pods[id]?.focus);
Expand All @@ -252,7 +253,7 @@ export const CodeNode = memo<NodeProps>(function ({
);
const inputRef = useRef<HTMLInputElement>(null);
const updateView = useStore(store, (state) => state.updateView);
const isCutting = useStore(store, (state) => state.cuttingId === id);
const isCutting = useStore(store, (state) => state.cuttingIds.has(id));

const showResult = useStore(
store,
Expand All @@ -264,6 +265,7 @@ export const CodeNode = memo<NodeProps>(function ({
state.pods[id]?.stderr
);
const nodesMap = useStore(store, (state) => state.ydoc.getMap<Node>("pods"));
const setPaneFocus = useStore(store, (state) => state.setPaneFocus);
const onResize = useCallback(
(e, data) => {
const { size } = data;
Expand Down Expand Up @@ -298,7 +300,7 @@ export const CodeNode = memo<NodeProps>(function ({

const onCopy = useCallback(
(clipboardData: any) => {
const pod = getPod(id);
const pod = clonePod(id);
if (!pod) return;
clipboardData.setData("text/plain", pod.content);
clipboardData.setData(
Expand All @@ -308,8 +310,9 @@ export const CodeNode = memo<NodeProps>(function ({
data: pod,
})
);
setPaneFocus();
},
[getPod, id]
[clonePod, id]
);

const cutBegin = useStore(store, (state) => state.cutBegin);
Expand Down Expand Up @@ -471,15 +474,6 @@ export const CodeNode = memo<NodeProps>(function ({
justifyContent: "center",
}}
className="nodrag"
onClick={(e) => {
const pane = document.getElementsByClassName(
"react-flow__pane"
)[0] as HTMLElement;
if (pane) {
pane.tabIndex = 0;
pane.focus();
}
}}
>
{!isGuest && (
<Tooltip title="Run (shift-enter)">
Expand All @@ -499,8 +493,8 @@ export const CodeNode = memo<NodeProps>(function ({
options={{ debug: true, format: "text/plain", onCopy } as any}
>
<Tooltip title="Copy">
<IconButton size="small">
<ContentCopyIcon fontSize="inherit" />
<IconButton size="small" className="copy-button">
<ContentCopyIcon fontSize="inherit" className="copy-button" />
</IconButton>
</Tooltip>
</CopyToClipboard>
Expand Down
Loading

0 comments on commit 6f0e840

Please sign in to comment.