Browse Source
* wip group manage * prototyping ui * tweaks * wip * wip * more wip * fixes add deletion * Fix tests * fixes * Remove test code * typo * fix crash when link is invalidpull/2544/head
pythongosssss
10 months ago
committed by
GitHub
8 changed files with 1007 additions and 49 deletions
@ -0,0 +1,149 @@
|
||||
.comfy-group-manage { |
||||
background: var(--bg-color); |
||||
color: var(--fg-color); |
||||
padding: 0; |
||||
font-family: Arial, Helvetica, sans-serif; |
||||
border-color: black; |
||||
margin: 20vh auto; |
||||
max-height: 60vh; |
||||
} |
||||
.comfy-group-manage-outer { |
||||
max-height: 60vh; |
||||
min-width: 500px; |
||||
display: flex; |
||||
flex-direction: column; |
||||
} |
||||
.comfy-group-manage-outer > header { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 10px; |
||||
justify-content: space-between; |
||||
background: var(--comfy-menu-bg); |
||||
padding: 15px 20px; |
||||
} |
||||
.comfy-group-manage-outer > header select { |
||||
background: var(--comfy-input-bg); |
||||
border: 1px solid var(--border-color); |
||||
color: var(--input-text); |
||||
padding: 5px 10px; |
||||
border-radius: 5px; |
||||
} |
||||
.comfy-group-manage h2 { |
||||
margin: 0; |
||||
font-weight: normal; |
||||
} |
||||
.comfy-group-manage main { |
||||
display: flex; |
||||
overflow: hidden; |
||||
} |
||||
.comfy-group-manage .drag-handle { |
||||
font-weight: bold; |
||||
} |
||||
.comfy-group-manage-list { |
||||
border-right: 1px solid var(--comfy-menu-bg); |
||||
} |
||||
.comfy-group-manage-list ul { |
||||
margin: 40px 0 0; |
||||
padding: 0; |
||||
list-style: none; |
||||
} |
||||
.comfy-group-manage-list-items { |
||||
max-height: 70vh; |
||||
overflow-y: scroll; |
||||
overflow-x: hidden; |
||||
} |
||||
.comfy-group-manage-list li { |
||||
display: flex; |
||||
padding: 10px 20px 10px 10px; |
||||
cursor: pointer; |
||||
align-items: center; |
||||
gap: 5px; |
||||
} |
||||
.comfy-group-manage-list div { |
||||
display: flex; |
||||
flex-direction: column; |
||||
} |
||||
.comfy-group-manage-list li:not(.selected):hover div { |
||||
text-decoration: underline; |
||||
} |
||||
.comfy-group-manage-list li.selected { |
||||
background: var(--border-color); |
||||
} |
||||
.comfy-group-manage-list li span { |
||||
opacity: 0.7; |
||||
font-size: smaller; |
||||
} |
||||
.comfy-group-manage-node { |
||||
flex: auto; |
||||
background: var(--border-color); |
||||
display: flex; |
||||
flex-direction: column; |
||||
} |
||||
.comfy-group-manage-node > div { |
||||
overflow: auto; |
||||
} |
||||
.comfy-group-manage-node header { |
||||
display: flex; |
||||
background: var(--bg-color); |
||||
height: 40px; |
||||
} |
||||
.comfy-group-manage-node header a { |
||||
text-align: center; |
||||
flex: auto; |
||||
border-right: 1px solid var(--comfy-menu-bg); |
||||
border-bottom: 1px solid var(--comfy-menu-bg); |
||||
padding: 10px; |
||||
cursor: pointer; |
||||
font-size: 15px; |
||||
} |
||||
.comfy-group-manage-node header a:last-child { |
||||
border-right: none; |
||||
} |
||||
.comfy-group-manage-node header a:not(.active):hover { |
||||
text-decoration: underline; |
||||
} |
||||
.comfy-group-manage-node header a.active { |
||||
background: var(--border-color); |
||||
border-bottom: none; |
||||
} |
||||
.comfy-group-manage-node-page { |
||||
display: none; |
||||
overflow: auto; |
||||
} |
||||
.comfy-group-manage-node-page.active { |
||||
display: block; |
||||
} |
||||
.comfy-group-manage-node-page div { |
||||
padding: 10px; |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 10px; |
||||
} |
||||
.comfy-group-manage-node-page input { |
||||
border: none; |
||||
color: var(--input-text); |
||||
background: var(--comfy-input-bg); |
||||
padding: 5px 10px; |
||||
} |
||||
.comfy-group-manage-node-page input[type="text"] { |
||||
flex: auto; |
||||
} |
||||
.comfy-group-manage-node-page label { |
||||
display: flex; |
||||
gap: 5px; |
||||
align-items: center; |
||||
} |
||||
.comfy-group-manage footer { |
||||
border-top: 1px solid var(--comfy-menu-bg); |
||||
padding: 10px; |
||||
display: flex; |
||||
gap: 10px; |
||||
} |
||||
.comfy-group-manage footer button { |
||||
font-size: 14px; |
||||
padding: 5px 10px; |
||||
border-radius: 0; |
||||
} |
||||
.comfy-group-manage footer button:first-child { |
||||
margin-right: auto; |
||||
} |
@ -0,0 +1,422 @@
|
||||
import { $el, ComfyDialog } from "../../scripts/ui.js"; |
||||
import { DraggableList } from "../../scripts/ui/draggableList.js"; |
||||
import { addStylesheet } from "../../scripts/utils.js"; |
||||
import { GroupNodeConfig, GroupNodeHandler } from "./groupNode.js"; |
||||
|
||||
addStylesheet(import.meta.url); |
||||
|
||||
const ORDER = Symbol(); |
||||
|
||||
function merge(target, source) { |
||||
if (typeof target === "object" && typeof source === "object") { |
||||
for (const key in source) { |
||||
const sv = source[key]; |
||||
if (typeof sv === "object") { |
||||
let tv = target[key]; |
||||
if (!tv) tv = target[key] = {}; |
||||
merge(tv, source[key]); |
||||
} else { |
||||
target[key] = sv; |
||||
} |
||||
} |
||||
} |
||||
|
||||
return target; |
||||
} |
||||
|
||||
export class ManageGroupDialog extends ComfyDialog { |
||||
/** @type { Record<"Inputs" | "Outputs" | "Widgets", {tab: HTMLAnchorElement, page: HTMLElement}> } */ |
||||
tabs = {}; |
||||
/** @type { number | null | undefined } */ |
||||
selectedNodeIndex; |
||||
/** @type { keyof ManageGroupDialog["tabs"] } */ |
||||
selectedTab = "Inputs"; |
||||
/** @type { string | undefined } */ |
||||
selectedGroup; |
||||
|
||||
/** @type { Record<string, Record<string, Record<string, { name?: string | undefined, visible?: boolean | undefined }>>> } */ |
||||
modifications = {}; |
||||
|
||||
get selectedNodeInnerIndex() { |
||||
return +this.nodeItems[this.selectedNodeIndex].dataset.nodeindex; |
||||
} |
||||
|
||||
constructor(app) { |
||||
super(); |
||||
this.app = app; |
||||
this.element = $el("dialog.comfy-group-manage", { |
||||
parent: document.body, |
||||
}); |
||||
} |
||||
|
||||
changeTab(tab) { |
||||
this.tabs[this.selectedTab].tab.classList.remove("active"); |
||||
this.tabs[this.selectedTab].page.classList.remove("active"); |
||||
this.tabs[tab].tab.classList.add("active"); |
||||
this.tabs[tab].page.classList.add("active"); |
||||
this.selectedTab = tab; |
||||
} |
||||
|
||||
changeNode(index, force) { |
||||
if (!force && this.selectedNodeIndex === index) return; |
||||
|
||||
if (this.selectedNodeIndex != null) { |
||||
this.nodeItems[this.selectedNodeIndex].classList.remove("selected"); |
||||
} |
||||
this.nodeItems[index].classList.add("selected"); |
||||
this.selectedNodeIndex = index; |
||||
|
||||
if (!this.buildInputsPage() && this.selectedTab === "Inputs") { |
||||
this.changeTab("Widgets"); |
||||
} |
||||
if (!this.buildWidgetsPage() && this.selectedTab === "Widgets") { |
||||
this.changeTab("Outputs"); |
||||
} |
||||
if (!this.buildOutputsPage() && this.selectedTab === "Outputs") { |
||||
this.changeTab("Inputs"); |
||||
} |
||||
|
||||
this.changeTab(this.selectedTab); |
||||
} |
||||
|
||||
getGroupData() { |
||||
this.groupNodeType = LiteGraph.registered_node_types["workflow/" + this.selectedGroup]; |
||||
this.groupNodeDef = this.groupNodeType.nodeData; |
||||
this.groupData = GroupNodeHandler.getGroupData(this.groupNodeType); |
||||
} |
||||
|
||||
changeGroup(group, reset = true) { |
||||
this.selectedGroup = group; |
||||
this.getGroupData(); |
||||
|
||||
const nodes = this.groupData.nodeData.nodes; |
||||
this.nodeItems = nodes.map((n, i) => |
||||
$el( |
||||
"li.draggable-item", |
||||
{ |
||||
dataset: { |
||||
nodeindex: n.index + "", |
||||
}, |
||||
onclick: () => { |
||||
this.changeNode(i); |
||||
}, |
||||
}, |
||||
[ |
||||
$el("span.drag-handle"), |
||||
$el( |
||||
"div", |
||||
{ |
||||
textContent: n.title ?? n.type, |
||||
}, |
||||
n.title |
||||
? $el("span", { |
||||
textContent: n.type, |
||||
}) |
||||
: [] |
||||
), |
||||
] |
||||
) |
||||
); |
||||
|
||||
this.innerNodesList.replaceChildren(...this.nodeItems); |
||||
|
||||
if (reset) { |
||||
this.selectedNodeIndex = null; |
||||
this.changeNode(0); |
||||
} else { |
||||
const items = this.draggable.getAllItems(); |
||||
let index = items.findIndex(item => item.classList.contains("selected")); |
||||
if(index === -1) index = this.selectedNodeIndex; |
||||
this.changeNode(index, true); |
||||
} |
||||
|
||||
const ordered = [...nodes]; |
||||
this.draggable?.dispose(); |
||||
this.draggable = new DraggableList(this.innerNodesList, "li"); |
||||
this.draggable.addEventListener("dragend", ({ detail: { oldPosition, newPosition } }) => { |
||||
if (oldPosition === newPosition) return; |
||||
ordered.splice(newPosition, 0, ordered.splice(oldPosition, 1)[0]); |
||||
for (let i = 0; i < ordered.length; i++) { |
||||
this.storeModification({ nodeIndex: ordered[i].index, section: ORDER, prop: "order", value: i }); |
||||
} |
||||
}); |
||||
} |
||||
|
||||
storeModification({ nodeIndex, section, prop, value }) { |
||||
const groupMod = (this.modifications[this.selectedGroup] ??= {}); |
||||
const nodesMod = (groupMod.nodes ??= {}); |
||||
const nodeMod = (nodesMod[nodeIndex ?? this.selectedNodeInnerIndex] ??= {}); |
||||
const typeMod = (nodeMod[section] ??= {}); |
||||
if (typeof value === "object") { |
||||
const objMod = (typeMod[prop] ??= {}); |
||||
Object.assign(objMod, value); |
||||
} else { |
||||
typeMod[prop] = value; |
||||
} |
||||
} |
||||
|
||||
getEditElement(section, prop, value, placeholder, checked, checkable = true) { |
||||
if (value === placeholder) value = ""; |
||||
|
||||
const mods = this.modifications[this.selectedGroup]?.nodes?.[this.selectedNodeInnerIndex]?.[section]?.[prop]; |
||||
if (mods) { |
||||
if (mods.name != null) { |
||||
value = mods.name; |
||||
} |
||||
if (mods.visible != null) { |
||||
checked = mods.visible; |
||||
} |
||||
} |
||||
|
||||
return $el("div", [ |
||||
$el("input", { |
||||
value, |
||||
placeholder, |
||||
type: "text", |
||||
onchange: (e) => { |
||||
this.storeModification({ section, prop, value: { name: e.target.value } }); |
||||
}, |
||||
}), |
||||
$el("label", { textContent: "Visible" }, [ |
||||
$el("input", { |
||||
type: "checkbox", |
||||
checked, |
||||
disabled: !checkable, |
||||
onchange: (e) => { |
||||
this.storeModification({ section, prop, value: { visible: !!e.target.checked } }); |
||||
}, |
||||
}), |
||||
]), |
||||
]); |
||||
} |
||||
|
||||
buildWidgetsPage() { |
||||
const widgets = this.groupData.oldToNewWidgetMap[this.selectedNodeInnerIndex]; |
||||
const items = Object.keys(widgets ?? {}); |
||||
const type = app.graph.extra.groupNodes[this.selectedGroup]; |
||||
const config = type.config?.[this.selectedNodeInnerIndex]?.input; |
||||
this.widgetsPage.replaceChildren( |
||||
...items.map((oldName) => { |
||||
return this.getEditElement("input", oldName, widgets[oldName], oldName, config?.[oldName]?.visible !== false); |
||||
}) |
||||
); |
||||
return !!items.length; |
||||
} |
||||
|
||||
buildInputsPage() { |
||||
const inputs = this.groupData.nodeInputs[this.selectedNodeInnerIndex]; |
||||
const items = Object.keys(inputs ?? {}); |
||||
const type = app.graph.extra.groupNodes[this.selectedGroup]; |
||||
const config = type.config?.[this.selectedNodeInnerIndex]?.input; |
||||
this.inputsPage.replaceChildren( |
||||
...items |
||||
.map((oldName) => { |
||||
let value = inputs[oldName]; |
||||
if (!value) { |
||||
return; |
||||
} |
||||
|
||||
return this.getEditElement("input", oldName, value, oldName, config?.[oldName]?.visible !== false); |
||||
}) |
||||
.filter(Boolean) |
||||
); |
||||
return !!items.length; |
||||
} |
||||
|
||||
buildOutputsPage() { |
||||
const nodes = this.groupData.nodeData.nodes; |
||||
const innerNodeDef = this.groupData.getNodeDef(nodes[this.selectedNodeInnerIndex]); |
||||
const outputs = innerNodeDef?.output ?? []; |
||||
const groupOutputs = this.groupData.oldToNewOutputMap[this.selectedNodeInnerIndex]; |
||||
|
||||
const type = app.graph.extra.groupNodes[this.selectedGroup]; |
||||
const config = type.config?.[this.selectedNodeInnerIndex]?.output; |
||||
const node = this.groupData.nodeData.nodes[this.selectedNodeInnerIndex]; |
||||
const checkable = node.type !== "PrimitiveNode"; |
||||
this.outputsPage.replaceChildren( |
||||
...outputs |
||||
.map((type, slot) => { |
||||
const groupOutputIndex = groupOutputs?.[slot]; |
||||
const oldName = innerNodeDef.output_name?.[slot] ?? type; |
||||
let value = config?.[slot]?.name; |
||||
const visible = config?.[slot]?.visible || groupOutputIndex != null; |
||||
if (!value || value === oldName) { |
||||
value = ""; |
||||
} |
||||
return this.getEditElement("output", slot, value, oldName, visible, checkable); |
||||
}) |
||||
.filter(Boolean) |
||||
); |
||||
return !!outputs.length; |
||||
} |
||||
|
||||
show(type) { |
||||
const groupNodes = Object.keys(app.graph.extra?.groupNodes ?? {}).sort((a, b) => a.localeCompare(b)); |
||||
|
||||
this.innerNodesList = $el("ul.comfy-group-manage-list-items"); |
||||
this.widgetsPage = $el("section.comfy-group-manage-node-page"); |
||||
this.inputsPage = $el("section.comfy-group-manage-node-page"); |
||||
this.outputsPage = $el("section.comfy-group-manage-node-page"); |
||||
const pages = $el("div", [this.widgetsPage, this.inputsPage, this.outputsPage]); |
||||
|
||||
this.tabs = [ |
||||
["Inputs", this.inputsPage], |
||||
["Widgets", this.widgetsPage], |
||||
["Outputs", this.outputsPage], |
||||
].reduce((p, [name, page]) => { |
||||
p[name] = { |
||||
tab: $el("a", { |
||||
onclick: () => { |
||||
this.changeTab(name); |
||||
}, |
||||
textContent: name, |
||||
}), |
||||
page, |
||||
}; |
||||
return p; |
||||
}, {}); |
||||
|
||||
const outer = $el("div.comfy-group-manage-outer", [ |
||||
$el("header", [ |
||||
$el("h2", "Group Nodes"), |
||||
$el( |
||||
"select", |
||||
{ |
||||
onchange: (e) => { |
||||
this.changeGroup(e.target.value); |
||||
}, |
||||
}, |
||||
groupNodes.map((g) => |
||||
$el("option", { |
||||
textContent: g, |
||||
selected: "workflow/" + g === type, |
||||
value: g, |
||||
}) |
||||
) |
||||
), |
||||
]), |
||||
$el("main", [ |
||||
$el("section.comfy-group-manage-list", this.innerNodesList), |
||||
$el("section.comfy-group-manage-node", [ |
||||
$el( |
||||
"header", |
||||
Object.values(this.tabs).map((t) => t.tab) |
||||
), |
||||
pages, |
||||
]), |
||||
]), |
||||
$el("footer", [ |
||||
$el( |
||||
"button.comfy-btn", |
||||
{ |
||||
onclick: (e) => { |
||||
const node = app.graph._nodes.find((n) => n.type === "workflow/" + this.selectedGroup); |
||||
if (node) { |
||||
alert("This group node is in use in the current workflow, please first remove these."); |
||||
return; |
||||
} |
||||
if (confirm(`Are you sure you want to remove the node: "${this.selectedGroup}"`)) { |
||||
delete app.graph.extra.groupNodes[this.selectedGroup]; |
||||
LiteGraph.unregisterNodeType("workflow/" + this.selectedGroup); |
||||
} |
||||
this.show(); |
||||
}, |
||||
}, |
||||
"Delete Group Node" |
||||
), |
||||
$el( |
||||
"button.comfy-btn", |
||||
{ |
||||
onclick: async () => { |
||||
let nodesByType; |
||||
let recreateNodes = []; |
||||
const types = {}; |
||||
for (const g in this.modifications) { |
||||
const type = app.graph.extra.groupNodes[g]; |
||||
let config = (type.config ??= {}); |
||||
|
||||
let nodeMods = this.modifications[g]?.nodes; |
||||
if (nodeMods) { |
||||
const keys = Object.keys(nodeMods); |
||||
if (nodeMods[keys[0]][ORDER]) { |
||||
// If any node is reordered, they will all need sequencing
|
||||
const orderedNodes = []; |
||||
const orderedMods = {}; |
||||
const orderedConfig = {}; |
||||
|
||||
for (const n of keys) { |
||||
const order = nodeMods[n][ORDER].order; |
||||
orderedNodes[order] = type.nodes[+n]; |
||||
orderedMods[order] = nodeMods[n]; |
||||
orderedNodes[order].index = order; |
||||
} |
||||
|
||||
// Rewrite links
|
||||
for (const l of type.links) { |
||||
if (l[0] != null) l[0] = type.nodes[l[0]].index; |
||||
if (l[2] != null) l[2] = type.nodes[l[2]].index; |
||||
} |
||||
|
||||
// Rewrite externals
|
||||
if (type.external) { |
||||
for (const ext of type.external) { |
||||
ext[0] = type.nodes[ext[0]]; |
||||
} |
||||
} |
||||
|
||||
// Rewrite modifications
|
||||
for (const id of keys) { |
||||
if (config[id]) { |
||||
orderedConfig[type.nodes[id].index] = config[id]; |
||||
} |
||||
delete config[id]; |
||||
} |
||||
|
||||
type.nodes = orderedNodes; |
||||
nodeMods = orderedMods; |
||||
type.config = config = orderedConfig; |
||||
} |
||||
|
||||
merge(config, nodeMods); |
||||
} |
||||
|
||||
types[g] = type; |
||||
|
||||
if (!nodesByType) { |
||||
nodesByType = app.graph._nodes.reduce((p, n) => { |
||||
p[n.type] ??= []; |
||||
p[n.type].push(n); |
||||
return p; |
||||
}, {}); |
||||
} |
||||
|
||||
const nodes = nodesByType["workflow/" + g]; |
||||
if (nodes) recreateNodes.push(...nodes); |
||||
} |
||||
|
||||
await GroupNodeConfig.registerFromWorkflow(types, {}); |
||||
|
||||
for (const node of recreateNodes) { |
||||
node.recreate(); |
||||
} |
||||
|
||||
this.modifications = {}; |
||||
this.app.graph.setDirtyCanvas(true, true); |
||||
this.changeGroup(this.selectedGroup, false); |
||||
}, |
||||
}, |
||||
"Save" |
||||
), |
||||
$el("button.comfy-btn", { onclick: () => this.element.close() }, "Close"), |
||||
]), |
||||
]); |
||||
|
||||
this.element.replaceChildren(outer); |
||||
this.changeGroup(type ? groupNodes.find((g) => "workflow/" + g === type) : groupNodes[0]); |
||||
this.element.showModal(); |
||||
|
||||
this.element.addEventListener("close", () => { |
||||
this.draggable?.dispose(); |
||||
}); |
||||
} |
||||
} |
@ -0,0 +1,287 @@
|
||||
// @ts-check
|
||||
/* |
||||
Original implementation: |
||||
https://github.com/TahaSh/drag-to-reorder
|
||||
MIT License |
||||
|
||||
Copyright (c) 2023 Taha Shashtari |
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy |
||||
of this software and associated documentation files (the "Software"), to deal |
||||
in the Software without restriction, including without limitation the rights |
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
||||
copies of the Software, and to permit persons to whom the Software is |
||||
furnished to do so, subject to the following conditions: |
||||
|
||||
The above copyright notice and this permission notice shall be included in all |
||||
copies or substantial portions of the Software. |
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
||||
SOFTWARE. |
||||
*/ |
||||
|
||||
import { $el } from "../ui.js"; |
||||
|
||||
$el("style", { |
||||
parent: document.head, |
||||
textContent: ` |
||||
.draggable-item { |
||||
position: relative; |
||||
will-change: transform; |
||||
user-select: none; |
||||
} |
||||
.draggable-item.is-idle { |
||||
transition: 0.25s ease transform; |
||||
} |
||||
.draggable-item.is-draggable { |
||||
z-index: 10; |
||||
} |
||||
` |
||||
}); |
||||
|
||||
export class DraggableList extends EventTarget { |
||||
listContainer; |
||||
draggableItem; |
||||
pointerStartX; |
||||
pointerStartY; |
||||
scrollYMax; |
||||
itemsGap = 0; |
||||
items = []; |
||||
itemSelector; |
||||
handleClass = "drag-handle"; |
||||
off = []; |
||||
offDrag = []; |
||||
|
||||
constructor(element, itemSelector) { |
||||
super(); |
||||
this.listContainer = element; |
||||
this.itemSelector = itemSelector; |
||||
|
||||
if (!this.listContainer) return; |
||||
|
||||
this.off.push(this.on(this.listContainer, "mousedown", this.dragStart)); |
||||
this.off.push(this.on(this.listContainer, "touchstart", this.dragStart)); |
||||
this.off.push(this.on(document, "mouseup", this.dragEnd)); |
||||
this.off.push(this.on(document, "touchend", this.dragEnd)); |
||||
} |
||||
|
||||
getAllItems() { |
||||
if (!this.items?.length) { |
||||
this.items = Array.from(this.listContainer.querySelectorAll(this.itemSelector)); |
||||
this.items.forEach((element) => { |
||||
element.classList.add("is-idle"); |
||||
}); |
||||
} |
||||
return this.items; |
||||
} |
||||
|
||||
getIdleItems() { |
||||
return this.getAllItems().filter((item) => item.classList.contains("is-idle")); |
||||
} |
||||
|
||||
isItemAbove(item) { |
||||
return item.hasAttribute("data-is-above"); |
||||
} |
||||
|
||||
isItemToggled(item) { |
||||
return item.hasAttribute("data-is-toggled"); |
||||
} |
||||
|
||||
on(source, event, listener, options) { |
||||
listener = listener.bind(this); |
||||
source.addEventListener(event, listener, options); |
||||
return () => source.removeEventListener(event, listener); |
||||
} |
||||
|
||||
dragStart(e) { |
||||
if (e.target.classList.contains(this.handleClass)) { |
||||
this.draggableItem = e.target.closest(this.itemSelector); |
||||
} |
||||
|
||||
if (!this.draggableItem) return; |
||||
|
||||
this.pointerStartX = e.clientX || e.touches[0].clientX; |
||||
this.pointerStartY = e.clientY || e.touches[0].clientY; |
||||
this.scrollYMax = this.listContainer.scrollHeight - this.listContainer.clientHeight; |
||||
|
||||
this.setItemsGap(); |
||||
this.initDraggableItem(); |
||||
this.initItemsState(); |
||||
|
||||
this.offDrag.push(this.on(document, "mousemove", this.drag)); |
||||
this.offDrag.push(this.on(document, "touchmove", this.drag, { passive: false })); |
||||
|
||||
this.dispatchEvent( |
||||
new CustomEvent("dragstart", { |
||||
detail: { element: this.draggableItem, position: this.getAllItems().indexOf(this.draggableItem) }, |
||||
}) |
||||
); |
||||
} |
||||
|
||||
setItemsGap() { |
||||
if (this.getIdleItems().length <= 1) { |
||||
this.itemsGap = 0; |
||||
return; |
||||
} |
||||
|
||||
const item1 = this.getIdleItems()[0]; |
||||
const item2 = this.getIdleItems()[1]; |
||||
|
||||
const item1Rect = item1.getBoundingClientRect(); |
||||
const item2Rect = item2.getBoundingClientRect(); |
||||
|
||||
this.itemsGap = Math.abs(item1Rect.bottom - item2Rect.top); |
||||
} |
||||
|
||||
initItemsState() { |
||||
this.getIdleItems().forEach((item, i) => { |
||||
if (this.getAllItems().indexOf(this.draggableItem) > i) { |
||||
item.dataset.isAbove = ""; |
||||
} |
||||
}); |
||||
} |
||||
|
||||
initDraggableItem() { |
||||
this.draggableItem.classList.remove("is-idle"); |
||||
this.draggableItem.classList.add("is-draggable"); |
||||
} |
||||
|
||||
drag(e) { |
||||
if (!this.draggableItem) return; |
||||
|
||||
e.preventDefault(); |
||||
|
||||
const clientX = e.clientX || e.touches[0].clientX; |
||||
const clientY = e.clientY || e.touches[0].clientY; |
||||
|
||||
const listRect = this.listContainer.getBoundingClientRect(); |
||||
|
||||
if (clientY > listRect.bottom) { |
||||
if (this.listContainer.scrollTop < this.scrollYMax) { |
||||
this.listContainer.scrollBy(0, 10); |
||||
this.pointerStartY -= 10; |
||||
} |
||||
} else if (clientY < listRect.top && this.listContainer.scrollTop > 0) { |
||||
this.pointerStartY += 10; |
||||
this.listContainer.scrollBy(0, -10); |
||||
} |
||||
|
||||
const pointerOffsetX = clientX - this.pointerStartX; |
||||
const pointerOffsetY = clientY - this.pointerStartY; |
||||
|
||||
this.updateIdleItemsStateAndPosition(); |
||||
this.draggableItem.style.transform = `translate(${pointerOffsetX}px, ${pointerOffsetY}px)`; |
||||
} |
||||
|
||||
updateIdleItemsStateAndPosition() { |
||||
const draggableItemRect = this.draggableItem.getBoundingClientRect(); |
||||
const draggableItemY = draggableItemRect.top + draggableItemRect.height / 2; |
||||
|
||||
// Update state
|
||||
this.getIdleItems().forEach((item) => { |
||||
const itemRect = item.getBoundingClientRect(); |
||||
const itemY = itemRect.top + itemRect.height / 2; |
||||
if (this.isItemAbove(item)) { |
||||
if (draggableItemY <= itemY) { |
||||
item.dataset.isToggled = ""; |
||||
} else { |
||||
delete item.dataset.isToggled; |
||||
} |
||||
} else { |
||||
if (draggableItemY >= itemY) { |
||||
item.dataset.isToggled = ""; |
||||
} else { |
||||
delete item.dataset.isToggled; |
||||
} |
||||
} |
||||
}); |
||||
|
||||
// Update position
|
||||
this.getIdleItems().forEach((item) => { |
||||
if (this.isItemToggled(item)) { |
||||
const direction = this.isItemAbove(item) ? 1 : -1; |
||||
item.style.transform = `translateY(${direction * (draggableItemRect.height + this.itemsGap)}px)`; |
||||
} else { |
||||
item.style.transform = ""; |
||||
} |
||||
}); |
||||
} |
||||
|
||||
dragEnd() { |
||||
if (!this.draggableItem) return; |
||||
|
||||
this.applyNewItemsOrder(); |
||||
this.cleanup(); |
||||
} |
||||
|
||||
applyNewItemsOrder() { |
||||
const reorderedItems = []; |
||||
|
||||
let oldPosition = -1; |
||||
this.getAllItems().forEach((item, index) => { |
||||
if (item === this.draggableItem) { |
||||
oldPosition = index; |
||||
return; |
||||
} |
||||
if (!this.isItemToggled(item)) { |
||||
reorderedItems[index] = item; |
||||
return; |
||||
} |
||||
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1; |
||||
reorderedItems[newIndex] = item; |
||||
}); |
||||
|
||||
for (let index = 0; index < this.getAllItems().length; index++) { |
||||
const item = reorderedItems[index]; |
||||
if (typeof item === "undefined") { |
||||
reorderedItems[index] = this.draggableItem; |
||||
} |
||||
} |
||||
|
||||
reorderedItems.forEach((item) => { |
||||
this.listContainer.appendChild(item); |
||||
}); |
||||
|
||||
this.items = reorderedItems; |
||||
|
||||
this.dispatchEvent( |
||||
new CustomEvent("dragend", { |
||||
detail: { element: this.draggableItem, oldPosition, newPosition: reorderedItems.indexOf(this.draggableItem) }, |
||||
}) |
||||
); |
||||
} |
||||
|
||||
cleanup() { |
||||
this.itemsGap = 0; |
||||
this.items = []; |
||||
this.unsetDraggableItem(); |
||||
this.unsetItemState(); |
||||
|
||||
this.offDrag.forEach((f) => f()); |
||||
this.offDrag = []; |
||||
} |
||||
|
||||
unsetDraggableItem() { |
||||
this.draggableItem.style = null; |
||||
this.draggableItem.classList.remove("is-draggable"); |
||||
this.draggableItem.classList.add("is-idle"); |
||||
this.draggableItem = null; |
||||
} |
||||
|
||||
unsetItemState() { |
||||
this.getIdleItems().forEach((item, i) => { |
||||
delete item.dataset.isAbove; |
||||
delete item.dataset.isToggled; |
||||
item.style.transform = ""; |
||||
}); |
||||
} |
||||
|
||||
dispose() { |
||||
this.off.forEach((f) => f()); |
||||
} |
||||
} |
Loading…
Reference in new issue