@dou-ui/gantt 0.0.0-stage → 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +10 -2
- package/dist/collaboration/index.js +323 -0
- package/dist/collaboration/index.js.map +1 -0
- package/dist/collaboration/peers.js +78 -0
- package/dist/collaboration/peers.js.map +1 -0
- package/dist/components/cell-editor.js +6 -0
- package/dist/components/cell-editor.js.map +1 -0
- package/dist/components/cell-editor.vue_vue_type_script_setup_true_lang.js +114 -0
- package/dist/components/cell-editor.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/gantt-menu.js +6 -0
- package/dist/components/gantt-menu.js.map +1 -0
- package/dist/components/gantt-menu.vue_vue_type_script_setup_true_lang.js +40 -0
- package/dist/components/gantt-menu.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/time-grid.js +6 -0
- package/dist/components/time-grid.js.map +1 -0
- package/dist/components/time-grid.vue_vue_type_script_setup_true_lang.js +32 -0
- package/dist/components/time-grid.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/time-header.js +6 -0
- package/dist/components/time-header.js.map +1 -0
- package/dist/components/time-header.vue_vue_type_script_setup_true_lang.js +60 -0
- package/dist/components/time-header.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/context.js +12 -0
- package/dist/context.js.map +1 -0
- package/dist/core/baseline.js +48 -0
- package/dist/core/baseline.js.map +1 -0
- package/dist/core/calendar.js +191 -0
- package/dist/core/calendar.js.map +1 -0
- package/dist/core/csv.js +424 -0
- package/dist/core/csv.js.map +1 -0
- package/dist/core/document.js +231 -0
- package/dist/core/document.js.map +1 -0
- package/dist/core/extension.js +42 -0
- package/dist/core/extension.js.map +1 -0
- package/dist/core/geometry.js +112 -0
- package/dist/core/geometry.js.map +1 -0
- package/dist/core/history.js +70 -0
- package/dist/core/history.js.map +1 -0
- package/dist/core/links.js +72 -0
- package/dist/core/links.js.map +1 -0
- package/dist/core/model.js +52 -0
- package/dist/core/model.js.map +1 -0
- package/dist/core/order.js +34 -0
- package/dist/core/order.js.map +1 -0
- package/dist/core/scale.js +118 -0
- package/dist/core/scale.js.map +1 -0
- package/dist/core/schedule.js +235 -0
- package/dist/core/schedule.js.map +1 -0
- package/dist/core/time.js +81 -0
- package/dist/core/time.js.map +1 -0
- package/dist/core/transaction.js +371 -0
- package/dist/core/transaction.js.map +1 -0
- package/dist/core/tree.js +114 -0
- package/dist/core/tree.js.map +1 -0
- package/dist/export/draw.js +214 -0
- package/dist/export/draw.js.map +1 -0
- package/dist/export/theme.js +131 -0
- package/dist/export/theme.js.map +1 -0
- package/dist/gantt.js +6 -0
- package/dist/gantt.js.map +1 -0
- package/dist/gantt.vue_vue_type_script_setup_true_lang.js +2774 -0
- package/dist/gantt.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/labels.js +87 -0
- package/dist/labels.js.map +1 -0
- package/dist/locale.js +355 -0
- package/dist/locale.js.map +1 -0
- package/dist/scheduler/index.js +8 -0
- package/dist/scheduler/index.js.map +1 -0
- package/dist/scheduler/layout.js +97 -0
- package/dist/scheduler/layout.js.map +1 -0
- package/dist/scheduler/scheduler.js +6 -0
- package/dist/scheduler/scheduler.js.map +1 -0
- package/dist/scheduler/scheduler.vue_vue_type_script_setup_true_lang.js +1359 -0
- package/dist/scheduler/scheduler.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/scheduler/style.d.ts +1 -0
- package/dist/scheduler/style.js +2 -0
- package/dist/style.css +1 -0
- package/dist/style.d.ts +1 -0
- package/dist/style.js +4 -0
- package/dist/time-axis.js +337 -0
- package/dist/time-axis.js.map +1 -0
- package/dist/types/collaboration/index.d.ts +39 -0
- package/dist/types/collaboration/peers.d.ts +22 -0
- package/dist/types/components/cell-editor.vue.d.ts +21 -0
- package/dist/types/components/gantt-menu.vue.d.ts +18 -0
- package/dist/types/components/time-grid.vue.d.ts +12 -0
- package/dist/types/components/time-header.vue.d.ts +23 -0
- package/dist/types/context.d.ts +64 -0
- package/dist/types/core/baseline.d.ts +14 -0
- package/dist/types/core/calendar.d.ts +75 -0
- package/dist/types/core/csv.d.ts +77 -0
- package/dist/types/core/document.d.ts +51 -0
- package/dist/types/core/extension.d.ts +88 -0
- package/dist/types/core/geometry.d.ts +33 -0
- package/dist/types/core/history.d.ts +19 -0
- package/dist/types/core/links.d.ts +20 -0
- package/dist/types/core/menu.d.ts +27 -0
- package/dist/types/core/model.d.ts +72 -0
- package/dist/types/core/order.d.ts +9 -0
- package/dist/types/core/scale.d.ts +33 -0
- package/dist/types/core/schedule.d.ts +44 -0
- package/dist/types/core/time.d.ts +39 -0
- package/dist/types/core/transaction.d.ts +134 -0
- package/dist/types/core/tree.d.ts +30 -0
- package/dist/types/export/draw.d.ts +76 -0
- package/dist/types/export/theme.d.ts +49 -0
- package/dist/types/gantt.vue.d.ts +54 -0
- package/dist/types/index.d.ts +148 -0
- package/dist/types/labels.d.ts +24 -0
- package/dist/types/locale.d.ts +161 -0
- package/dist/types/scheduler/index.d.ts +102 -0
- package/dist/types/scheduler/layout.d.ts +58 -0
- package/dist/types/scheduler/scheduler.vue.d.ts +46 -0
- package/dist/types/scheduler/types.d.ts +109 -0
- package/dist/types/time-axis.d.ts +80 -0
- package/dist/types/types.d.ts +305 -0
- package/package.json +79 -4
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scheduler.js","names":["_sfc_main"],"sources":["../../src/scheduler/scheduler.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { ChevronDownIcon, ChevronRightIcon, TrashIcon } from '@dou-ui/icons'\nimport {\n holdPointer,\n matchesHotkey,\n useElementSize,\n useLocale,\n type ContextMenuItem,\n type PointerHold,\n} from 'dou-ui'\nimport {\n computed,\n nextTick,\n onBeforeUnmount,\n onMounted,\n ref,\n shallowRef,\n toRaw,\n useId,\n useTemplateRef,\n watch,\n} from 'vue'\nimport GanttMenu from '../components/gantt-menu.vue'\nimport TimeGrid from '../components/time-grid.vue'\nimport TimeHeader from '../components/time-header.vue'\nimport { resolveCalendar } from '../core/calendar'\nimport { GanttHistory } from '../core/history'\nimport { readDocument } from '../core/document'\nimport { spanOf, writeSpan, type GanttTask, type TaskSpan } from '../core/model'\nimport { snapStep, type GanttScale } from '../core/scale'\nimport { MINUTES_PER_DAY, parseTime } from '../core/time'\nimport {\n emptyDocument,\n GanttTransaction,\n type GanttDocument,\n type GanttTransactionMeta,\n} from '../core/transaction'\nimport {\n dateLabel,\n datesLabel,\n durationLabel,\n endLabel,\n markerRoom,\n spokenDuration,\n} from '../labels'\nimport { format, useGanttMessages } from '../locale'\nimport { useTimeAxis } from '../time-axis'\nimport {\n groupByResource,\n loadSegments,\n type SchedulerResource,\n type SchedulerLoadSegment,\n type SchedulerItem,\n} from './layout'\nimport type {\n SchedulerEmits,\n SchedulerExpose,\n SchedulerMenuTarget,\n SchedulerMove,\n SchedulerProps,\n SchedulerRange,\n SchedulerSlots,\n} from './types'\n\ndefineOptions({ name: 'DouScheduler' })\n\nconst props = withDefaults(defineProps<SchedulerProps>(), {\n today: true,\n table: true,\n tableWidth: 200,\n rowHeight: 36,\n load: true,\n menu: true,\n})\nconst model = defineModel<GanttDocument>({ default: emptyDocument })\n/** The `v-model`, with a wrongly shaped one read as well as it can be. */\nconst source = computed(() => readDocument(model.value, false))\nconst tasks = computed(() => source.value.tasks)\nconst selection = defineModel<string[]>('selection', { default: () => [] })\nconst scale = defineModel<GanttScale>('scale')\nconst emit = defineEmits<SchedulerEmits>()\ndefineSlots<SchedulerSlots>()\n\nconst messages = useGanttMessages(() => props.messages)\nconst sm = computed(() => messages.value.scheduler)\nconst { locale } = useLocale()\nconst weekStart = computed(() => props.weekStart ?? locale.value.region.weekStart)\nconst calendar = computed(() => resolveCalendar(props.calendar))\nconst editable = computed(() => !props.readonly)\n\n/** Height of the two-row time header, in px. */\nconst HEADER = 56\n/** The distance from one lane to the next, in px. */\nconst LANE = 28\n/** Without the list, room above a resource's lanes for its name, in px. */\nconst CAPTION = 18\nconst OVERSCAN = 240\n/** Below this width the list steps aside and the timeline takes the whole width. */\nconst NARROW = 520\nconst TABLE_MIN = 120\n\nconst root = useTemplateRef<HTMLElement>('root')\nconst chart = useTemplateRef<HTMLElement>('chart')\nconst tableBody = useTemplateRef<HTMLElement>('tableBody')\nconst { width: rootWidth } = useElementSize(root)\nconst { width: viewWidth, height: viewHeight } = useElementSize(chart)\n\nconst narrow = computed(() => rootWidth.value > 0 && rootWidth.value < NARROW)\nconst showTable = computed(() => props.table && !narrow.value)\nconst caption = computed(() => (showTable.value ? 0 : CAPTION))\nconst tableSize = ref(props.tableWidth)\nwatch(\n () => props.tableWidth,\n (width) => (tableSize.value = width),\n)\n\n// Document and history -------------------------------------------------------------\n\nconst history = new GanttHistory()\nconst canUndo = ref(false)\nconst canRedo = ref(false)\n\n/*\n * A bound v-model only catches up when the parent renders, so changes made\n * in the same tick build on the latest commit instead.\n */\nlet latest: GanttDocument | null = null\nconst docOf = (): GanttDocument => latest ?? toRaw(source.value)\n\nfunction commit(tr: GanttTransaction) {\n if (!latest) void nextTick(() => (latest = null))\n latest = tr.doc\n model.value = tr.doc\n const ids = new Set(tr.doc.tasks.map((task) => task.id))\n const kept = selection.value.filter((id) => ids.has(id))\n if (kept.length !== selection.value.length) selection.value = kept\n emit('transaction', tr)\n}\n\n/** Applies changes as one transaction, one undo step. */\nfunction transact(fn: (tr: GanttTransaction) => void, meta?: GanttTransactionMeta) {\n const tr = new GanttTransaction(docOf())\n if (meta) tr.setMeta(meta)\n fn(tr)\n if (!tr.changed) return tr\n const before = selection.value\n commit(tr)\n history.record(tr, before, selection.value)\n canUndo.value = history.canUndo\n canRedo.value = history.canRedo\n // tasks added from outside, such as a booking for a drawn range, are read out\n const added = tr.steps.flatMap((step) => (step.type === 'addTask' ? [step.task.title] : []))\n if (added.length)\n announce(format(messages.value.taskAdded, { title: added.join(messages.value.listSeparator) }))\n return tr\n}\n\nfunction travel(direction: 'undo' | 'redo') {\n const result = history[direction](docOf())\n if (!result) return null\n result.tr.setMeta({ origin: direction, history: false })\n commit(result.tr)\n selection.value = result.selection\n canUndo.value = history.canUndo\n canRedo.value = history.canRedo\n return result.tr\n}\n\nconst undo = () => !!travel('undo')\nconst redo = () => !!travel('redo')\n\n/** Undoes or redoes, and reads out the task it changed. */\nfunction announceTravel(direction: 'undo' | 'redo') {\n const tr = travel(direction)\n if (!tr) return\n const m = messages.value\n // the change as first made: undoing runs the steps backwards\n const step = tr.steps[direction === 'undo' ? tr.steps.length - 1 : 0]!\n const id = step.type === 'addTask' ? step.task.id : 'id' in step ? step.id : ''\n void nextTick(() => {\n const found = itemById.value.get(id)\n const change = found\n ? describe(found.item, found.row)\n : format(m.taskRemoved, {\n title: tr.before.tasks.find((task) => task.id === id)?.title ?? '',\n })\n announce(format(m[direction === 'undo' ? 'undone' : 'redone'], { change }))\n })\n}\n\n// Rows -------------------------------------------------------------------------------\n\ninterface GroupRow {\n kind: 'group'\n key: string\n title: string\n top: number\n height: number\n}\n\ninterface ResourceRow {\n kind: 'resource'\n key: string\n /** `null` for the row of tasks without a resource. */\n resource: SchedulerResource | null\n title: string\n items: SchedulerItem[]\n lanes: number\n load: SchedulerLoadSegment[]\n capacity: number\n overloaded: boolean\n top: number\n height: number\n}\n\ntype Row = GroupRow | ResourceRow\n\nconst collapsed = ref(new Set<string>())\nconst drag = shallowRef<Drag | null>(null)\nconst taskList = computed(() => toRaw(tasks.value))\n/** The tasks as shown: while dragging, as they would be after the drop. */\nconst shown = computed(() => {\n // only once a drag has begun, which is after everything here is set up\n if (!drag.value?.started) return taskList.value\n const plans = planned.value\n if (!plans?.length) return taskList.value\n const patches = new Map(plans.map((plan) => [plan.id, plan.patch]))\n return taskList.value.map((task) => {\n const patch = patches.get(task.id)\n return patch ? { ...task, ...patch } : task\n })\n})\nconst placed = computed(() => groupByResource(shown.value, props.resources))\n/**\n * While dragging, rows keep the lanes they had: a row that shrank would\n * pull the rows below up under the pointer.\n */\nconst keptLanes = shallowRef<Map<string, number> | null>(null)\n\nconst rows = computed<Row[]>(() => {\n const out: Row[] = []\n let top = 0\n const push = (row: Omit<GroupRow, 'top'> | Omit<ResourceRow, 'top'>) => {\n out.push({ ...row, top } as Row)\n top += row.height\n }\n const resourceRow = (resource: SchedulerResource | null): Omit<ResourceRow, 'top'> => {\n const entry = placed.value.get(resource?.id ?? '')\n const lanes = Math.max(entry?.lanes ?? 0, keptLanes.value?.get(resource?.id ?? '') ?? 0)\n const load = loadSegments((entry?.items ?? []).map((item) => item.span))\n const capacity = Math.max(1, resource?.capacity ?? 1)\n return {\n kind: 'resource',\n key: resource?.id ?? '',\n resource,\n title: resource?.title ?? sm.value.unassigned,\n items: entry?.items ?? [],\n lanes,\n load,\n capacity,\n overloaded: !!resource && load.some((segment) => segment.count > capacity),\n height: props.rowHeight + Math.max(0, lanes - 1) * LANE + caption.value,\n }\n }\n const done = new Set<string>()\n for (const resource of props.resources) {\n if (!resource.group) {\n push(resourceRow(resource))\n continue\n }\n if (done.has(resource.group)) continue\n done.add(resource.group)\n const key = `group:${resource.group}`\n push({ kind: 'group', key, title: resource.group, height: props.rowHeight })\n if (collapsed.value.has(key)) continue\n for (const member of props.resources)\n if (member.group === resource.group) push(resourceRow(member))\n }\n if (placed.value.has('')) push(resourceRow(null))\n return out\n})\n\nconst rowByKey = computed(() => new Map(rows.value.map((row) => [row.key, row])))\nconst totalHeight = computed(() => {\n const last = rows.value.at(-1)\n return last ? last.top + last.height : 0\n})\n/** Where each task is drawn. */\nconst itemById = computed(() => {\n const out = new Map<string, { item: SchedulerItem; row: ResourceRow }>()\n for (const row of rows.value)\n if (row.kind === 'resource') for (const item of row.items) out.set(item.task.id, { item, row })\n return out\n})\n\nfunction toggle(row: GroupRow) {\n const next = new Set(collapsed.value)\n if (next.has(row.key)) next.delete(row.key)\n else next.add(row.key)\n collapsed.value = next\n}\n\n// Time axis ----------------------------------------------------------------------------\n\nconst content = computed(() => {\n let start = Infinity\n let end = -Infinity\n for (const entry of placed.value.values())\n for (const { span } of entry.items) {\n start = Math.min(start, span.start)\n end = Math.max(end, span.end)\n }\n return start > end ? null : { start, end }\n})\n\nconst axis = useTimeAxis({\n chart,\n tableBody,\n viewWidth,\n scale,\n range: () => props.range,\n today: () => props.today,\n weekStart,\n calendar,\n messages,\n content,\n fitWidth: () => (showTable.value ? viewWidth.value : rootWidth.value),\n})\nconst {\n level,\n ppm,\n now,\n extent,\n origin,\n totalWidth,\n xOf,\n contentX,\n scrollTop,\n scrollLeft,\n scrollbar,\n topCells,\n bottomCells,\n shaded,\n todayX,\n timeWindow,\n} = axis\n\nwatch([viewHeight, viewWidth], axis.measureScrollbar, { flush: 'post' })\nwatch([rootWidth, viewWidth], axis.fitWhenSized)\n\nconst markers = computed(() => {\n const inRange = (props.markers ?? []).flatMap((marker, index) => {\n const at = parseTime(marker.date)?.at\n if (at == null || at < extent.value.start || at > extent.value.end) return []\n return [{ key: `${index}:${marker.date}`, x: xOf(at), marker }]\n })\n const room = markerRoom(inRange.map((item) => (item.marker.label ? item.x : -Infinity)))\n return inRange.map((item, i) => ({ ...item, room: room[i]! }))\n})\n\n// What is in view ---------------------------------------------------------------------\n\nconst bodyHeight = computed(() => Math.max(0, viewHeight.value - HEADER))\n\n/** The rows in view, with some room above and below. */\nconst visibleRows = computed(() => {\n const list = rows.value\n const from = scrollTop.value - OVERSCAN\n const to = scrollTop.value + bodyHeight.value + OVERSCAN\n // the first row that ends below `from`\n let lo = 0\n let hi = list.length\n while (lo < hi) {\n const mid = (lo + hi) >> 1\n if (list[mid]!.top + list[mid]!.height <= from) lo = mid + 1\n else hi = mid\n }\n const out: Row[] = []\n for (let i = lo; i < list.length && list[i]!.top < to; i++) out.push(list[i]!)\n return out\n})\n\nconst resourceRows = computed(() =>\n visibleRows.value.filter((row): row is ResourceRow => row.kind === 'resource'),\n)\n\n/** Where a lane's bar row sits, in body px. */\nconst laneTop = (row: ResourceRow, lane: number) => row.top + caption.value + lane * LANE\n\nconst describeRow = (row: ResourceRow) => {\n const text = format(sm.value.row, { title: row.title, count: row.items.length })\n return row.overloaded ? format(sm.value.overloaded, { row: text }) : text\n}\n\nfunction describe(item: SchedulerItem, row: ResourceRow) {\n const m = messages.value\n const { task, span } = item\n // as the gantt chart reads them out\n const text =\n task.type === 'milestone' || span.end <= span.start\n ? format(m.milestone, {\n title: task.title,\n start: dateLabel(\n span.start - (span.dateOnly ? MINUTES_PER_DAY / 2 : 0),\n span.dateOnly,\n m,\n ),\n })\n : format(sm.value.task, {\n title: task.title,\n dates: datesLabel(span, m),\n duration: spokenDuration(span, calendar.value, m),\n })\n return format(sm.value.item, { task: text, resource: row.title })\n}\n\nconst selected = computed(() => new Set(selection.value))\n\nlet canvas: CanvasRenderingContext2D | null | undefined\n/** Width of a bar title in px; estimated where text can't be measured. */\nfunction textWidth(text: string) {\n // measure only once the chart is in the page and its font is known\n if (canvas === undefined && root.value) {\n try {\n canvas = document.createElement('canvas').getContext('2d')\n if (canvas) {\n const style = getComputedStyle(root.value)\n canvas.font = `${style.getPropertyValue('--dou-font-size-sm').trim() || '13px'} ${style.fontFamily}`\n }\n } catch {\n canvas = null\n }\n }\n return canvas ? canvas.measureText(text).width : text.length * 13\n}\n\ninterface Bar {\n item: SchedulerItem\n row: ResourceRow\n left: number\n width: number\n top: number\n milestone: boolean\n /** The title doesn't fit in the bar: it goes after it. */\n outside: boolean\n aria: string\n}\n\nconst bars = computed<Bar[]>(() => {\n const { start, end } = timeWindow.value\n const out: Bar[] = []\n for (const row of resourceRows.value)\n for (const item of row.items) {\n const { span } = item\n if (span.end < start || span.start > end) continue\n const milestone = item.task.type === 'milestone'\n const width = Math.max(milestone ? 0 : 2, (span.end - span.start) * ppm.value)\n out.push({\n item,\n row,\n left: xOf(span.start),\n width,\n top: laneTop(row, item.lane),\n milestone,\n outside: milestone || textWidth(item.task.title) + 20 > width,\n aria: describe(item, row),\n })\n }\n return out\n})\n\n/** How busy each row in view is, as stretches under it. */\nconst loads = computed(() => {\n if (!props.load) return []\n const { start, end } = timeWindow.value\n return resourceRows.value.flatMap((row) =>\n row.resource\n ? row.load\n .filter((segment) => segment.end > start && segment.start < end)\n .map((segment) => ({\n key: `${row.key}:${segment.start}`,\n top: row.top + row.height - 6,\n left: xOf(segment.start),\n width: Math.max(1, (segment.end - segment.start) * ppm.value),\n level: Math.min(1, segment.count / row.capacity),\n over: segment.count > row.capacity,\n }))\n : [],\n )\n})\n\n/** Without the list, rows show their title at the left edge of the timeline. */\nconst rowTitles = computed(() => (showTable.value ? [] : visibleRows.value))\n\n// Table ----------------------------------------------------------------------------------\n\nconst tableMax = computed(() => Math.max(TABLE_MIN, Math.round((rootWidth.value || 1000) * 0.5)))\nconst hovered = ref<string | null>(null)\n\nfunction onDividerDown(event: PointerEvent) {\n if (event.button !== 0) return\n event.preventDefault()\n const x0 = event.clientX\n const width0 = tableSize.value\n const move = (e: PointerEvent) =>\n (tableSize.value = Math.min(tableMax.value, Math.max(TABLE_MIN, width0 + e.clientX - x0)))\n const up = () => {\n window.removeEventListener('pointermove', move)\n window.removeEventListener('pointerup', up)\n window.removeEventListener('pointercancel', up)\n }\n window.addEventListener('pointermove', move)\n window.addEventListener('pointerup', up)\n window.addEventListener('pointercancel', up)\n}\n\nfunction onDividerKey(event: KeyboardEvent) {\n const step = event.shiftKey ? 40 : 10\n if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return\n event.preventDefault()\n const next = tableSize.value + (event.key === 'ArrowLeft' ? -step : step)\n tableSize.value = Math.min(tableMax.value, Math.max(TABLE_MIN, next))\n}\n\n// Selection ------------------------------------------------------------------------------\n\nconst active = ref<string | null>(null)\n\nfunction select(task: GanttTask, event: MouseEvent | KeyboardEvent) {\n const id = task.id\n if (event.shiftKey || event.metaKey || event.ctrlKey)\n selection.value = selected.value.has(id)\n ? selection.value.filter((item) => item !== id)\n : [...selection.value, id]\n else if (selection.value.length !== 1 || selection.value[0] !== id) selection.value = [id]\n}\n\nfunction onTaskClick(task: GanttTask, event: MouseEvent) {\n if (dragged) return\n active.value = task.id\n select(task, event)\n emit('taskClick', task, event)\n}\n\n// Dragging ---------------------------------------------------------------------------------\n\ntype DragMode = 'move' | 'start' | 'end' | 'draw'\n\ninterface Drag {\n mode: DragMode\n /** The task pressed; empty when drawing. */\n id: string\n pointerId: number\n touch: boolean\n /** Where the press was and where the pointer is, in timeline px. */\n x0: number\n x: number\n y0: number\n y: number\n clientX: number\n clientY: number\n started: boolean\n /** The tasks that move, with their spans and rows when the drag began. */\n spans: Map<string, { span: TaskSpan; row: string; lane: number }>\n /** The row pressed, and the row under the pointer. */\n from: string\n to: string\n}\n\nconst THRESHOLD = { mouse: 3, touch: 6 }\nconst LONG_PRESS = 300\nconst EDGE = 32\n\nlet dragged = false\nlet pressTimer: ReturnType<typeof setTimeout> | undefined\nlet frame = 0\nlet hold: PointerHold | null = null\n\nconst snap = (at: number, step: number) => Math.round(at / step) * step\nconst edgeSpeed = (gap: number) => (gap < EDGE ? Math.ceil(((EDGE - gap) / EDGE) * 16) : 0)\n\n/** The step a drag snaps to: whole days when any task is date-only. */\nfunction stepFor(spans: Iterable<{ dateOnly: boolean }>) {\n const unit = snapStep(axis.dayWidth.value)\n for (const span of spans) if (span.dateOnly) return Math.max(unit, MINUTES_PER_DAY)\n return unit\n}\n\n/** The body y of a pointer. */\nconst contentY = (clientY: number) =>\n clientY - chart.value!.getBoundingClientRect().top - HEADER + chart.value!.scrollTop\n\n/** The resource row at a body y, if any. */\nfunction resourceRowAt(y: number): ResourceRow | null {\n const list = rows.value\n let lo = 0\n let hi = list.length - 1\n while (lo <= hi) {\n const mid = (lo + hi) >> 1\n const row = list[mid]!\n if (y < row.top) hi = mid - 1\n else if (y >= row.top + row.height) lo = mid + 1\n else return row.kind === 'resource' ? row : null\n }\n return null\n}\n\nconst taskById = (id: string) => docOf().tasks.find((task) => task.id === id)\n\ninterface Planned {\n id: string\n span: TaskSpan\n /** The row it goes to. */\n row: string\n patch: Partial<GanttTask>\n}\n\n/** What the current drag would do. */\nconst planned = computed<Planned[] | null>(() => {\n const d = drag.value\n if (!d?.started || d.mode === 'draw') return null\n const delta = (d.x - d.x0) / ppm.value\n const out: Planned[] = []\n const first = d.spans.get(d.id)!\n const unit = stepFor([...d.spans.values()].map((entry) => entry.span))\n for (const [id, entry] of d.spans) {\n const task = taskById(id)\n if (!task) continue\n const { span } = entry\n let start = span.start\n let end = span.end\n if (d.mode === 'move') {\n const shift = snap(first.span.start + delta, unit) - first.span.start\n start += shift\n end += shift\n } else if (d.mode === 'start') start = Math.min(snap(span.start + delta, unit), span.end - unit)\n else end = Math.max(snap(span.end + delta, unit), span.start + unit)\n // tasks from the pressed row follow the pointer to another row\n const row = d.mode === 'move' && entry.row === d.from ? d.to : entry.row\n const patch: Partial<GanttTask> = writeSpan(task, start, end)\n if (row !== entry.row) patch.resource = row || undefined\n out.push({ id, span: { start, end, dateOnly: span.dateOnly }, row, patch })\n }\n return out\n})\n\n/**\n * Why `canMove` refuses changes, or `null` when it allows them all. Each\n * patch is checked with the resource it ends up with.\n */\nfunction checkMove(patches: Iterable<{ id: string; patch: Partial<GanttTask> }>) {\n const rule = props.canMove\n if (!rule) return null\n const doc = docOf()\n for (const { id, patch } of patches) {\n const task = taskById(id)\n if (!task) continue\n const next = { ...task, ...patch }\n if (!next.start) continue\n const known = next.resource && props.resources.some((item) => item.id === next.resource)\n const move: SchedulerMove = {\n task,\n start: next.start,\n end: next.end,\n resource: known ? next.resource : undefined,\n }\n const answer = rule(move, doc)\n if (answer === true) continue\n return typeof answer === 'string' && answer ? answer : messages.value.moveRefused\n }\n return null\n}\n\n/** Why the current drag would be refused. */\nconst dragRefusal = computed(() => {\n const plans = planned.value\n return plans?.length ? checkMove(plans) : null\n})\n\n/** The range being drawn on an empty part of a row. */\nconst drawn = computed(() => {\n const d = drag.value\n if (!d?.started || d.mode !== 'draw') return null\n const unit = snapStep(axis.dayWidth.value)\n const a = origin.value + Math.min(d.x0, d.x) / ppm.value\n const b = origin.value + Math.max(d.x0, d.x) / ppm.value\n const start = Math.floor(a / unit) * unit\n const end = Math.max(start + unit, Math.ceil(b / unit) * unit)\n return { row: d.from, start, end, dateOnly: unit >= MINUTES_PER_DAY }\n})\n\n/** A dashed outline over the range being drawn. */\nconst drawnBox = computed(() => {\n const range = drawn.value\n const row = range && rowByKey.value.get(range.row)\n if (!range || row?.kind !== 'resource') return null\n return {\n left: xOf(range.start),\n width: (range.end - range.start) * ppm.value,\n top: laneTop(row, 0),\n }\n})\n\n/** The new dates beside the pointer while dragging. */\nconst dragTip = computed(() => {\n const d = drag.value\n if (!d?.started) return null\n const m = messages.value\n const found = d.mode === 'draw' ? undefined : itemById.value.get(d.id)\n const span = found?.item.span ?? (drawn.value && { ...drawn.value })\n const row = found?.row ?? rowByKey.value.get(d.from)\n if (!span || row?.kind !== 'resource') return null\n const refusal = d.mode === 'draw' ? null : dragRefusal.value\n const text = refusal\n ? refusal\n : span.end > span.start\n ? format(m.rangeTip, {\n start: dateLabel(span.start, span.dateOnly, m),\n end: endLabel(span, m),\n duration: durationLabel(span, calendar.value, m),\n })\n : dateLabel(span.start - (span.dateOnly ? MINUTES_PER_DAY / 2 : 0), span.dateOnly, m)\n return {\n text,\n refused: !!refusal,\n left: Math.max(scrollLeft.value + 8, xOf(span.start)),\n top: laneTop(row, found?.item.lane ?? 0),\n }\n})\n\nfunction startDrag(event: PointerEvent, mode: DragMode, id: string, from: ResourceRow) {\n const touch = event.pointerType === 'touch'\n const spans = new Map<string, { span: TaskSpan; row: string; lane: number }>()\n if (mode !== 'draw') {\n const ids = mode === 'move' && selected.value.has(id) ? selection.value : [id]\n for (const each of ids) {\n const found = itemById.value.get(each)\n if (found && !found.item.task.locked)\n spans.set(each, { span: found.item.span, row: found.row.key, lane: found.item.lane })\n }\n if (!spans.has(id)) return\n }\n hold?.release()\n hold = holdPointer(event, root.value)\n const x = contentX(event.clientX)\n const y = contentY(event.clientY)\n const d: Drag = {\n mode,\n id,\n pointerId: event.pointerId,\n touch,\n x0: x,\n x,\n y0: y,\n y,\n clientX: event.clientX,\n clientY: event.clientY,\n started: false,\n spans,\n from: from.key,\n to: from.key,\n }\n drag.value = d\n window.addEventListener('pointermove', onDragMove)\n window.addEventListener('pointerup', onDragEnd)\n window.addEventListener('pointercancel', stopDrag)\n window.addEventListener('keydown', onDragKey, true)\n // a finger picks a bar up, or starts a range, after a short rest\n if (touch)\n pressTimer = setTimeout(() => {\n if (drag.value !== d) return\n begin(d)\n navigator.vibrate?.(8)\n }, LONG_PRESS)\n}\n\nfunction begin(d: Drag) {\n d.started = true\n keptLanes.value = new Map(\n rows.value.flatMap((row) => (row.kind === 'resource' ? [[row.key, row.lanes] as const] : [])),\n )\n hold?.grab()\n window.getSelection()?.removeAllRanges()\n frame = requestAnimationFrame(autoScroll)\n drag.value = { ...d }\n}\n\nfunction update(d: Drag, x: number, y: number) {\n const next = { ...d, x, y }\n if (d.mode === 'move') next.to = resourceRowAt(y)?.key ?? d.to\n drag.value = next\n}\n\nfunction onDragMove(event: PointerEvent) {\n const d = drag.value\n if (!d || event.pointerId !== d.pointerId) return\n d.clientX = event.clientX\n d.clientY = event.clientY\n const x = contentX(event.clientX)\n const y = contentY(event.clientY)\n if (!d.started) {\n const moved = Math.hypot(x - d.x0, y - d.y0)\n // a finger that moves before the long press scrolls instead\n if (d.touch) {\n if (moved > THRESHOLD.touch) stopDrag()\n return\n }\n if (moved < THRESHOLD.mouse) return\n begin(d)\n }\n update(drag.value!, x, y)\n}\n\nfunction autoScroll() {\n const d = drag.value\n const el = chart.value\n if (!d?.started || !el) return\n const rect = el.getBoundingClientRect()\n // only towards where the pointer has gone, so a bar picked up by the edge doesn't run off\n let dx = -edgeSpeed(d.clientX - rect.left) || edgeSpeed(rect.right - d.clientX)\n if (Math.sign(dx) !== Math.sign(d.x - d.x0)) dx = 0\n let dy = -edgeSpeed(d.clientY - rect.top - HEADER) || edgeSpeed(rect.bottom - d.clientY)\n if (d.mode !== 'move' || Math.sign(dy) !== Math.sign(d.y - d.y0)) dy = 0\n if (dx || dy) {\n el.scrollLeft += dx\n el.scrollTop += dy\n update(d, contentX(d.clientX), contentY(d.clientY))\n }\n frame = requestAnimationFrame(autoScroll)\n}\n\nfunction onDragEnd(event: PointerEvent) {\n const d = drag.value\n if (!d || event.pointerId !== d.pointerId) return\n // a finger held on a bar and let go without moving: the task's menu\n if (d.touch && d.started && d.mode === 'move' && Math.abs(d.x - d.x0) < THRESHOLD.touch) {\n const found = itemById.value.get(d.id)\n stopDrag()\n if (found && menuAllowed(found.item.task, event.clientX, event.clientY))\n showMenu(taskTarget(found.item.task, found.row), event.clientX, event.clientY)\n return\n }\n if (d.started) {\n dragged = true\n setTimeout(() => (dragged = false))\n if (d.mode === 'draw') finishDraw()\n else finishMove(d)\n } else if (d.mode === 'draw' && !d.touch) selection.value = []\n stopDrag()\n}\n\nfunction finishMove(d: Drag) {\n const plans = planned.value\n if (!plans?.length) return\n if (dragRefusal.value) return void announce(dragRefusal.value)\n transact((tr) => {\n for (const plan of plans) tr.updateTask(plan.id, plan.patch)\n })\n const pressed = plans.find((plan) => plan.id === d.id)\n const row = pressed && rowByKey.value.get(pressed.row)\n if (pressed && pressed.row !== d.from && row)\n announce(format(sm.value.moved, { title: taskById(d.id)?.title ?? '', resource: row.title }))\n}\n\nfunction finishDraw() {\n const range = drawn.value\n if (!range) return\n const span = writeSpan({ id: '', title: '' }, range.start, range.end)\n const out: SchedulerRange = {\n resource: range.row || undefined,\n start: span.start!,\n end: span.end ?? span.start!,\n }\n emit('create', out)\n}\n\nfunction onDragKey(event: KeyboardEvent) {\n if (event.key !== 'Escape') return\n event.preventDefault()\n event.stopPropagation()\n stopDrag()\n}\n\nfunction stopDrag() {\n clearTimeout(pressTimer)\n cancelAnimationFrame(frame)\n hold?.release()\n hold = null\n drag.value = null\n keptLanes.value = null\n window.removeEventListener('pointermove', onDragMove)\n window.removeEventListener('pointerup', onDragEnd)\n window.removeEventListener('pointercancel', stopDrag)\n window.removeEventListener('keydown', onDragKey, true)\n}\n\nfunction onBarDown(event: PointerEvent, bar: Bar) {\n if (event.button !== 0 || !editable.value) return\n event.stopPropagation()\n const handle = (event.currentTarget as HTMLElement).dataset.handle\n const mode: DragMode = handle === 'start' || handle === 'end' ? handle : 'move'\n if (bar.item.task.locked) return\n startDrag(event, mode, bar.item.task.id, bar.row)\n}\n\nfunction onBodyDown(event: PointerEvent) {\n if (event.button !== 0 || !editable.value) return\n const row = resourceRowAt(contentY(event.clientY))\n if (row) startDrag(event, 'draw', '', row)\n else if (event.pointerType !== 'touch') selection.value = []\n}\n\n// two fingers zoom; while they do, nothing is dragged\nconst onTouchMove = (event: TouchEvent) => void axis.pinchMove(event)\nconst onTouchEnd = (event: TouchEvent) => axis.pinchEnd(event)\nfunction onTouchStart(event: TouchEvent) {\n if (axis.pinchStart(event)) stopDrag()\n}\n\nonMounted(() => {\n const el = root.value!\n el.addEventListener('touchstart', onTouchStart, { passive: true })\n el.addEventListener('touchmove', onTouchMove, { passive: false })\n el.addEventListener('touchend', onTouchEnd)\n el.addEventListener('touchcancel', onTouchEnd)\n})\nonBeforeUnmount(() => {\n const el = root.value\n el?.removeEventListener('touchstart', onTouchStart)\n el?.removeEventListener('touchmove', onTouchMove)\n el?.removeEventListener('touchend', onTouchEnd)\n el?.removeEventListener('touchcancel', onTouchEnd)\n stopDrag()\n})\n\n// Menu -------------------------------------------------------------------------------------\n\nconst menu = useTemplateRef<InstanceType<typeof GanttMenu>>('menu')\nconst menuItems = shallowRef<ContextMenuItem[]>([])\nconst menuOpen = ref(false)\n\n/** The moment under a point on screen, or the task's start when it is off the timeline. */\nfunction timeAt(x: number, fallback: number) {\n const rect = chart.value!.getBoundingClientRect()\n if (x < rect.left || x > rect.right) return fallback\n return Math.round(origin.value + contentX(x) / ppm.value)\n}\n\nconst taskTarget = (task: GanttTask, row: ResourceRow) =>\n ({ kind: 'task', task, resource: row.resource, time: 0 }) as const\n\n/** Your items, then the built-in one, a separator between them. */\nfunction itemsFor(target: SchedulerMenuTarget) {\n const items: ContextMenuItem[] = [...(props.contextMenu?.(target) ?? [])]\n if (props.menu && editable.value && target.kind === 'task') {\n const ids = selected.value.has(target.task.id) ? selection.value : [target.task.id]\n const m = messages.value.menu\n items.push({\n key: 'remove',\n label: ids.length > 1 ? format(m.removeTasks, { count: ids.length }) : m.removeTask,\n icon: TrashIcon,\n shortcut: 'delete',\n tone: 'danger',\n divided: items.length > 0,\n run: () => removeTasks(ids),\n })\n }\n return items\n}\n\n/** Opens the menu at a point on screen; `false` when it has nothing to show. */\nfunction showMenu(on: SchedulerMenuTarget, x: number, y: number) {\n const start = on.kind === 'task' ? (itemById.value.get(on.task.id)?.item.span.start ?? 0) : 0\n const target = { ...on, time: timeAt(x, start) }\n const items = itemsFor(target)\n if (!items.length) return false\n if (target.kind === 'task') {\n active.value = target.task.id\n if (!selected.value.has(target.task.id)) selection.value = [target.task.id]\n }\n menuItems.value = items\n menu.value?.open(x, y)\n return true\n}\n\n/** When a finger last went down: its long press opens the menu itself, not the browser's. */\nlet touchedAt = 0\nconst onRootPointerdown = (event: PointerEvent) => {\n if (event.pointerType === 'touch') touchedAt = Date.now()\n}\n\n/**\n * A menu opened by a resting finger or by the menu key has no browser event:\n * it gets a stand-in, so `taskContextmenu` comes first in every case and\n * `preventDefault()` keeps the menu closed.\n */\nfunction menuAllowed(item: GanttTask, x: number, y: number) {\n const event = new MouseEvent('contextmenu', { cancelable: true, clientX: x, clientY: y })\n emit('taskContextmenu', item, event)\n return !event.defaultPrevented\n}\n\nfunction onTaskContextmenu(bar: Bar, event: MouseEvent) {\n // a finger's long press opens the menu, and sends the event, when it lets go\n if (Date.now() - touchedAt < 1000) return event.preventDefault()\n emit('taskContextmenu', bar.item.task, event)\n if (event.defaultPrevented) return\n if (showMenu(taskTarget(bar.item.task, bar.row), event.clientX, event.clientY))\n event.preventDefault()\n}\n\nfunction onBodyContextmenu(event: MouseEvent) {\n const target = event.target as HTMLElement\n if (event.defaultPrevented || target.closest('.dou-gantt__bar-row')) return\n if (Date.now() - touchedAt < 1000) return event.preventDefault()\n const row = resourceRowAt(contentY(event.clientY))\n if (!row) return\n const on = { kind: 'row', resource: row.resource, time: 0 } as const\n if (showMenu(on, event.clientX, event.clientY)) event.preventDefault()\n}\n\n/** The menu key, or Shift+F10, opens the menu of the task the keyboard is on, at its bar. */\nfunction openKeyMenu() {\n const found = active.value ? itemById.value.get(active.value) : undefined\n if (!found) return false\n const el = root.value?.querySelector(\n `.dou-gantt__bar[data-task=\"${CSS.escape(found.item.task.id)}\"]`,\n )\n const rect = el?.getBoundingClientRect()\n if (!rect) return false\n if (!menuAllowed(found.item.task, rect.left + 8, rect.bottom + 4)) return true\n return showMenu(taskTarget(found.item.task, found.row), rect.left + 8, rect.bottom + 4)\n}\n\n// Keys -------------------------------------------------------------------------------------\n\nconst keysHint = useId()\nconst live = ref('')\n\nfunction announce(text: string) {\n live.value = ''\n void nextTick(() => (live.value = text))\n}\n\n/** The tasks in reading order: row by row, by start. */\nconst ordered = computed(() => {\n const out: { item: SchedulerItem; row: ResourceRow; index: number }[] = []\n const list = rows.value.filter((row): row is ResourceRow => row.kind === 'resource')\n list.forEach((row, index) => {\n // oxlint-disable-next-line unicorn/no-array-sort -- sorts a fresh array\n const items = [...row.items].sort((a, b) => a.span.start - b.span.start || a.lane - b.lane)\n for (const item of items) out.push({ item, row, index })\n })\n return { out, rows: list }\n})\n\nfunction focusTask(id: string) {\n active.value = id\n scrollToTask(id)\n void nextTick(() => {\n const el = root.value?.querySelector<HTMLElement>(\n `.dou-gantt__bar[data-task=\"${CSS.escape(id)}\"]`,\n )\n ;(el ?? root.value)?.focus({ preventScroll: true })\n })\n}\n\nfunction scrollToTask(id: string) {\n const found = itemById.value.get(id)\n const el = chart.value\n if (!found || !el) return\n const top = laneTop(found.row, found.item.lane)\n if (top < el.scrollTop) el.scrollTop = top\n else if (top + props.rowHeight > el.scrollTop + bodyHeight.value)\n el.scrollTop = top + props.rowHeight - bodyHeight.value\n const left = xOf(found.item.span.start)\n const right = xOf(found.item.span.end)\n if (left < scrollLeft.value || right > scrollLeft.value + viewWidth.value)\n axis.scrollToTime(found.item.span.start)\n}\n\n/** Arrow keys: the next task along, or the one nearest in time in the row above or below. */\nfunction stepFocus(key: string) {\n const { out, rows: list } = ordered.value\n if (!out.length) return\n const at = out.findIndex((entry) => entry.item.task.id === active.value)\n if (at === -1) return void focusTask(out[0]!.item.task.id)\n const current = out[at]!\n if (key === 'ArrowLeft' || key === 'ArrowRight') {\n const next = out[at + (key === 'ArrowLeft' ? -1 : 1)]\n if (next && next.row === current.row) focusTask(next.item.task.id)\n return\n }\n const direction = key === 'ArrowUp' ? -1 : 1\n for (let i = current.index + direction; i >= 0 && i < list.length; i += direction) {\n const candidates = out.filter((entry) => entry.index === i)\n if (!candidates.length) continue\n const time = current.item.span.start\n let best = candidates[0]!\n for (const entry of candidates)\n if (Math.abs(entry.item.span.start - time) < Math.abs(best.item.span.start - time))\n best = entry\n return void focusTask(best.item.task.id)\n }\n}\n\n/** Alt and the arrow keys: earlier or later, or to the resource above or below. */\nfunction keyMove(key: string) {\n const found = active.value ? itemById.value.get(active.value) : undefined\n if (!found || found.item.task.locked) return\n const { item, row } = found\n const task = item.task\n if (key === 'ArrowLeft' || key === 'ArrowRight') {\n const unit = stepFor([item.span])\n const shift = key === 'ArrowLeft' ? -unit : unit\n const patch = writeSpan(task, item.span.start + shift, item.span.end + shift)\n const refusal = checkMove([{ id: task.id, patch }])\n if (refusal) return void announce(refusal)\n transact((tr) => tr.updateTask(task.id, patch), { group: `key-move:${task.id}` })\n const span = spanOf(taskById(task.id)!)\n if (span) announce(describe({ ...item, span }, row))\n return\n }\n const list = ordered.value.rows\n const index = list.indexOf(row)\n const target = list[index + (key === 'ArrowUp' ? -1 : 1)]\n if (!target) return\n const refusal = checkMove([{ id: task.id, patch: { resource: target.resource?.id } }])\n if (refusal) return void announce(refusal)\n transact((tr) => tr.updateTask(task.id, { resource: target.resource?.id }))\n announce(format(sm.value.moved, { title: task.title, resource: target.title }))\n focusTask(task.id)\n}\n\nfunction removeSelected() {\n const ids = selection.value.length ? selection.value : active.value ? [active.value] : []\n if (ids.length) removeTasks(ids)\n}\n\nfunction removeTasks(ids: string[]) {\n const titles = ids.map((id) => taskById(id)?.title).filter(Boolean)\n transact((tr) => {\n for (const id of ids) tr.removeTask(id)\n })\n active.value = null\n const m = messages.value\n announce(format(m.tasksRemoved, { titles: titles.join(m.listSeparator) }))\n}\n\nfunction onKeydown(event: KeyboardEvent) {\n if (drag.value) return\n const key = event.key\n if (matchesHotkey(event, 'mod+z')) {\n event.preventDefault()\n if (editable.value) announceTravel('undo')\n return\n }\n if (matchesHotkey(event, 'mod+shift+z') || matchesHotkey(event, 'mod+y')) {\n event.preventDefault()\n if (editable.value) announceTravel('redo')\n return\n }\n if (key.startsWith('Arrow')) {\n event.preventDefault()\n if (event.altKey) {\n if (editable.value) keyMove(key)\n } else stepFocus(key)\n return\n }\n if (key === 'ContextMenu' || matchesHotkey(event, 'shift+f10')) {\n if (openKeyMenu()) event.preventDefault()\n return\n }\n if ((key === 'Delete' || key === 'Backspace') && editable.value) {\n event.preventDefault()\n removeSelected()\n return\n }\n if ((key === 'Enter' || key === ' ') && active.value) {\n event.preventDefault()\n const task = taskById(active.value)\n if (task) select(task, event)\n return\n }\n if (key === 'Escape' && selection.value.length) {\n event.preventDefault()\n selection.value = []\n }\n}\n\n// Exposed ------------------------------------------------------------------------------------\n\ndefineExpose<SchedulerExpose>({\n transact,\n undo,\n redo,\n get canUndo() {\n return canUndo.value\n },\n get canRedo() {\n return canRedo.value\n },\n fit: axis.fit,\n scrollToDate: axis.scrollToDate,\n scrollToToday: axis.scrollToToday,\n scrollToTask,\n focusTask,\n zoomIn: axis.zoomIn,\n zoomOut: axis.zoomOut,\n})\n</script>\n\n<template>\n <div\n ref=\"root\"\n class=\"dou-gantt is-scheduler\"\n :class=\"{\n 'is-narrow': narrow,\n 'is-editable': editable,\n 'is-dragging': drag?.started,\n [`is-dragging-${drag?.mode}`]: drag?.started,\n }\"\n :style=\"{ '--dou-gantt-row-height': `${rowHeight}px`, '--dou-gantt-lane': `${LANE}px` }\"\n role=\"group\"\n tabindex=\"0\"\n :aria-label=\"ariaLabel ?? sm.name\"\n :aria-describedby=\"keysHint\"\n @keydown=\"onKeydown\"\n @pointerdown.capture=\"onRootPointerdown\"\n >\n <span :id=\"keysHint\" class=\"dou-gantt__sr-only\">{{ sm.keys }}</span>\n <div class=\"dou-gantt__sr-only\" aria-live=\"polite\">{{ live }}</div>\n <div v-if=\"showTable\" class=\"dou-gantt__table-pane\" :style=\"{ width: `${tableSize}px` }\">\n <div class=\"dou-gantt__table\" role=\"list\" :aria-label=\"sm.resource\">\n <div class=\"dou-gantt__table-head\">\n <div class=\"dou-gantt__th is-title\">{{ sm.resource }}</div>\n </div>\n <div\n ref=\"tableBody\"\n class=\"dou-gantt__table-body\"\n tabindex=\"-1\"\n @scroll=\"axis.onTableScroll\"\n >\n <div class=\"dou-gantt__table-rows\" :style=\"{ height: `${totalHeight + scrollbar}px` }\">\n <div\n v-for=\"row in visibleRows\"\n :key=\"row.key\"\n class=\"dou-gantt__tr\"\n :class=\"{\n 'is-group': row.kind === 'group',\n 'is-hovered': hovered === row.key,\n 'is-overloaded': row.kind === 'resource' && row.overloaded,\n }\"\n :style=\"{ transform: `translateY(${row.top}px)`, height: `${row.height}px` }\"\n :data-row=\"row.key\"\n role=\"listitem\"\n :aria-label=\"row.kind === 'resource' ? describeRow(row) : row.title\"\n @pointerenter=\"hovered = row.key\"\n @pointerleave=\"hovered = null\"\n >\n <div class=\"dou-gantt__td is-title\">\n <template v-if=\"row.kind === 'group'\">\n <button\n type=\"button\"\n class=\"dou-gantt__toggle\"\n tabindex=\"-1\"\n :aria-label=\"collapsed.has(row.key) ? messages.expand : messages.collapse\"\n :aria-expanded=\"!collapsed.has(row.key)\"\n @click=\"toggle(row)\"\n >\n <component :is=\"collapsed.has(row.key) ? ChevronRightIcon : ChevronDownIcon\" />\n </button>\n <span class=\"dou-gantt__cell-text\">{{ row.title }}</span>\n </template>\n <template v-else>\n <span v-if=\"row.resource?.group\" class=\"dou-gantt__indent\" style=\"width: 16px\" />\n <span class=\"dou-gantt__toggle-space\" />\n <span class=\"dou-gantt__cell-text\" :class=\"{ 'is-unassigned': !row.resource }\">\n <slot v-if=\"row.resource\" name=\"resource\" :resource=\"row.resource\">\n {{ row.title }}\n </slot>\n <template v-else>{{ row.title }}</template>\n </span>\n <span\n v-if=\"row.overloaded\"\n class=\"dou-gantt__overload\"\n :title=\"sm.overloadHint\"\n aria-hidden=\"true\"\n />\n </template>\n </div>\n </div>\n </div>\n </div>\n </div>\n </div>\n <div\n ref=\"chart\"\n class=\"dou-gantt__chart\"\n :class=\"{ 'is-fixed': range }\"\n tabindex=\"-1\"\n @scroll.passive=\"axis.onChartScroll\"\n @wheel=\"axis.onWheel\"\n >\n <div class=\"dou-gantt__canvas\" :style=\"{ width: `${totalWidth}px` }\">\n <TimeHeader\n :top-cells=\"topCells\"\n :bottom-cells=\"bottomCells\"\n :bottom-unit=\"level.bottom\"\n :calendar=\"calendar\"\n :now=\"now\"\n :today-x=\"todayX\"\n >\n <span\n v-for=\"item in markers\"\n :key=\"item.key\"\n class=\"dou-gantt__marker-label\"\n :class=\"[\n item.marker.tone && `is-${item.marker.tone}`,\n { 'is-cut': item.room !== Infinity, 'is-crowded': !item.room },\n ]\"\n :title=\"item.room !== Infinity ? item.marker.label : undefined\"\n :style=\"{\n left: `${item.x}px`,\n maxWidth: item.room && item.room !== Infinity ? `${item.room}px` : undefined,\n }\"\n >\n <span v-if=\"item.marker.label\" class=\"dou-gantt__marker-text\">{{\n item.marker.label\n }}</span>\n </span>\n </TimeHeader>\n <div\n class=\"dou-gantt__body\"\n :style=\"{ height: `${totalHeight}px` }\"\n @pointerdown=\"onBodyDown\"\n @contextmenu=\"onBodyContextmenu\"\n >\n <div v-if=\"!showTable\" class=\"dou-gantt__row-titles\">\n <span\n v-for=\"row in rowTitles\"\n :key=\"row.key\"\n class=\"dou-gantt__row-title\"\n :class=\"{ 'is-summary': row.kind === 'group' }\"\n :style=\"{ transform: `translateY(${row.top}px)` }\"\n >\n <slot\n v-if=\"row.kind === 'resource' && row.resource\"\n name=\"resource\"\n :resource=\"row.resource\"\n >\n {{ row.title }}\n </slot>\n <template v-else>{{ row.title }}</template>\n </span>\n </div>\n <TimeGrid :cells=\"bottomCells\" :shaded=\"shaded\" />\n <div\n v-for=\"row in visibleRows\"\n :key=\"row.key\"\n class=\"dou-gantt__lane-row\"\n :class=\"{ 'is-group': row.kind === 'group', 'is-hovered': hovered === row.key }\"\n :style=\"{ transform: `translateY(${row.top}px)`, height: `${row.height}px` }\"\n />\n <div v-if=\"todayX != null\" class=\"dou-gantt__today\" :style=\"{ left: `${todayX}px` }\" />\n <div\n v-for=\"item in markers\"\n :key=\"item.key\"\n class=\"dou-gantt__marker\"\n :class=\"item.marker.tone && `is-${item.marker.tone}`\"\n :style=\"{ left: `${item.x}px` }\"\n />\n <div\n v-for=\"segment in loads\"\n :key=\"segment.key\"\n class=\"dou-gantt__load\"\n :class=\"{ 'is-over': segment.over }\"\n :style=\"{\n transform: `translate(${segment.left}px, ${segment.top}px)`,\n width: `${segment.width}px`,\n '--dou-gantt-load': segment.level,\n }\"\n />\n <div\n v-for=\"bar in bars\"\n :key=\"bar.item.task.id\"\n class=\"dou-gantt__bar-row\"\n :class=\"{\n 'is-selected': selected.has(bar.item.task.id),\n 'is-active': drag?.started && drag.spans.has(bar.item.task.id),\n }\"\n :style=\"{ transform: `translateY(${bar.top}px)` }\"\n >\n <div\n class=\"dou-gantt__bar\"\n :class=\"[\n bar.item.task.tone && `is-${bar.item.task.tone}`,\n {\n 'is-milestone': bar.milestone,\n 'is-selected': selected.has(bar.item.task.id),\n 'is-locked': bar.item.task.locked,\n 'is-active': active === bar.item.task.id,\n 'is-move-refused':\n !!dragRefusal && drag?.started && drag.spans.has(bar.item.task.id),\n },\n ]\"\n :data-task=\"bar.item.task.id\"\n :style=\"{\n left: `${bar.left}px`,\n width: bar.milestone ? undefined : `${bar.width}px`,\n }\"\n role=\"img\"\n tabindex=\"-1\"\n :aria-label=\"bar.aria\"\n @pointerdown=\"onBarDown($event, bar)\"\n @click=\"onTaskClick(bar.item.task, $event)\"\n @dblclick=\"emit('taskDblclick', bar.item.task, $event)\"\n @contextmenu=\"onTaskContextmenu(bar, $event)\"\n >\n <span v-if=\"!bar.outside\" class=\"dou-gantt__bar-label\">\n <slot name=\"bar\" :task=\"bar.item.task\">{{ bar.item.task.title }}</slot>\n </span>\n </div>\n <template v-if=\"editable && !bar.item.task.locked && !bar.milestone\">\n <span\n class=\"dou-gantt__handle is-start\"\n data-handle=\"start\"\n :style=\"{ left: `${bar.left}px` }\"\n @pointerdown=\"onBarDown($event, bar)\"\n />\n <span\n class=\"dou-gantt__handle is-end\"\n data-handle=\"end\"\n :style=\"{ left: `${bar.left + bar.width}px` }\"\n @pointerdown=\"onBarDown($event, bar)\"\n />\n </template>\n <span\n v-if=\"bar.outside\"\n class=\"dou-gantt__bar-side\"\n :style=\"{ left: `${bar.left + (bar.milestone ? 12 : bar.width + 8)}px` }\"\n >\n <slot name=\"bar\" :task=\"bar.item.task\">{{ bar.item.task.title }}</slot>\n </span>\n </div>\n <div\n v-if=\"drawnBox\"\n class=\"dou-gantt__ghost\"\n :style=\"{\n transform: `translate(${drawnBox.left}px, ${drawnBox.top}px)`,\n width: `${drawnBox.width}px`,\n }\"\n />\n <div\n v-if=\"dragTip\"\n class=\"dou-gantt__tip\"\n :class=\"{ 'is-refused': dragTip.refused }\"\n :style=\"{ transform: `translate(${dragTip.left}px, ${dragTip.top}px)` }\"\n >\n {{ dragTip.text }}\n </div>\n </div>\n </div>\n </div>\n <div\n v-if=\"showTable\"\n class=\"dou-gantt__divider\"\n role=\"separator\"\n :aria-label=\"messages.resizeTable\"\n tabindex=\"0\"\n aria-orientation=\"vertical\"\n :aria-valuenow=\"Math.round(tableSize)\"\n :aria-valuemin=\"TABLE_MIN\"\n :aria-valuemax=\"tableMax\"\n @pointerdown=\"onDividerDown\"\n @keydown=\"onDividerKey\"\n />\n <GanttMenu ref=\"menu\" v-model:open=\"menuOpen\" :items=\"menuItems\" />\n <slot />\n </div>\n</template>\n"],"mappings":""}
|