Skip to content

Commit

Permalink
Bring back the context menu (#240)
Browse files Browse the repository at this point in the history
  • Loading branch information
newcat committed Jan 2, 2024
1 parent 65ea0fd commit 476fb0e
Show file tree
Hide file tree
Showing 10 changed files with 418 additions and 301 deletions.
6 changes: 6 additions & 0 deletions packages/renderer-vue/playground/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ baklavaView.settings.enableMinimap = true;
baklavaView.settings.sidebar.resizable = false;
baklavaView.settings.displayValueOnHover = true;
baklavaView.settings.nodes.resizable = true;
baklavaView.settings.contextMenu.additionalItems = [
{ isDivider: true },
{ label: "Copy", command: Commands.COPY_COMMAND },
{ label: "Paste", command: Commands.PASTE_COMMAND },
];
const engine = new DependencyEngine(editor);
engine.events.afterRun.subscribe(token, (r) => {
engine.pause();
Expand Down
230 changes: 102 additions & 128 deletions packages/renderer-vue/src/components/ContextMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@
/>
</svg>
</div>
<context-menu
<ContextMenu
v-if="item.submenu"
:value="activeMenu === index"
:model-value="activeMenu === index"
:items="item.submenu"
:is-nested="true"
:is-flipped="{ x: flippedX, y: flippedY }"
Expand All @@ -40,8 +40,8 @@
</transition>
</template>

<script lang="ts">
import { computed, defineComponent, Ref, ref, watch } from "vue";
<script setup lang="ts">
import { computed, Ref, ref, watch } from "vue";
import { onClickOutside } from "@vueuse/core";
export interface IMenuItem {
Expand All @@ -52,130 +52,104 @@ export interface IMenuItem {
disabled?: boolean | Readonly<Ref<boolean>>;
}
export default defineComponent({
props: {
modelValue: {
type: Boolean,
default: false,
},
items: {
type: Array as () => IMenuItem[],
required: true,
},
x: {
type: Number,
default: 0,
},
y: {
type: Number,
default: 0,
},
isNested: {
type: Boolean,
default: false,
},
isFlipped: {
type: Object as () => { x: boolean; y: boolean },
default: () => ({ x: false, y: false }),
},
flippable: {
type: Boolean,
default: false,
},
},
emits: ["click", "update:modelValue"],
setup(props, { emit }) {
let activeMenuResetTimeout: number | null = null;
const el = ref<HTMLElement | null>(null);
const activeMenu = ref(-1);
const height = ref(0);
const rootIsFlipped = ref({ x: false, y: false });
const flippedX = computed(() => props.flippable && (rootIsFlipped.value.x || props.isFlipped.x));
const flippedY = computed(() => props.flippable && (rootIsFlipped.value.y || props.isFlipped.y));
const styles = computed(() => {
const s: any = {};
if (!props.isNested) {
s.top = (flippedY.value ? props.y - height.value : props.y) + "px";
s.left = props.x + "px";
}
return s;
});
const classes = computed(() => {
return {
"--flipped-x": flippedX.value,
"--flipped-y": flippedY.value,
"--nested": props.isNested,
};
});
const itemsWithHoverProperty = computed(() => props.items.map((i) => ({ ...i, hover: false })));
watch([() => props.y, () => props.items], () => {
height.value = props.items.length * 30;
const parentWidth = el.value?.parentElement?.offsetWidth ?? 0;
const parentHeight = el.value?.parentElement?.offsetHeight ?? 0;
rootIsFlipped.value.x = !props.isNested && props.x > parentWidth * 0.75;
rootIsFlipped.value.y = !props.isNested && props.y + height.value > parentHeight - 20;
});
onClickOutside(el, () => {
if (props.modelValue) {
emit("update:modelValue", false);
}
});
const onClick = (item: IMenuItem) => {
if (!item.submenu && item.value) {
emit("click", item.value);
emit("update:modelValue", false);
}
};
const onChildClick = (value: string) => {
emit("click", value);
activeMenu.value = -1;
if (!props.isNested) {
emit("update:modelValue", false);
}
};
const onMouseEnter = (event: MouseEvent, index: number) => {
if (props.items[index].submenu) {
activeMenu.value = index;
if (activeMenuResetTimeout !== null) {
clearTimeout(activeMenuResetTimeout);
activeMenuResetTimeout = null;
}
}
};
const onMouseLeave = (event: MouseEvent, index: number) => {
if (props.items[index].submenu) {
activeMenuResetTimeout = window.setTimeout(() => {
activeMenu.value = -1;
activeMenuResetTimeout = null;
}, 200);
}
};
return {
el,
activeMenu,
flippedX,
flippedY,
styles,
classes,
itemsWithHoverProperty,
onClick,
onChildClick,
onClickOutside,
onMouseEnter,
onMouseLeave,
};
const props = withDefaults(
defineProps<{
modelValue: boolean;
items: IMenuItem[];
x?: number;
y?: number;
isNested?: boolean;
isFlipped?: { x: boolean; y: boolean };
flippable?: boolean;
}>(),
{
x: 0,
y: 0,
isNested: false,
isFlipped: () => ({ x: false, y: false }),
flippable: false,
},
);
const emit = defineEmits<{
"update:modelValue": [boolean];
"click": [value: string];
}>();
let activeMenuResetTimeout: number | null = null;
const el = ref<HTMLElement | null>(null);
const activeMenu = ref(-1);
const height = ref(0);
const rootIsFlipped = ref({ x: false, y: false });
const flippedX = computed(() => props.flippable && (rootIsFlipped.value.x || props.isFlipped.x));
const flippedY = computed(() => props.flippable && (rootIsFlipped.value.y || props.isFlipped.y));
const styles = computed(() => {
const s: any = {};
if (!props.isNested) {
s.top = (flippedY.value ? props.y - height.value : props.y) + "px";
s.left = props.x + "px";
}
return s;
});
const classes = computed(() => {
return {
"--flipped-x": flippedX.value,
"--flipped-y": flippedY.value,
"--nested": props.isNested,
};
});
const itemsWithHoverProperty = computed(() => props.items.map((i) => ({ ...i, hover: false })));
watch([() => props.y, () => props.items], () => {
height.value = props.items.length * 30;
const parentWidth = el.value?.parentElement?.offsetWidth ?? 0;
const parentHeight = el.value?.parentElement?.offsetHeight ?? 0;
rootIsFlipped.value.x = !props.isNested && props.x > parentWidth * 0.75;
rootIsFlipped.value.y = !props.isNested && props.y + height.value > parentHeight - 20;
});
onClickOutside(el, () => {
if (props.modelValue) {
emit("update:modelValue", false);
}
});
const onClick = (item: IMenuItem) => {
if (!item.submenu && item.value) {
emit("click", item.value);
emit("update:modelValue", false);
}
};
const onChildClick = (value: string) => {
emit("click", value);
activeMenu.value = -1;
if (!props.isNested) {
emit("update:modelValue", false);
}
};
const onMouseEnter = (event: MouseEvent, index: number) => {
if (props.items[index].submenu) {
activeMenu.value = index;
if (activeMenuResetTimeout !== null) {
clearTimeout(activeMenuResetTimeout);
activeMenuResetTimeout = null;
}
}
};
const onMouseLeave = (event: MouseEvent, index: number) => {
if (props.items[index].submenu) {
activeMenuResetTimeout = window.setTimeout(() => {
activeMenu.value = -1;
activeMenuResetTimeout = null;
}, 200);
}
};
</script>
94 changes: 94 additions & 0 deletions packages/renderer-vue/src/contextMenu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { Ref, computed, ref, reactive } from "vue";
import { AbstractNode } from "@baklavajs/core";
import { IMenuItem } from "./components/ContextMenu.vue";
import { IBaklavaViewModel } from "./viewModel";
import { useNodeCategories, useTransform } from "./utility";

export function useContextMenu(viewModel: Ref<IBaklavaViewModel>) {
const show = ref(false);
const x = ref(0);
const y = ref(0);
const categories = useNodeCategories(viewModel);
const { transform } = useTransform();

const nodeItems = computed<IMenuItem[]>(() => {
let defaultNodes: IMenuItem[] = [];
const categoryItems: Record<string, IMenuItem[]> = {};

for (const category of categories.value) {
const mappedNodes = Object.entries(category.nodeTypes).map(([nodeType, info]) => ({
label: info.title,
value: "addNode:" + nodeType,
}));
if (category.name === "default") {
defaultNodes = mappedNodes;
} else {
categoryItems[category.name] = mappedNodes;
}
}

const menuItems: IMenuItem[] = [
...Object.entries(categoryItems).map(([category, items]) => ({
label: category,
submenu: items,
})),
];
if (menuItems.length > 0 && defaultNodes.length > 0) {
menuItems.push({ isDivider: true });
}
menuItems.push(...defaultNodes);

return menuItems;
});

const items = computed<IMenuItem[]>(() => {
if (viewModel.value.settings.contextMenu.additionalItems.length === 0) {
return nodeItems.value;
} else {
return [
{ label: "Add node", submenu: nodeItems.value },
...viewModel.value.settings.contextMenu.additionalItems.map((item) => {
if ("isDivider" in item || "submenu" in item) {
return item;
} else {
return {
label: item.label,
value: "command:" + item.command,
disabled: !viewModel.value.commandHandler.canExecuteCommand(item.command),
};
}
}),
];
}
});

function open(ev: MouseEvent) {
show.value = true;
x.value = ev.offsetX;
y.value = ev.offsetY;
}

function onClick(value: string) {
if (value.startsWith("addNode:")) {
// get node type
const nodeType = value.substring("addNode:".length);
const nodeInformation = viewModel.value.editor.nodeTypes.get(nodeType);
if (!nodeInformation) {
return;
}

const instance = reactive(new nodeInformation.type()) as AbstractNode;
viewModel.value.displayedGraph.addNode(instance);
const [transformedX, transformedY] = transform(x.value, y.value);
instance.position.x = transformedX;
instance.position.y = transformedY;
} else if (value.startsWith("command:")) {
const command = value.substring("command:".length);
if (viewModel.value.commandHandler.canExecuteCommand(command)) {
viewModel.value.commandHandler.executeCommand(command);
}
}
}

return { show, x, y, items, open, onClick };
}
Loading

0 comments on commit 476fb0e

Please sign in to comment.