Skip to content

Commit

Permalink
feat(editor): implement drag & drop
Browse files Browse the repository at this point in the history
  • Loading branch information
sabberworm committed Oct 16, 2024
1 parent f4a6da8 commit 44daf53
Show file tree
Hide file tree
Showing 24 changed files with 546 additions and 159 deletions.
106 changes: 106 additions & 0 deletions src/main/frontend/hooks/useDropTarget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { useContext, useEffect, useRef } from 'react';

import { Hop } from '../model/hops';

import { ScriptContext } from '../App';
import { DROPPED_METHOD_KEY, HOP_REFERENCE_TYPE, JSON_TYPES } from '../widgets/DropZone';
import { Script } from '../model/Script';

import { HistoryUpdater } from './useHistoryImmutable';
import { jsonToHops } from '../model/jsonToHops';

export const DRAGGED_HOP: { hop: Hop | null; parentHops: Hop[] | null } = {
hop: null,
parentHops: null,
};

export function dataToString(jsonData: DataTransferItem): Promise<string> {
return new Promise((resolve, _reject) => {
if (jsonData.kind === 'string') {
jsonData.getAsString(str => resolve(str));
} else if (jsonData.kind === 'file') {
const jsonFile = jsonData.getAsFile()!;
const reader = new FileReader();
reader.addEventListener('load', () => {
resolve(reader.result as string);
});
reader.readAsText(jsonFile);
}
});
}

function handleHopDrop(hopData: string, targetHopList: Hop[], targetHopPosition: number, scriptContext: HistoryUpdater<Script>, isCopy = false) {
const { hop, parentHops } = DRAGGED_HOP;
DRAGGED_HOP.hop = null;
DRAGGED_HOP.parentHops = null;
if (isCopy) {
// Use JSON representation of dragged hop to avoid cycles in case a hop was copied into itself
targetHopList.splice(targetHopPosition, 0, JSON.parse(hopData) as Hop);
scriptContext.commit();
return;
}

if (!hop || !parentHops) {
console.error('Origin of dragged hop', hopData, 'not known');
return;
}

const prevPosition = parentHops.indexOf(hop);
const isSameList = targetHopList === parentHops;
if (isSameList) {
if (targetHopPosition === prevPosition || targetHopPosition === prevPosition + 1) {
// Item was moved before or after itself ➞ nothing changes
return;
}
}
//
parentHops.splice(prevPosition, 1);
if (isSameList && targetHopPosition > prevPosition) {
// Account for the removed element
targetHopPosition--;
}

targetHopList.splice(targetHopPosition, 0, hop);
scriptContext.commit();
}

async function handleFileDrop(data: DataTransfer, targetHopList: Hop[], targetHopPosition: number, scriptContext: HistoryUpdater<Script>) {
const items = [...data.items];
let item: DataTransferItem | undefined;
for (const type of JSON_TYPES) {
item = items.find(it => it.type === type);
if (item) {
break;
}
}

if (!item) {
console.error('Data', data, 'do not contain usable items');
return;
}

const json = await dataToString(item);
const hops = jsonToHops(json);

targetHopList.splice(targetHopPosition, 0, ...hops);
scriptContext.commit();
}

export function useDropTarget<E extends HTMLElement>(hops: Hop[], position = 0) {
const scriptContext = useContext(ScriptContext);
const ref = useRef<E>(null);

useEffect(() => {
if (ref.current) {
ref.current[DROPPED_METHOD_KEY] = (data, isCopy) => {
if (data.types.includes(HOP_REFERENCE_TYPE)) {
handleHopDrop(data.getData(HOP_REFERENCE_TYPE), hops, position, scriptContext, isCopy);
} else {
void handleFileDrop(data, hops, position, scriptContext);
}
};
}
}, [hops, position, ref.current]);

return [ref] as const;
}
26 changes: 26 additions & 0 deletions src/main/frontend/model/jsonToHops.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Hop } from './hops';

export function jsonToHops(json: string): Hop[] {
try {
const val = JSON.parse(json);
if (Array.isArray(val)) {
// Assume hop list
return val as Hop[];
}
if (typeof val === 'object') {
// Guess the type from the shape
if ('type' in val) {
// Assume single hop
return [val as Hop];
}
if ('hops' in val && Array.isArray(val.hops)) {
// Assume script or list of hops
return val.hops as Hop[];
}
}
throw new Error(`Unknown shape of ${json}, cannot turn into hop list`);
} catch (e) {
console.error('Could not parse', json, e);
}
return [];
}
36 changes: 22 additions & 14 deletions src/main/frontend/sections/ScriptEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Select } from '../widgets/Select';
import { Parameter } from './editor/Parameter';
import { Pipeline } from './editor/Pipeline';
import { LogLevel } from '../model/LogLevel';
import { DropZone } from '../widgets/DropZone';

const Elm = styled('div')`
grid-auto-rows: min-content 1fr min-content;
Expand Down Expand Up @@ -48,20 +49,27 @@ export const ScriptEditor: FC = () => {

return (
<Elm className="script-editor">
<fieldset className="field-container">
<legend>Options</legend>
<Select label="Log Level" list={LOG_LEVEL_LABELS} value={script.logLevel} onChange={val => (script.logLevel = val)} />
</fieldset>
<div className="script">
<Pipeline hops={script.hops} addButton={false} />
</div>
<fieldset className="parameters">
<legend>Parameters</legend>
{script.parameters.map((param, i) => (
<Parameter key={i} i={i} param={param} />
))}
<button is="coral-button" icon="add" onClick={addParameter} />
</fieldset>
<DropZone dropZoneClass="hop-list">
<fieldset className="field-container">
<legend>Options</legend>
<Select
label="Log Level"
list={LOG_LEVEL_LABELS}
value={script.logLevel}
onChange={val => (script.logLevel = val)}
/>
</fieldset>
<div className="script">
<Pipeline hops={script.hops} addButton={false} />
</div>
<fieldset className="parameters">
<legend>Parameters</legend>
{script.parameters.map((param, i) => (
<Parameter key={i} i={i} param={param} />
))}
<button is="coral-button" icon="add" onClick={addParameter} />
</fieldset>
</DropZone>
</Elm>
);
};
8 changes: 4 additions & 4 deletions src/main/frontend/sections/editor/FallbackStep.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import React, { FC, useContext } from 'react';
import React, { forwardRef, useContext } from 'react';

import { Hop } from '../../model/hops';
import { StepEditor } from '../../widgets/StepEditor';
import { ScriptContext } from '../../App';
import { CodeEditor } from '../../widgets/CodeEditor';

export const FallbackStep: FC<{ parentHops: Hop[]; hop: Hop }> = ({ parentHops, hop }) => {
export const FallbackStep = forwardRef<HTMLDivElement, { parentHops: Hop[]; hop: Hop }>(function FallbackStep({ parentHops, hop }, ref) {
const scriptContext = useContext(ScriptContext);

const { type: hopType, ...hopWithoutType } = hop;
const code = JSON.stringify(hopWithoutType, null, ' ');

return (
<StepEditor parentHops={parentHops} hop={hop} title={`Unknown Hop (${hopType})`}>
<StepEditor parentHops={parentHops} hop={hop} title={`Unknown Hop (${hopType})`} ref={ref}>
<CodeEditor
value={code}
onChange={(value, hasError) => {
Expand All @@ -30,4 +30,4 @@ export const FallbackStep: FC<{ parentHops: Hop[]; hop: Hop }> = ({ parentHops,
/>
</StepEditor>
);
};
});
19 changes: 15 additions & 4 deletions src/main/frontend/sections/editor/Pipeline.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
import React, { FC, useContext } from 'react';
import React, { FC, forwardRef, useContext } from 'react';

import { styled } from 'goober';

import { Hop, HOP_DEFINITIONS, HopType } from '../../model/hops';
import { PipelineStep } from './PipelineStep';
import { Picker } from '../../widgets/Picker';
import { ScriptContext } from '../../App';
import { useDropTarget } from '../../hooks/useDropTarget';

const Elm = styled('div', forwardRef)`
position: relative;
min-height: 1.5em;
`;

export const Pipeline: FC<{ hops: Hop[]; addButton?: boolean }> = ({ hops, addButton = true }) => {
const scriptContext = useContext(ScriptContext);
const [ref] = useDropTarget<HTMLDivElement>(hops, 0);

return (
<>
{hops.map((hop, i) => (
<PipelineStep parentHops={hops} key={i} hop={hop} />
))}
<Elm className="hop-list" ref={ref}>
{hops.map((hop, i) => (
<PipelineStep parentHops={hops} key={i} hop={hop} />
))}
</Elm>
{addButton ? (
<>
<Picker
Expand Down
34 changes: 19 additions & 15 deletions src/main/frontend/sections/editor/PipelineStep.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import React, { FC } from 'react';

import { useDropTarget } from '../../hooks/useDropTarget';

import { Hop } from '../../model/hops';

import { FallbackStep } from './FallbackStep';
Expand All @@ -20,36 +22,38 @@ import { SetPropertyStep } from './types/SetPropertyStep';
import { TryStep } from './types/TryStep';

export const PipelineStep: FC<{ parentHops: Hop[]; hop: Hop }> = ({ parentHops, hop }) => {
const [ref] = useDropTarget<HTMLDivElement>(parentHops, parentHops.indexOf(hop) + 1);

switch (hop.type) {
case 'childNodes':
return <ChildNodesStep parentHops={parentHops} hop={hop} />;
return <ChildNodesStep parentHops={parentHops} hop={hop} ref={ref} />;
case 'copyNode':
return <CopyNodeStep parentHops={parentHops} hop={hop} />;
return <CopyNodeStep parentHops={parentHops} hop={hop} ref={ref} />;
case 'createChildNode':
return <CreateChildNodeStep parentHops={parentHops} hop={hop} />;
return <CreateChildNodeStep parentHops={parentHops} hop={hop} ref={ref} />;
case 'declare':
return <DeclareStep parentHops={parentHops} hop={hop} />;
return <DeclareStep parentHops={parentHops} hop={hop} ref={ref} />;
case 'each':
return <EachStep parentHops={parentHops} hop={hop} />;
return <EachStep parentHops={parentHops} hop={hop} ref={ref} />;
case 'filterNode':
return <FilterNodeStep parentHops={parentHops} hop={hop} />;
return <FilterNodeStep parentHops={parentHops} hop={hop} ref={ref} />;
case 'moveNode':
return <MoveNodeStep parentHops={parentHops} hop={hop} />;
return <MoveNodeStep parentHops={parentHops} hop={hop} ref={ref} />;
case 'nodeQuery':
return <NodeQueryStep parentHops={parentHops} hop={hop} />;
return <NodeQueryStep parentHops={parentHops} hop={hop} ref={ref} />;
case 'renameProperty':
return <RenamePropertyStep parentHops={parentHops} hop={hop} />;
return <RenamePropertyStep parentHops={parentHops} hop={hop} ref={ref} />;
case 'reorderNode':
return <ReorderNodeStep parentHops={parentHops} hop={hop} />;
return <ReorderNodeStep parentHops={parentHops} hop={hop} ref={ref} />;
case 'resolveNode':
return <ResolveNodeStep parentHops={parentHops} hop={hop} />;
return <ResolveNodeStep parentHops={parentHops} hop={hop} ref={ref} />;
case 'runScript':
return <RunScriptStep parentHops={parentHops} hop={hop} />;
return <RunScriptStep parentHops={parentHops} hop={hop} ref={ref} />;
case 'setProperty':
return <SetPropertyStep parentHops={parentHops} hop={hop} />;
return <SetPropertyStep parentHops={parentHops} hop={hop} ref={ref} />;
case 'try':
return <TryStep parentHops={parentHops} hop={hop} />;
return <TryStep parentHops={parentHops} hop={hop} ref={ref} />;
default:
return <FallbackStep parentHops={parentHops} hop={hop} />;
return <FallbackStep parentHops={parentHops} hop={hop} ref={ref} />;
}
};
14 changes: 10 additions & 4 deletions src/main/frontend/sections/editor/types/ChildNodesStep.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { FC } from 'react';
import React, { forwardRef } from 'react';

import { Hop } from '../../../model/hops';
import { StepEditor } from '../../../widgets/StepEditor';
Expand All @@ -8,9 +8,15 @@ import { Help } from '../../../widgets/Help';
import { Input } from '../../../widgets/Input';
import { Pipeline } from '../Pipeline';

export const ChildNodesStep: FC<{ parentHops: Hop[]; hop: Type }> = ({ parentHops, hop }) => {
export const ChildNodesStep = forwardRef<HTMLDivElement, { parentHops: Hop[]; hop: Type }>(function ChildNodesStep({ parentHops, hop }, ref) {
return (
<StepEditor parentHops={parentHops} hop={hop} title={shortDescription(hop)} pipeline={<Pipeline hops={(hop.hops ??= [])} />}>
<StepEditor
parentHops={parentHops}
hop={hop}
title={shortDescription(hop)}
pipeline={<Pipeline hops={(hop.hops ??= [])} />}
ref={ref}
>
<Input
label="Name Pattern"
value={hop.namePattern ?? ''}
Expand Down Expand Up @@ -48,4 +54,4 @@ export const ChildNodesStep: FC<{ parentHops: Hop[]; hop: Type }> = ({ parentHop
</Help>
</StepEditor>
);
};
});
14 changes: 10 additions & 4 deletions src/main/frontend/sections/editor/types/CopyNodeStep.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { FC } from 'react';
import React, { forwardRef } from 'react';

import { Hop } from '../../../model/hops';
import { StepEditor } from '../../../widgets/StepEditor';
Expand All @@ -9,9 +9,15 @@ import { Input } from '../../../widgets/Input';
import { Pipeline } from '../Pipeline';
import { Conflict } from '../../../widgets/Conflict';

export const CopyNodeStep: FC<{ parentHops: Hop[]; hop: Type }> = ({ parentHops, hop }) => {
export const CopyNodeStep = forwardRef<HTMLDivElement, { parentHops: Hop[]; hop: Type }>(function CopyNodeStep({ parentHops, hop }, ref) {
return (
<StepEditor parentHops={parentHops} hop={hop} title={shortDescription(hop)} pipeline={<Pipeline hops={(hop.hops ??= [])} />}>
<StepEditor
parentHops={parentHops}
hop={hop}
title={shortDescription(hop)}
pipeline={<Pipeline hops={(hop.hops ??= [])} />}
ref={ref}
>
<Input label="New Name" value={hop.newName ?? ''} onChange={newName => (hop.newName = newName)} />
<Conflict
label="If the target node exists"
Expand Down Expand Up @@ -48,4 +54,4 @@ export const CopyNodeStep: FC<{ parentHops: Hop[]; hop: Type }> = ({ parentHops,
</Help>
</StepEditor>
);
};
});
Loading

0 comments on commit 44daf53

Please sign in to comment.