From c7f5ddacc04a1c4039070f1399b6b3e4471028b3 Mon Sep 17 00:00:00 2001 From: Eugenio Topa Date: Sun, 26 Jan 2025 22:03:48 +0100 Subject: [PATCH 1/3] Started history feature --- src/components/GGanttChart.vue | 28 +++++- src/composables/useRows.ts | 173 ++++++++++++++++++++++++++++++++- src/types/history.ts | 8 ++ src/types/index.ts | 1 + 4 files changed, 203 insertions(+), 7 deletions(-) create mode 100644 src/types/history.ts diff --git a/src/components/GGanttChart.vue b/src/components/GGanttChart.vue index 97946a3..c8a7f45 100644 --- a/src/components/GGanttChart.vue +++ b/src/components/GGanttChart.vue @@ -11,7 +11,9 @@ import { faMagnifyingGlassPlus, faMagnifyingGlassMinus, faExpandAlt, - faCompressAlt + faCompressAlt, + faUndo, + faRedo } from "@fortawesome/free-solid-svg-icons" import { computed, @@ -391,6 +393,7 @@ const emitBarEvent = ( isDragging.value = false emit("dragend-bar", { bar, e, movedBars }) updateBarPositions() + rowManager.onBarMove() break case "contextmenu": emit("contextmenu-bar", { bar, e, datetime }) @@ -673,7 +676,6 @@ provide(GANTT_ID_KEY, id.value) -
+
+ + +
@@ -753,7 +771,8 @@ provide(GANTT_ID_KEY, id.value) .g-gantt-command-fixed, .g-gantt-command-slider, .g-gantt-command-vertical, -.g-gantt-command-zoom { +.g-gantt-command-zoom, +.g-gantt-command-history { display: flex; align-items: center; gap: 2px; @@ -766,7 +785,8 @@ provide(GANTT_ID_KEY, id.value) .g-gantt-command-vertical button:disabled, .g-gantt-command-slider button:disabled, .g-gantt-command-zoom button:disabled, -.g-gantt-command-groups button:disabled { +.g-gantt-command-groups button:disabled, +.g-gantt-command-history button:disabled { opacity: 0.5; cursor: not-allowed; } diff --git a/src/composables/useRows.ts b/src/composables/useRows.ts index 3da8585..7298925 100644 --- a/src/composables/useRows.ts +++ b/src/composables/useRows.ts @@ -1,11 +1,21 @@ -import { ref, computed, type Ref, type Slots, watch } from "vue" +import { + ref, + computed, + type Ref, + type Slots, + watch, + onMounted, + type ComputedRef, + nextTick +} from "vue" import type { ChartRow, GanttBarObject, LabelColumnConfig, LabelColumnField, SortDirection, - SortState + SortState, + HistoryState } from "../types" import dayjs from "dayjs" @@ -28,6 +38,12 @@ export interface UseRowsReturn { resetCustomOrder: () => void expandAllGroups: () => void collapseAllGroups: () => void + canUndo: ComputedRef + canRedo: ComputedRef + undo: () => void + redo: () => void + clearHistory: () => void + onBarMove: () => void } /** @@ -43,6 +59,84 @@ export interface UseRowsProps { onGroupExpansion: (rowId: string | number) => void } +function cloneBarForHistory(bar: GanttBarObject) { + const dynamicKeys = Object.keys(bar).filter((key) => key !== "ganttBarConfig") + + const clonedBar: any = {} + dynamicKeys.forEach((key) => { + clonedBar[key] = bar[key] + }) + + clonedBar.ganttBarConfig = { + ...bar.ganttBarConfig, + style: bar.ganttBarConfig.style ? { ...bar.ganttBarConfig.style } : undefined, + connections: bar.ganttBarConfig.connections?.map((conn) => ({ ...conn })) + } + + return clonedBar as GanttBarObject +} + +function cloneRowsForHistory(rows: ChartRow[]): ChartRow[] { + return rows.map((row) => { + const clonedRow: ChartRow = { + id: row.id, + label: row.label, + bars: row.bars.map(cloneBarForHistory), + children: row.children ? cloneRowsForHistory(row.children) : undefined, + connections: row.connections?.map((conn) => ({ ...conn })) + } + return clonedRow + }) +} + +function restoreBarFromHistory( + historicBar: GanttBarObject, + originalBar: GanttBarObject +): GanttBarObject { + return { + ...historicBar, + ganttBarConfig: { + ...historicBar.ganttBarConfig, + hasHandles: originalBar.ganttBarConfig.hasHandles, + style: historicBar.ganttBarConfig.style ? { ...historicBar.ganttBarConfig.style } : undefined, + connections: historicBar.ganttBarConfig.connections?.map((conn) => ({ ...conn })) + } + } +} + +function restoreRowsFromHistory(historyRows: ChartRow[], originalRows: ChartRow[]): ChartRow[] { + return historyRows.map((historyRow, index) => { + const originalRow = originalRows.find((r) => r.id === historyRow.id) || originalRows[index] + + return { + ...historyRow, + _originalNode: originalRow?._originalNode, + bars: historyRow.bars.map((historyBar, barIndex) => + restoreBarFromHistory(historyBar, originalRow?.bars[barIndex] || historyBar) + ), + children: + historyRow.children && originalRow?.children + ? restoreRowsFromHistory(historyRow.children, originalRow.children) + : historyRow.children + } + }) +} + +function createHistoryState( + rows: ChartRow[], + expandedGroups: Set, + customOrder: Map +): HistoryState { + return { + rows: cloneRowsForHistory(rows), + expandedGroups: new Set(Array.from(expandedGroups)), + customOrder: new Map(customOrder), + timestamp: Date.now() + } +} + +const MAX_HISTORY_STATES = 50 + /** * A composable that manages rows in a Gantt chart, providing sorting, grouping, and row manipulation functionality * @param slots - Vue slots object for accessing slot content @@ -73,6 +167,72 @@ export function useRows( const customOrder = ref>(new Map()) const reorderedRows = ref([]) + const historyStates = ref([]) + const currentHistoryIndex = ref(-1) + + const initializeHistory = () => { + historyStates.value = [ + createHistoryState(reorderedRows.value, expandedGroups.value, customOrder.value) + ] + currentHistoryIndex.value = 0 + } + + onMounted(() => { + initializeHistory() + }) + + const canUndo = computed(() => currentHistoryIndex.value > 0 && historyStates.value.length > 1) + const canRedo = computed( + () => + historyStates.value.length > 1 && currentHistoryIndex.value < historyStates.value.length - 1 + ) + + const addHistoryState = () => { + if (currentHistoryIndex.value < historyStates.value.length - 1) { + historyStates.value = historyStates.value.slice(0, currentHistoryIndex.value + 1) + } + + historyStates.value.push( + createHistoryState(reorderedRows.value, expandedGroups.value, customOrder.value) + ) + currentHistoryIndex.value++ + + if (historyStates.value.length > MAX_HISTORY_STATES) { + const excess = historyStates.value.length - MAX_HISTORY_STATES + historyStates.value = historyStates.value.slice(excess) + currentHistoryIndex.value = Math.max(0, currentHistoryIndex.value - excess) + } + } + + const undo = () => { + if (!canUndo.value) return + + currentHistoryIndex.value-- + const previousState = historyStates.value[currentHistoryIndex.value]! + + reorderedRows.value = restoreRowsFromHistory(previousState.rows, reorderedRows.value) + customOrder.value = new Map(previousState.customOrder) + } + + const redo = () => { + if (!canRedo.value) return + + currentHistoryIndex.value++ + const nextState = historyStates.value[currentHistoryIndex.value]! + + reorderedRows.value = restoreRowsFromHistory(nextState.rows, reorderedRows.value) + customOrder.value = new Map(nextState.customOrder) + } + + const onBarMove = () => { + nextTick(() => { + addHistoryState() + }) + } + + const clearHistory = () => { + initializeHistory() + } /** * Extracts rows data from slots, processing both direct and nested slot contents * @returns Array of ChartRow objects @@ -558,6 +718,7 @@ export function useRows( const updateRows = (newRows: ChartRow[]) => { reorderedRows.value = newRows + addHistoryState() } return { @@ -574,6 +735,12 @@ export function useRows( customOrder, resetCustomOrder, expandAllGroups, - collapseAllGroups + collapseAllGroups, + canUndo, + canRedo, + undo, + redo, + clearHistory, + onBarMove } } diff --git a/src/types/history.ts b/src/types/history.ts new file mode 100644 index 0000000..318322a --- /dev/null +++ b/src/types/history.ts @@ -0,0 +1,8 @@ +import type { ChartRow } from "./chart" + +export interface HistoryState { + rows: ChartRow[] + expandedGroups: Set + customOrder: Map + timestamp: number +} diff --git a/src/types/index.ts b/src/types/index.ts index 9bd5b0b..883a97f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,3 +4,4 @@ export * from "./config" export * from "./events" export * from "./style" export * from "./navigation" +export * from "./history" From 1f47baea5bfad53bf333f358ef24e203a1fa392d Mon Sep 17 00:00:00 2001 From: Eugenio Topa Date: Sun, 26 Jan 2025 23:10:35 +0100 Subject: [PATCH 2/3] Added lodash-es to clone --- package-lock.json | 26 ++++- package.json | 2 + src/components/GGanttChart.vue | 18 ++-- src/composables/useRows.ts | 168 ++++++++++++++++++--------------- 4 files changed, 128 insertions(+), 86 deletions(-) diff --git a/package-lock.json b/package-lock.json index 41fac32..1fa83e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "hy-vue-gantt", - "version": "2.2.0", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hy-vue-gantt", - "version": "2.2.0", + "version": "2.3.0", "license": "MIT", "dependencies": { "@tsconfig/node22": "^22.0.0", + "lodash-es": "^4.17.21", "uuid": "^11.0.5" }, "devDependencies": { @@ -18,6 +19,7 @@ "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", "@senojs/rollup-plugin-style-inject": "^0.2.3", + "@types/lodash-es": "^4.17.12", "@types/node": "^22.10.1", "@types/postcss-preset-env": "^7.7.0", "@vitejs/plugin-vue": "^5.2.1", @@ -2825,6 +2827,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.14", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.14.tgz", + "integrity": "sha512-jsxagdikDiDBeIRaPYtArcT8my4tN1og7MtMRquFT3XNA6axxyHDRUemqDz/taRDdOUn0GnGHRCuff4q48sW9A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/markdown-it": { "version": "12.2.3", "dev": true, @@ -7743,7 +7762,8 @@ }, "node_modules/lodash-es": { "version": "4.17.21", - "dev": true, + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", "license": "MIT" }, "node_modules/lodash.capitalize": { diff --git a/package.json b/package.json index 044d6fb..6cd0ec0 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", "@senojs/rollup-plugin-style-inject": "^0.2.3", + "@types/lodash-es": "^4.17.12", "@types/node": "^22.10.1", "@types/postcss-preset-env": "^7.7.0", "@vitejs/plugin-vue": "^5.2.1", @@ -93,6 +94,7 @@ }, "dependencies": { "@tsconfig/node22": "^22.0.0", + "lodash-es": "^4.17.21", "uuid": "^11.0.5" } } diff --git a/src/components/GGanttChart.vue b/src/components/GGanttChart.vue index c8a7f45..f812599 100644 --- a/src/components/GGanttChart.vue +++ b/src/components/GGanttChart.vue @@ -424,6 +424,16 @@ const updateRangeBackground = () => { } } +const redo = () => { + rowManager.redo() + updateBarPositions() +} + +const undo = () => { + rowManager.undo() + updateBarPositions() +} + // ResizeObserver instance let resizeObserver: ResizeObserver @@ -695,17 +705,13 @@ provide(GANTT_ID_KEY, id.value)
-
diff --git a/src/composables/useRows.ts b/src/composables/useRows.ts index 7298925..cda8950 100644 --- a/src/composables/useRows.ts +++ b/src/composables/useRows.ts @@ -1,13 +1,4 @@ -import { - ref, - computed, - type Ref, - type Slots, - watch, - onMounted, - type ComputedRef, - nextTick -} from "vue" +import { ref, computed, type Ref, type Slots, watch, onMounted, type ComputedRef } from "vue" import type { ChartRow, GanttBarObject, @@ -15,9 +6,12 @@ import type { LabelColumnField, SortDirection, SortState, - HistoryState + HistoryState, + BaseConnection, + GanttBarConfig } from "../types" import dayjs from "dayjs" +import { cloneDeep } from "lodash-es" /** * Interface defining the return object from the useRows composable @@ -59,79 +53,94 @@ export interface UseRowsProps { onGroupExpansion: (rowId: string | number) => void } -function cloneBarForHistory(bar: GanttBarObject) { - const dynamicKeys = Object.keys(bar).filter((key) => key !== "ganttBarConfig") - - const clonedBar: any = {} - dynamicKeys.forEach((key) => { - clonedBar[key] = bar[key] - }) - - clonedBar.ganttBarConfig = { - ...bar.ganttBarConfig, - style: bar.ganttBarConfig.style ? { ...bar.ganttBarConfig.style } : undefined, - connections: bar.ganttBarConfig.connections?.map((conn) => ({ ...conn })) - } +interface CleanBar { + ganttBarConfig: GanttBarConfig + [key: string]: any +} - return clonedBar as GanttBarObject +interface CleanRow { + id?: string | number + label: string + bars: CleanBar[] + connections?: BaseConnection[] + children?: CleanRow[] } -function cloneRowsForHistory(rows: ChartRow[]): ChartRow[] { - return rows.map((row) => { - const clonedRow: ChartRow = { +function createHistoryState( + rows: ChartRow[], + expandedGroups: Set, + customOrder: Map +): HistoryState { + const prepareRowForCloning = (row: ChartRow): CleanRow => { + const cleanRow: CleanRow = { id: row.id, label: row.label, - bars: row.bars.map(cloneBarForHistory), - children: row.children ? cloneRowsForHistory(row.children) : undefined, - connections: row.connections?.map((conn) => ({ ...conn })) + bars: + row.bars?.map((bar) => ({ + ...bar, + ganttBarConfig: { + id: bar.ganttBarConfig.id, + label: bar.ganttBarConfig.label, + html: bar.ganttBarConfig.html, + hasHandles: bar.ganttBarConfig.hasHandles, + immobile: bar.ganttBarConfig.immobile, + bundle: bar.ganttBarConfig.bundle, + pushOnOverlap: bar.ganttBarConfig.pushOnOverlap, + pushOnConnect: bar.ganttBarConfig.pushOnConnect, + style: bar.ganttBarConfig.style, + class: bar.ganttBarConfig.class, + connections: bar.ganttBarConfig.connections, + milestoneId: bar.ganttBarConfig.milestoneId + } + })) || [], + connections: row.connections, + children: row.children?.map(prepareRowForCloning) } - return clonedRow - }) -} -function restoreBarFromHistory( - historicBar: GanttBarObject, - originalBar: GanttBarObject -): GanttBarObject { + return cleanRow + } + + const preparedRows = rows.map(prepareRowForCloning) + return { - ...historicBar, - ganttBarConfig: { - ...historicBar.ganttBarConfig, - hasHandles: originalBar.ganttBarConfig.hasHandles, - style: historicBar.ganttBarConfig.style ? { ...historicBar.ganttBarConfig.style } : undefined, - connections: historicBar.ganttBarConfig.connections?.map((conn) => ({ ...conn })) - } + rows: cloneDeep(preparedRows), + expandedGroups: new Set(expandedGroups), + customOrder: new Map(customOrder), + timestamp: Date.now() } } -function restoreRowsFromHistory(historyRows: ChartRow[], originalRows: ChartRow[]): ChartRow[] { - return historyRows.map((historyRow, index) => { - const originalRow = originalRows.find((r) => r.id === historyRow.id) || originalRows[index] - - return { - ...historyRow, - _originalNode: originalRow?._originalNode, - bars: historyRow.bars.map((historyBar, barIndex) => - restoreBarFromHistory(historyBar, originalRow?.bars[barIndex] || historyBar) - ), - children: - historyRow.children && originalRow?.children - ? restoreRowsFromHistory(historyRow.children, originalRow.children) - : historyRow.children +function restoreState( + state: HistoryState, + originalRows: ChartRow[] +): { + rows: ChartRow[] + expandedGroups: Set + customOrder: Map +} { + const restoreRow = (historyRow: CleanRow, originalRow: ChartRow | undefined): ChartRow => { + const restored = cloneDeep(historyRow) as ChartRow + + if (originalRow) { + restored._originalNode = originalRow._originalNode } - }) -} -function createHistoryState( - rows: ChartRow[], - expandedGroups: Set, - customOrder: Map -): HistoryState { + if (restored.children && originalRow?.children) { + restored.children = restored.children.map((child, index) => + restoreRow(child, originalRow.children![index]) + ) + } + + return restored + } + return { - rows: cloneRowsForHistory(rows), - expandedGroups: new Set(Array.from(expandedGroups)), - customOrder: new Map(customOrder), - timestamp: Date.now() + rows: state.rows.map((historyRow) => { + const originalRow = originalRows.find((r) => r.id === historyRow.id) + return restoreRow(historyRow as CleanRow, originalRow) + }), + expandedGroups: new Set(state.expandedGroups), + customOrder: new Map(state.customOrder) } } @@ -195,6 +204,7 @@ export function useRows( historyStates.value.push( createHistoryState(reorderedRows.value, expandedGroups.value, customOrder.value) ) + currentHistoryIndex.value++ if (historyStates.value.length > MAX_HISTORY_STATES) { @@ -210,8 +220,11 @@ export function useRows( currentHistoryIndex.value-- const previousState = historyStates.value[currentHistoryIndex.value]! - reorderedRows.value = restoreRowsFromHistory(previousState.rows, reorderedRows.value) - customOrder.value = new Map(previousState.customOrder) + // Ripristiniamo lo stato completo in un'unica operazione + const restored = restoreState(previousState, reorderedRows.value) + + reorderedRows.value = restored.rows + customOrder.value = restored.customOrder } const redo = () => { @@ -220,14 +233,15 @@ export function useRows( currentHistoryIndex.value++ const nextState = historyStates.value[currentHistoryIndex.value]! - reorderedRows.value = restoreRowsFromHistory(nextState.rows, reorderedRows.value) - customOrder.value = new Map(nextState.customOrder) + // Ripristiniamo lo stato completo in un'unica operazione + const restored = restoreState(nextState, reorderedRows.value) + + reorderedRows.value = restored.rows + customOrder.value = restored.customOrder } const onBarMove = () => { - nextTick(() => { - addHistoryState() - }) + addHistoryState() } const clearHistory = () => { From 08d7bda6af2782cc8b5c6219fff95cc34eda848f Mon Sep 17 00:00:00 2001 From: Eugenio Topa Date: Mon, 27 Jan 2025 23:19:39 +0100 Subject: [PATCH 3/3] feat: History mode, undo and redo of actions --- src/components/GGanttChart.vue | 70 ++++- src/composables/useColumnTouchResize.ts | 33 ++ src/composables/useRowTouchDrag.ts | 62 +++- src/composables/useRows.ts | 381 ++++++++++++++++++++++-- src/composables/useTouchEvents.ts | 50 ++++ 5 files changed, 558 insertions(+), 38 deletions(-) diff --git a/src/components/GGanttChart.vue b/src/components/GGanttChart.vue index f812599..7bd298e 100644 --- a/src/components/GGanttChart.vue +++ b/src/components/GGanttChart.vue @@ -45,7 +45,7 @@ import { useConnections } from "../composables/useConnections" import { useTooltip } from "../composables/useTooltip" import { useChartNavigation } from "../composables/useChartNavigation" import { useKeyboardNavigation } from "../composables/useKeyboardNavigation" -import { useRows } from "../composables/useRows" +import { useRows, findBarInRows } from "../composables/useRows" // Types and Constants import { colorSchemes, type ColorSchemeKey } from "../color-schemes" @@ -424,13 +424,73 @@ const updateRangeBackground = () => { } } -const redo = () => { - rowManager.redo() +const undo = () => { + const changes = rowManager.undo() + if (!changes) return + + changes.rowChanges.forEach((rowChange) => { + emit("row-drop", { + sourceRow: rowChange.sourceRow, + targetRow: undefined, + newIndex: rowChange.newIndex, + parentId: rowChange.newParentId + }) + }) + + changes.barChanges.forEach((barChange) => { + const bar = findBarInRows(rowManager.rows.value, barChange.barId) + if (!bar) return + + emit("dragend-bar", { + bar, + e: new MouseEvent("mouseup"), + movedBars: new Map([ + [ + bar, + { + oldStart: barChange.newStart!, + oldEnd: barChange.newEnd! + } + ] + ]) + }) + }) + updateBarPositions() } -const undo = () => { - rowManager.undo() +const redo = () => { + const changes = rowManager.redo() + if (!changes) return + + changes.rowChanges.forEach((rowChange) => { + emit("row-drop", { + sourceRow: rowChange.sourceRow, + targetRow: undefined, + newIndex: rowChange.newIndex, + parentId: rowChange.newParentId + }) + }) + + changes.barChanges.forEach((barChange) => { + const bar = findBarInRows(rowManager.rows.value, barChange.barId) + if (!bar) return + + emit("dragend-bar", { + bar, + e: new MouseEvent("mouseup"), + movedBars: new Map([ + [ + bar, + { + oldStart: barChange.oldStart!, + oldEnd: barChange.oldEnd! + } + ] + ]) + }) + }) + updateBarPositions() } diff --git a/src/composables/useColumnTouchResize.ts b/src/composables/useColumnTouchResize.ts index f284f51..7e29618 100644 --- a/src/composables/useColumnTouchResize.ts +++ b/src/composables/useColumnTouchResize.ts @@ -1,5 +1,8 @@ import { ref } from "vue" +/** + * Interface defining the state for touch-based column resizing + */ interface TouchResizeState { isResizing: boolean startX: number @@ -7,6 +10,11 @@ interface TouchResizeState { initialWidth: number } +/** + * A composable that manages touch-based column resizing functionality + * Handles touch events and width calculations for responsive column sizing + * @returns Object containing resize state and event handlers + */ export function useColumnTouchResize() { const touchState = ref({ isResizing: false, @@ -15,6 +23,10 @@ export function useColumnTouchResize() { initialWidth: 0 }) + /** + * Resets resize state to initial values + * Called when resize operation ends or is cancelled + */ const resetTouchState = () => { touchState.value = { isResizing: false, @@ -24,6 +36,13 @@ export function useColumnTouchResize() { } } + /** + * Initializes touch resize operation + * Sets up initial positions and state for resizing + * @param e - Touch event that started the resize + * @param column - Column being resized + * @param currentWidth - Current width of the column + */ const handleTouchStart = (e: TouchEvent, column: string, currentWidth: number) => { const touch = e.touches[0] if (!touch) return @@ -38,6 +57,12 @@ export function useColumnTouchResize() { } } + /** + * Handles ongoing touch resize movement + * Calculates and applies new column width + * @param e - Touch move event + * @param onResize - Callback function to update column width + */ const handleTouchMove = (e: TouchEvent, onResize: (column: string, newWidth: number) => void) => { const touch = e.touches[0] if (!touch || !touchState.value.isResizing) return @@ -52,12 +77,20 @@ export function useColumnTouchResize() { } } + /** + * Finalizes touch resize operation + * Cleans up state and event listeners + */ const handleTouchEnd = () => { if (touchState.value.isResizing) { resetTouchState() } } + /** + * Handles touch cancel event + * Behaves same as touch end + */ const handleTouchCancel = handleTouchEnd return { diff --git a/src/composables/useRowTouchDrag.ts b/src/composables/useRowTouchDrag.ts index 1776005..faff798 100644 --- a/src/composables/useRowTouchDrag.ts +++ b/src/composables/useRowTouchDrag.ts @@ -1,6 +1,9 @@ -import { ref } from 'vue' -import type { ChartRow } from '../types' +import { ref } from "vue" +import type { ChartRow } from "../types" +/** + * Interface defining the state for touch-based row dragging + */ interface TouchDragState { isDragging: boolean startY: number @@ -8,12 +11,17 @@ interface TouchDragState { draggedRow: ChartRow | null dropTarget: { row: ChartRow | null - position: 'before' | 'after' | 'child' + position: "before" | "after" | "child" } dragElement: HTMLElement | null initialTransform: string } +/** + * A composable that manages touch-based drag and drop functionality for rows + * Handles touch events, visual feedback, and drop position detection + * @returns Object containing touch state and event handlers + */ export function useRowTouchDrag() { const touchState = ref({ isDragging: false, @@ -22,12 +30,16 @@ export function useRowTouchDrag() { draggedRow: null, dropTarget: { row: null, - position: 'before' + position: "before" }, dragElement: null, - initialTransform: '' + initialTransform: "" }) + /** + * Resets touch drag state and restores original element position + * Called when drag operation ends or is cancelled + */ const resetTouchState = () => { if (touchState.value.dragElement) { touchState.value.dragElement.style.transform = touchState.value.initialTransform @@ -40,13 +52,20 @@ export function useRowTouchDrag() { draggedRow: null, dropTarget: { row: null, - position: 'before' + position: "before" }, dragElement: null, - initialTransform: '' + initialTransform: "" } } + /** + * Initializes touch drag operation + * Sets up initial positions and state for dragging + * @param event - Touch event that started the drag + * @param row - Row being dragged + * @param element - DOM element being dragged + */ const handleTouchStart = (event: TouchEvent, row: ChartRow, element: HTMLElement) => { const touch = event.touches[0] if (!touch) return @@ -56,7 +75,7 @@ export function useRowTouchDrag() { event.preventDefault() } }, 100) - + touchState.value = { isDragging: true, startY: touch.clientY, @@ -64,13 +83,20 @@ export function useRowTouchDrag() { draggedRow: row, dropTarget: { row: null, - position: 'before' + position: "before" }, dragElement: element, - initialTransform: element.style.transform || '' + initialTransform: element.style.transform || "" } } + /** + * Handles ongoing touch drag movement + * Updates visual position and calculates drop targets + * @param event - Touch move event + * @param targetRow - Row being dragged over + * @param rowElement - DOM element being dragged over + */ const handleTouchMove = (event: TouchEvent, targetRow: ChartRow, rowElement: HTMLElement) => { const touch = event.touches[0] if (!touch || !touchState.value.isDragging || !touchState.value.dragElement) return @@ -80,7 +106,7 @@ export function useRowTouchDrag() { const deltaY = touch.clientY - touchState.value.startY touchState.value.dragElement.style.transform = `translateY(${deltaY}px)` - + const rect = rowElement.getBoundingClientRect() const relativeY = touch.clientY - rect.top const position = relativeY / rect.height @@ -88,21 +114,27 @@ export function useRowTouchDrag() { if (touchState.value.draggedRow !== targetRow) { if (targetRow.children?.length) { if (position < 0.25) { - touchState.value.dropTarget = { row: targetRow, position: 'before' } + touchState.value.dropTarget = { row: targetRow, position: "before" } } else if (position > 0.75) { - touchState.value.dropTarget = { row: targetRow, position: 'after' } + touchState.value.dropTarget = { row: targetRow, position: "after" } } else { - touchState.value.dropTarget = { row: targetRow, position: 'child' } + touchState.value.dropTarget = { row: targetRow, position: "child" } } } else { touchState.value.dropTarget = { row: targetRow, - position: position < 0.5 ? 'before' : 'after' + position: position < 0.5 ? "before" : "after" } } } } + /** + * Finalizes touch drag operation + * Determines final drop position and triggers updates + * @param event - Touch end event + * @returns Object containing drag result information or null if invalid + */ const handleTouchEnd = (event: TouchEvent) => { if (!touchState.value.isDragging) return null diff --git a/src/composables/useRows.ts b/src/composables/useRows.ts index cda8950..e193b1e 100644 --- a/src/composables/useRows.ts +++ b/src/composables/useRows.ts @@ -7,8 +7,7 @@ import type { SortDirection, SortState, HistoryState, - BaseConnection, - GanttBarConfig + BaseConnection } from "../types" import dayjs from "dayjs" import { cloneDeep } from "lodash-es" @@ -34,8 +33,8 @@ export interface UseRowsReturn { collapseAllGroups: () => void canUndo: ComputedRef canRedo: ComputedRef - undo: () => void - redo: () => void + undo: () => HistoryChange + redo: () => HistoryChange clearHistory: () => void onBarMove: () => void } @@ -53,24 +52,57 @@ export interface UseRowsProps { onGroupExpansion: (rowId: string | number) => void } -interface CleanBar { - ganttBarConfig: GanttBarConfig - [key: string]: any -} - interface CleanRow { id?: string | number label: string - bars: CleanBar[] + bars: GanttBarObject[] connections?: BaseConnection[] children?: CleanRow[] } +interface BarHistoryChange { + barId: string + rowId: string | number + oldStart?: string + newStart?: string + oldEnd?: string + newEnd?: string +} + +interface RowHistoryChange { + type: "reorder" | "group" + sourceRow: ChartRow + targetRow?: ChartRow + oldIndex: number + newIndex: number + oldParentId?: string | number + newParentId?: string | number +} + +interface HistoryChange { + rowChanges: RowHistoryChange[] + barChanges: BarHistoryChange[] +} + +/** + * Creates a snapshot of the current chart state for history tracking + * Captures rows, expanded groups, and custom ordering + * @param rows - Current rows in the chart + * @param expandedGroups - Set of currently expanded group IDs + * @param customOrder - Map of current row ordering + * @returns HistoryState object containing current chart state + */ function createHistoryState( rows: ChartRow[], expandedGroups: Set, customOrder: Map ): HistoryState { + /** + * Helper function that creates a deep copy of a row's state for history tracking + * Cleans and normalizes row data for storage + * @param row - Row to prepare for cloning + * @returns Cleaned row object ready for history + */ const prepareRowForCloning = (row: ChartRow): CleanRow => { const cleanRow: CleanRow = { id: row.id, @@ -110,6 +142,13 @@ function createHistoryState( } } +/** + * Restores a previous state from history + * Reconstructs rows with their original nodes and maintains component references + * @param state - Historical state to restore + * @param originalRows - Current rows to merge with historical data + * @returns Object containing restored rows, groups and ordering + */ function restoreState( state: HistoryState, originalRows: ChartRow[] @@ -118,6 +157,13 @@ function restoreState( expandedGroups: Set customOrder: Map } { + /** + * Restores a single row from history state + * Preserves original component references while updating data + * @param historyRow - Row data from history + * @param originalRow - Current row instance to merge with + * @returns Restored row with preserved component references + */ const restoreRow = (historyRow: CleanRow, originalRow: ChartRow | undefined): ChartRow => { const restored = cloneDeep(historyRow) as ChartRow @@ -144,6 +190,253 @@ function restoreState( } } +/** + * Finds the parent ID for a row at a given path + * Used for tracking hierarchy changes in row movement + * @param rows - Array of rows to search + * @param path - Array of indices representing path to row + * @returns ID of parent row or undefined if at root + */ +function findParentId(rows: ChartRow[], path: number[]): string | number | undefined { + if (path.length <= 1) return undefined + let current = rows[path[0]!] + for (let i = 1; i < path.length - 1; i++) { + if (!current?.children) return undefined + current = current.children[path[i]!] + } + return current?.id +} + +/** + * Recursively searches for a bar by ID through all rows + * @param rows - Array of rows to search + * @param barId - ID of bar to find + * @returns Found bar object or null if not found + */ +export function findBarInRows(rows: ChartRow[], barId: string): GanttBarObject | null { + for (const row of rows) { + const found = row.bars.find((bar) => bar.ganttBarConfig.id === barId) + if (found) return found + + if (row.children) { + const foundInChildren = findBarInRows(row.children, barId) + if (foundInChildren) return foundInChildren + } + } + return null +} + +/** + * Recursively searches for a row by ID + * @param rows - Array of rows to search + * @param id - ID of row to find + * @returns Found row or null if not found + */ +function findRowById(rows: ChartRow[], id: string | number): ChartRow | null { + for (const row of rows) { + if (row.id === id) return row + if (row.children) { + const found = findRowById(row.children, id) + if (found) return found + } + } + return null +} + +/** + * Finds the path of indices to a row with given ID + * @param rows - Array of rows to search + * @param id - ID of row to find path for + * @returns Array of indices representing path to row + */ +function findRowPath(rows: ChartRow[], id: string | number): number[] { + for (let i = 0; i < rows.length; i++) { + if (rows[i]!.id === id) return [i] + if (rows[i]!.children) { + const childPath = findRowPath(rows[i]!.children!, id) + if (childPath.length) return [i, ...childPath] + } + } + return [] +} + +/** + * Finds the row ID that contains a specific bar + * @param barId - ID of bar to locate + * @param rows - Array of rows to search + * @returns ID of row containing the bar or empty string if not found + */ +function findRowIdForBar(barId: string, rows: ChartRow[]): string | number { + function searchInRows(rows: ChartRow[]): string | number | null { + for (const row of rows) { + if (row.bars.some((bar) => bar.ganttBarConfig.id === barId)) { + return row.id! + } + if (row.children) { + const foundId = searchInRows(row.children) + if (foundId) return foundId + } + } + return null + } + return searchInRows(rows) || "" +} + +/** + * Extracts all bars from a historical state + * Creates a map for efficient bar lookup + * @param state - Historical state to extract bars from + * @returns Map of bar IDs to bar objects + */ +function getAllBarsFromState(state: HistoryState): Map { + const barsMap = new Map() + + function collectBars(rows: ChartRow[]) { + rows.forEach((row) => { + row.bars.forEach((bar) => { + barsMap.set(bar.ganttBarConfig.id, bar) + }) + if (row.children) { + collectBars(row.children) + } + }) + } + + collectBars(state.rows) + return barsMap +} + +/** + * Compares two versions of a bar to detect changes + * @param oldBar - Previous version of bar + * @param newBar - Current version of bar + * @param barStart - Reference to start time property + * @param barEnd - Reference to end time property + * @param rows - Reference to current rows + * @returns Change object or null if no changes + */ +function compareBarStates( + oldBar: GanttBarObject, + newBar: GanttBarObject, + barStart: Ref, + barEnd: Ref, + rows: Ref +): BarHistoryChange | null { + if ( + oldBar[barStart.value] === newBar[barStart.value] && + oldBar[barEnd.value] === newBar[barEnd.value] + ) { + return null + } + + return { + barId: oldBar.ganttBarConfig.id, + rowId: findRowIdForBar(oldBar.ganttBarConfig.id, rows.value), + oldStart: oldBar[barStart.value], + newStart: newBar[barStart.value], + oldEnd: oldBar[barEnd.value], + newEnd: newBar[barEnd.value] + } +} + +/** + * Compares all bars between two states to detect changes + * @param oldState - Previous chart state + * @param newState - Current chart state + * @param barStart - Reference to start time property + * @param barEnd - Reference to end time property + * @param rows - Reference to current rows + * @returns Array of detected bar changes + */ +function compareAllBars( + oldState: HistoryState, + newState: HistoryState, + barStart: Ref, + barEnd: Ref, + rows: Ref +): BarHistoryChange[] { + const changes: BarHistoryChange[] = [] + + const oldBars = getAllBarsFromState(oldState) + const newBars = getAllBarsFromState(newState) + + oldBars.forEach((oldBar, barId) => { + const newBar = newBars.get(barId) + if (newBar) { + const change = compareBarStates(oldBar, newBar, barStart, barEnd, rows) + if (change) { + changes.push(change) + } + } + }) + + return changes +} + +/** + * Calculates all changes between two chart states + * Includes both row position changes and bar modifications + * @param prevState - Previous chart state + * @param newState - Current chart state + * @param barStart - Reference to start time property + * @param barEnd - Reference to end time property + * @param rows - Reference to current rows + * @returns Object containing all detected changes + */ +function calculateHistoryChanges( + prevState: HistoryState, + newState: HistoryState, + barStart: Ref, + barEnd: Ref, + rows: Ref +): HistoryChange { + const rowChanges: RowHistoryChange[] = [] + + /** + * Compares row positions between states to detect structural changes + * Used as part of history change calculation + * @param oldRows - Previous row arrangement + * @returns Array of detected row position changes + */ + function compareRows(oldRows: ChartRow[]) { + for (const oldRow of oldRows) { + if (!oldRow.id) continue + + const oldPath = findRowPath(prevState.rows, oldRow.id) + const newPath = findRowPath(newState.rows, oldRow.id) + + if (oldPath.length !== newPath.length || !oldPath.every((v, i) => v === newPath[i])) { + const oldParentId = oldPath.length > 1 ? findParentId(prevState.rows, oldPath) : undefined + const newParentId = newPath.length > 1 ? findParentId(newState.rows, newPath) : undefined + + rowChanges.push({ + type: oldParentId !== newParentId ? "group" : "reorder", + sourceRow: oldRow, + oldIndex: oldPath[oldPath.length - 1] as number, + newIndex: newPath[newPath.length - 1] as number, + oldParentId, + newParentId + }) + } + + if (oldRow.children) { + const newRow = findRowById(newState.rows, oldRow.id) + if (newRow?.children) { + compareRows(oldRow.children) + } + } + } + } + + compareRows(prevState.rows) + const barChanges = compareAllBars(prevState, newState, barStart, barEnd, rows) + + return { + rowChanges, + barChanges + } +} + const MAX_HISTORY_STATES = 50 /** @@ -179,6 +472,10 @@ export function useRows( const historyStates = ref([]) const currentHistoryIndex = ref(-1) + /** + * Initializes history tracking for the chart + * Creates first history entry with current state + */ const initializeHistory = () => { historyStates.value = [ createHistoryState(reorderedRows.value, expandedGroups.value, customOrder.value) @@ -196,6 +493,10 @@ export function useRows( historyStates.value.length > 1 && currentHistoryIndex.value < historyStates.value.length - 1 ) + /** + * Adds a new state to the history stack + * Handles history size limits and current position + */ const addHistoryState = () => { if (currentHistoryIndex.value < historyStates.value.length - 1) { historyStates.value = historyStates.value.slice(0, currentHistoryIndex.value + 1) @@ -214,36 +515,56 @@ export function useRows( } } + /** + * Reverts chart to previous state + * Handles both row and bar changes + * @returns Object containing reverted changes + */ const undo = () => { - if (!canUndo.value) return - + const currentState = historyStates.value[currentHistoryIndex.value]! currentHistoryIndex.value-- const previousState = historyStates.value[currentHistoryIndex.value]! - // Ripristiniamo lo stato completo in un'unica operazione - const restored = restoreState(previousState, reorderedRows.value) + const changes = calculateHistoryChanges(currentState, previousState, barStart, barEnd, rows) + const restored = restoreState(previousState, reorderedRows.value) reorderedRows.value = restored.rows customOrder.value = restored.customOrder + + return changes } + /** + * Reapplies previously undone changes + * Handles both row and bar changes + * @returns Object containing reapplied changes + */ const redo = () => { - if (!canRedo.value) return - + const currentState = historyStates.value[currentHistoryIndex.value]! currentHistoryIndex.value++ const nextState = historyStates.value[currentHistoryIndex.value]! - // Ripristiniamo lo stato completo in un'unica operazione - const restored = restoreState(nextState, reorderedRows.value) + const changes = calculateHistoryChanges(currentState, nextState, barStart, barEnd, rows) + const restored = restoreState(nextState, reorderedRows.value) reorderedRows.value = restored.rows customOrder.value = restored.customOrder + + return changes } + /** + * Records bar movement in history + * Called after bar position changes are completed + */ const onBarMove = () => { addHistoryState() } + /** + * Resets history tracking + * Clears all recorded states except current + */ const clearHistory = () => { initializeHistory() } @@ -289,6 +610,11 @@ export function useRows( return rows } + /** + * Retrieves source rows from props or slots + * Handles both dynamic and static row sources + * @returns Array of source rows + */ const getSourceRows = () => { if (initialRows?.value?.length) { return initialRows.value @@ -524,6 +850,11 @@ export function useRows( return ["Id", "Label", "StartDate", "EndDate", "Duration"].includes(field) } + /** + * Applies custom row ordering when sorting is disabled + * @param rowsToSort - Rows to apply custom order to + * @returns Reordered array of rows + */ const applyCustomOrder = (rowsToSort: ChartRow[]): ChartRow[] => { if (customOrder.value.size === 0 || sortState.value.direction !== "none") { return rowsToSort @@ -544,6 +875,12 @@ export function useRows( if (!sourceRows.length) return sourceRows + /** + * Processes rows to include group bars + * Calculates synthetic bars for group rows based on children + * @param rows - Rows to process + * @returns Processed rows with group bars + */ const processRowsWithGroupBars = (rows: ChartRow[]): ChartRow[] => { return rows.map((row) => { if (row.children?.length) { @@ -575,6 +912,10 @@ export function useRows( return sourceRows }) + /** + * Resets custom row ordering + * Clears stored order information + */ const resetCustomOrder = () => { customOrder.value.clear() } @@ -730,6 +1071,10 @@ export function useRows( */ const getChartRows = () => rows.value + /** + * Updates rows and records change in history + * @param newRows - New rows to update with + */ const updateRows = (newRows: ChartRow[]) => { reorderedRows.value = newRows addHistoryState() diff --git a/src/composables/useTouchEvents.ts b/src/composables/useTouchEvents.ts index eb5081c..8b1c3e9 100644 --- a/src/composables/useTouchEvents.ts +++ b/src/composables/useTouchEvents.ts @@ -1,6 +1,9 @@ import { ref } from "vue" import type { GanttBarObject } from "../types" +/** + * Interface defining the state for touch event handling + */ interface TouchState { isDragging: boolean startX: number @@ -11,6 +14,13 @@ interface TouchState { dragTarget: "bar" | "leftHandle" | "rightHandle" | null } +/** + * A composable that manages touch event handling and mouse event simulation + * Converts touch interactions to mouse events for consistent behavior + * @param initDragCallback - Function to initialize drag operations + * @param threshold - Minimum movement threshold to start drag + * @returns Object containing touch event handlers + */ export function useTouchEvents( initDragCallback: (bar: GanttBarObject, e: MouseEvent) => void, threshold: number = 5 @@ -25,6 +35,10 @@ export function useTouchEvents( dragTarget: null }) + /** + * Resets touch state to initial values + * Called when touch interaction ends or is cancelled + */ const resetTouchState = () => { touchState.value = { isDragging: false, @@ -37,6 +51,11 @@ export function useTouchEvents( } } + /** + * Determines what part of a bar is being touched + * @param element - DOM element being touched + * @returns Type of drag target or null if invalid + */ const determineDragTarget = ( element: HTMLElement ): "bar" | "leftHandle" | "rightHandle" | null => { @@ -56,6 +75,14 @@ export function useTouchEvents( return null } + /** + * Creates a synthetic mouse event from a touch event + * @param touch - Touch event to convert + * @param eventType - Type of mouse event to create + * @param movementX - Optional X movement value + * @param movementY - Optional Y movement value + * @returns Synthetic mouse event + */ const createMouseEventFromTouch = ( touch: Touch, eventType: "mousedown" | "mousemove" | "mouseup", @@ -84,6 +111,13 @@ export function useTouchEvents( return mouseEvent } + /** + * Handles the start of a touch interaction + * Initializes drag state and creates synthetic mouse down event + * @param event - Touch start event + * @param bar - Bar being touched + * @returns Synthetic mouse event or undefined if invalid + */ const handleTouchStart = (event: TouchEvent, bar?: GanttBarObject) => { const touch = event.touches[0] if (!touch || !bar) return @@ -110,6 +144,12 @@ export function useTouchEvents( initDragCallback(bar, mouseEvent) } + /** + * Handles ongoing touch movement + * Creates synthetic mouse move events when threshold is met + * @param event - Touch move event + * @returns Synthetic mouse event or undefined if invalid + */ const handleTouchMove = (event: TouchEvent) => { const touch = event.touches[0] if (!touch || !touchState.value.currentBar) return @@ -135,6 +175,12 @@ export function useTouchEvents( } } + /** + * Finalizes touch interaction + * Creates synthetic mouse up event + * @param event - Touch end event + * @returns Synthetic mouse event or undefined if invalid + */ const handleTouchEnd = (event: TouchEvent) => { const touch = event.changedTouches[0] if (!touch || !touchState.value.currentBar) return @@ -148,6 +194,10 @@ export function useTouchEvents( resetTouchState() } + /** + * Handles touch cancel event + * Behaves same as touch end + */ const handleTouchCancel = handleTouchEnd return {