@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":"gantt.js","names":["_sfc_main"],"sources":["../src/gantt.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport type { ContextMenuItem } from 'dou-ui'\nimport {\n ChevronDownIcon,\n ChevronRightIcon,\n IndentIcon,\n ListTreeIcon,\n OutdentIcon,\n EditIcon,\n PlusIcon,\n TrashIcon,\n} from '@dou-ui/icons'\nimport {\n holdPointer,\n matchesHotkey,\n svgToPNG,\n useElementSize,\n useLocale,\n type PointerHold,\n} from 'dou-ui'\nimport {\n computed,\n nextTick,\n onBeforeUnmount,\n onMounted,\n provide,\n ref,\n shallowRef,\n toRaw,\n useId,\n useTemplateRef,\n watch,\n} from 'vue'\nimport { ganttKey, type GanttContext } from './context'\nimport { resolveGanttExtensions, type GanttHistoryHandler } from './core/extension'\nimport GanttMenu from './components/gantt-menu.vue'\nimport CellEditor from './components/cell-editor.vue'\nimport TimeGrid from './components/time-grid.vue'\nimport TimeHeader from './components/time-header.vue'\nimport {\n endKeepingWork,\n isWorkTime,\n nextWorkTime,\n resolveCalendar,\n shiftWorkdays,\n shiftWorkMinutes,\n workdaysBetween,\n workingMinutes,\n} from './core/calendar'\nimport type { GanttMenuTarget } from './core/menu'\nimport { arrowPath, roundedPath, routeLink, type BarBox } from './core/geometry'\nimport { baselineSpans } from './core/baseline'\nimport {\n HEADER as EXPORT_HEADER,\n LINKED_END_ROOM,\n drawGantt,\n paddedRange,\n svgDocument,\n type ExportColumn,\n} from './export/draw'\nimport { readTheme, type ExportTheme } from './export/theme'\nimport { GanttHistory } from './core/history'\nimport { readDocument } from './core/document'\nimport { linkRefusal, linkTypeOf, type GanttLinkRefusal } from './core/links'\nimport { applyGanttCSV, readGanttCSV, type GanttCSVImportOptions } from './core/csv'\nimport { createId, spanOf, writeSpan, type GanttLink, type GanttTask } from './core/model'\nimport { computeCriticalPath, computeSchedule, findBrokenLinks } from './core/schedule'\nimport {\n emptyDocument,\n GanttTransaction,\n type GanttDocument,\n type GanttStep,\n type GanttTransactionMeta,\n} from './core/transaction'\nimport { DAY_WIDTH_RANGE, levelByName, snapStep, type GanttScale } from './core/scale'\nimport { MINUTES_PER_DAY, dayIndex, dayStart, formatTime, parseTime } from './core/time'\nimport { buildTree, type GanttRow } from './core/tree'\nimport { useTimeAxis } from './time-axis'\nimport {\n dateLabel,\n datesLabel,\n durationLabel,\n endLabel,\n markerRoom,\n spokenDuration,\n unitLabel,\n} from './labels'\nimport { format, useGanttMessages } from './locale'\nimport type {\n GanttColumn,\n GanttEmits,\n GanttExpose,\n GanttInsertOptions,\n GanttExportOptions,\n GanttImageOptions,\n GanttMove,\n GanttPrintOptions,\n GanttCSVExportOptions,\n GanttProps,\n GanttSlots,\n} from './types'\n\ndefineOptions({ name: 'DouGantt' })\n\nconst props = withDefaults(defineProps<GanttProps>(), {\n table: true,\n tableWidth: 360,\n rowHeight: 36,\n barLabel: 'auto',\n today: true,\n scheduling: 'manual',\n menu: true,\n addRow: 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))\nconst tasks = computed(() => source.value.tasks)\nconst links = computed(() => source.value.links)\nconst selection = defineModel<string[]>('selection', { default: () => [] })\nconst scale = defineModel<GanttScale>('scale')\nconst emit = defineEmits<GanttEmits>()\ndefineSlots<GanttSlots>()\n\nconst messages = useGanttMessages(() => props.messages)\nconst resolved = computed(() => resolveGanttExtensions(props.extensions))\nconst { locale } = useLocale()\nconst weekStart = computed(() => props.weekStart ?? locale.value.region.weekStart)\n\n/** Height of the two-row time header, in px; the table's header matches it. */\nconst HEADER = 56\n/** Height of a task bar, in px. */\nconst BAR = 20\n/** Room around the visible part that is rendered too, in px. */\nconst OVERSCAN_X = 320\nconst OVERSCAN_ROWS = 6\n/** Below this width the table steps aside and the timeline takes the whole width. */\nconst NARROW = 520\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\n// Tasks ---------------------------------------------------------------------------\n\n// Document and history -------------------------------------------------------------\n\nconst history = new GanttHistory()\n/** An extension's history, used instead of the chart's own. */\nlet customHistory: GanttHistoryHandler | null = null\nconst canUndo = ref(false)\nconst canRedo = ref(false)\nconst syncHistory = () => {\n canUndo.value = customHistory ? customHistory.canUndo() : history.canUndo\n canRedo.value = customHistory ? customHistory.canRedo() : history.canRedo\n}\nconst editable = computed(() => !props.readonly)\n\n/*\n * A bound v-model only catches up when the parent renders, so changes made\n * in the same tick (a drag, then undo) build on the latest commit instead.\n */\nlet latest: GanttDocument | null = null\nconst docOf = (): GanttDocument => latest ?? toRaw(source.value)\n\n/** The last transaction committed, for reading out what an extension's undo changed. */\nlet lastCommit: GanttTransaction | null = null\n\n/** Runs the extensions' `transaction` hooks; `false` when one cancelled the change. */\nfunction check(tr: GanttTransaction) {\n for (const hook of resolved.value.transactions) {\n hook(tr, context)\n if (tr.cancelled) {\n emit('transactionCancel', tr)\n refuse(tr.cancelReason ?? messages.value.changeCancelled)\n return false\n }\n }\n return true\n}\n\nfunction commit(tr: GanttTransaction) {\n if (!check(tr)) return false\n lastCommit = tr\n if (!latest) void nextTick(() => (latest = null))\n latest = tr.doc\n model.value = tr.doc\n if (tr.touches('tasks')) {\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 }\n if (selectedLink.value && !tr.doc.links.some((link) => link.id === selectedLink.value))\n selectedLink.value = null\n emit('transaction', tr)\n return true\n}\n\n/** Tasks a transaction gave dates to. */\nfunction datedIn(steps: readonly GanttStep[]) {\n const out = new Set<string>()\n for (const step of steps) {\n if (step.type === 'addTask') out.add(step.task.id)\n else if (step.type === 'updateTask' && ('start' in step.patch || 'end' in step.patch))\n out.add(step.id)\n }\n return out\n}\n\n/** How many tasks automatic scheduling pushed in each transaction. */\nconst pushedBy = new WeakMap<GanttTransaction, number>()\n\n/** With automatic scheduling, pushes the tasks that wait on what changed, in the same transaction. */\nfunction pushFollowers(tr: GanttTransaction) {\n if (props.scheduling !== 'auto' || tr.meta.schedule === false) return\n const result = computeSchedule(tr.doc, { calendar: calendar.value, fixed: datedIn(tr.steps) })\n for (const [id, patch] of result.moves) tr.updateTask(id, patch)\n if (result.moves.size) pushedBy.set(tr, result.moves.size)\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 pushFollowers(tr)\n const before = selection.value\n if (!commit(tr)) return tr\n if (!customHistory) history.record(tr, before, selection.value)\n syncHistory()\n return tr\n}\n\nfunction travel(direction: 'undo' | 'redo') {\n if (customHistory) {\n const before = lastCommit\n const done = customHistory[direction]()\n syncHistory()\n return done && lastCommit !== before ? lastCommit : null\n }\n const result = history[direction](docOf())\n if (!result) return null\n result.tr.setMeta({ origin: direction, history: false, schedule: false })\n commit(result.tr)\n selection.value = result.selection\n syncHistory()\n return result.tr\n}\n\nconst undo = () => !!travel('undo')\nconst redo = () => !!travel('redo')\n\n/** Moves tasks so that every link is kept, as one undo step. */\nfunction schedule(options: { pull?: boolean } = {}) {\n const result = computeSchedule(docOf(), { calendar: calendar.value, pull: options.pull })\n if (result.moves.size)\n transact(\n (tr) => {\n for (const [id, patch] of result.moves) tr.updateTask(id, patch)\n },\n { schedule: false },\n )\n announce(format(messages.value.scheduled, { count: result.moves.size }))\n return result\n}\n\nconst selectedLink = ref<string | null>(null)\n\n// While a bar is dragged, the chart shows the tasks as they would be after the drop.\nconst drag = shallowRef<Drag | null>(null)\n/*\n * The tasks and links as plain arrays. They are replaced, never changed in\n * place, so reading them raw spares tracking every field of every task.\n */\nconst taskList = computed(() => toRaw(tasks.value))\nconst linkList = computed(() => toRaw(links.value))\nconst shown = computed(() => {\n if (!drag.value?.started) return taskList.value\n const changes = dragPreview.value\n if (!changes) return taskList.value\n return taskList.value.map((task) => {\n const patch = changes.get(task.id)\n return patch ? { ...task, ...patch } : task\n })\n})\n\nconst tree = computed(() => buildTree(shown.value))\nconst rows = computed(() => tree.value.rows)\nconst calendar = computed(() => resolveCalendar(props.calendar))\nconst selected = computed(() => new Set(selection.value))\nconst shownDoc = computed<GanttDocument>(() => ({\n version: 1,\n tasks: shown.value,\n links: linkList.value,\n}))\n\n/** While dragging with automatic scheduling: the tasks the drop would push, and where to. */\n/** The tasks a drag is about to change, with the ones it would push, as they would be written. */\nconst preview = computed<Map<string, Partial<GanttTask>> | null>(() => {\n const changes = dragPreview.value\n const pushes = pushPreview.value?.moves\n if (!changes || !pushes?.size) return changes\n const out = new Map(changes)\n for (const [id, patch] of pushes) out.set(id, patch)\n return out\n})\n\nconst pushPreview = computed(() => {\n const changes = dragPreview.value\n if (props.scheduling !== 'auto' || !changes || drag.value?.mode === 'progress') return null\n return computeSchedule(shownDoc.value, { calendar: calendar.value, fixed: changes.keys() })\n})\n\n/** Links whose later task starts (or ends) before the link allows. */\nconst brokenLinks = computed(\n () =>\n pushPreview.value?.violated ?? findBrokenLinks(shownDoc.value, { calendar: calendar.value }),\n)\nconst critical = computed(() =>\n props.critical ? computeCriticalPath(shownDoc.value, { calendar: calendar.value }) : null,\n)\n\n// Time axis -----------------------------------------------------------------------\n\n/** The time the tasks cover, or `null` when nothing is scheduled. */\nconst content = computed(() => {\n let start = Infinity\n let end = -Infinity\n for (const row of tree.value.byId.values()) {\n if (!row.span) continue\n start = Math.min(start, row.span.start)\n end = Math.max(end, row.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 // worked out from the root, so a table about to step aside doesn't count\n fitWidth: () => (showTable.value ? viewWidth.value : rootWidth.value),\n})\nconst {\n dayWidth,\n level,\n ppm,\n now,\n extent,\n origin,\n totalWidth,\n xOf,\n contentX,\n scrollTop,\n scrollLeft,\n scrollbar,\n onChartScroll,\n onTableScroll,\n scrollToTime,\n scrollToDate,\n scrollToToday,\n fit,\n zoomTo,\n zoomIn,\n zoomOut,\n onWheel,\n timeWindow,\n topCells,\n bottomCells,\n shaded,\n todayX,\n} = axis\n\nwatch([viewHeight, viewWidth], axis.measureScrollbar, { flush: 'post' })\nwatch([rootWidth, viewWidth], axis.fitWhenSized)\n\n// Dates that can't be read leave a task unscheduled; say so once per task while developing.\nconst warned = new Set<string>()\nwatch(\n tasks,\n (list) => {\n // the app's bundler replaces this, as for Vue's own warnings\n if (process.env.NODE_ENV === 'production') return\n for (const task of list) {\n for (const key of ['start', 'end'] as const) {\n const text = task[key]\n if (text == null || text === '' || parseTime(text) || warned.has(`${task.id}:${key}`))\n continue\n warned.add(`${task.id}:${key}`)\n console.warn(\n `[@dou-ui/gantt] task \"${task.id}\" has a ${key} that can't be read: \"${text}\". Use YYYY-MM-DD or YYYY-MM-DD HH:mm.`,\n )\n }\n }\n },\n { immediate: true },\n)\n\n// What is in view -----------------------------------------------------------------\n\nconst bodyHeight = computed(() => Math.max(0, viewHeight.value - HEADER))\nconst rowWindow = computed(() => {\n const h = props.rowHeight\n const from = Math.max(0, Math.floor(scrollTop.value / h) - OVERSCAN_ROWS)\n const to = Math.min(\n rows.value.length,\n Math.ceil((scrollTop.value + bodyHeight.value) / h) + OVERSCAN_ROWS,\n )\n return { from, to }\n})\nconst visibleRows = computed(() => rows.value.slice(rowWindow.value.from, rowWindow.value.to))\n\n/**\n * Without the table, rows whose bar is out of view (or that have none)\n * show their title at the left edge of the timeline.\n */\nconst rowTitles = computed(() => {\n if (showTable.value) return []\n const from = origin.value + scrollLeft.value / ppm.value\n const to = origin.value + (scrollLeft.value + viewWidth.value) / ppm.value\n return visibleRows.value\n .filter((row) => !row.span || row.span.end < from || row.span.start > to)\n .map((row) => ({ row }))\n})\n\n/**\n * Tasks with a link at their finishing end. Their side titles step further\n * out, past where the line leaves or arrives, so the two don't run together.\n */\nconst linkedEnds = computed(() => {\n const out = new Set<string>()\n for (const link of linkList.value) {\n const type = link.type ?? 'fs'\n if (type === 'fs' || type === 'ff') out.add(link.from)\n if (type === 'ff' || type === 'sf') out.add(link.to)\n }\n return out\n})\n\n/** Where a bar's side title starts, from the bar's left: clear of a line leaving its end. */\nconst sideOffset = (bar: Bar) =>\n (bar.row.milestone ? 12 : bar.width + 8) +\n (linkedEnds.value.has(bar.row.task.id) ? LINKED_END_ROOM : 0)\n\n/** Rows titled at the edge; their own side labels would only crowd it. */\nconst titled = computed(() => new Set(rowTitles.value.map((item) => item.row.task.id)))\n\n// Bars ------------------------------------------------------------------------------\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 row: GanttRow\n left: number\n width: number\n top: number\n /** Where the title goes. */\n label: 'inside' | 'outside' | 'none'\n aria: string\n}\n\nfunction describe(row: GanttRow) {\n const m = messages.value\n const { task, span } = row\n if (!span) return format(m.unscheduled, { title: task.title })\n const start = dateLabel(span.start, span.dateOnly, m)\n if (row.milestone) {\n const text = format(m.milestone, { title: task.title, start })\n const change = planChange(row)\n return change ? format(m.baseline.withChange, { task: text, change }) : text\n }\n const params = {\n title: task.title,\n dates: datesLabel(span, m),\n duration: spokenDuration(span, calendar.value, m),\n progress: Math.round(row.progress * 100),\n }\n let text = format(row.summary ? m.summary : m.task, params)\n if (critical.value?.tasks.has(task.id)) text = format(m.criticalTask, { task: text })\n const change = planChange(row)\n return change ? format(m.baseline.withChange, { task: text, change }) : text\n}\n\nconst bars = computed<Bar[]>(() => {\n const { start, end } = timeWindow.value\n const h = props.rowHeight\n const out: Bar[] = []\n for (const row of visibleRows.value) {\n const span = row.span\n if (!span || span.end < start - 600 / ppm.value || span.start > end) continue\n const left = xOf(span.start)\n const width = Math.max(row.milestone ? 0 : 2, (span.end - span.start) * ppm.value)\n let label: Bar['label'] = props.barLabel === 'auto' ? 'inside' : props.barLabel\n if (row.milestone || row.summary) label = props.barLabel === 'none' ? 'none' : 'outside'\n else if (props.barLabel === 'auto' && textWidth(row.task.title) + 20 > width) label = 'outside'\n out.push({ row, left, width, top: row.index * h, label, aria: describe(row) })\n }\n return out\n})\n\n/** Dashed outlines where a drag would push the waiting tasks to. */\nconst ghosts = computed(() => {\n const moves = pushPreview.value?.moves\n if (!moves?.size) return []\n const out: { id: string; left: number; width: number; top: number; milestone: boolean }[] = []\n for (const [id, patch] of moves) {\n const row = tree.value.byId.get(id)\n const span = row && row.index >= 0 && spanOf({ ...row.task, ...patch })\n if (!span) continue\n out.push({\n id,\n left: xOf(span.start),\n width: Math.max(2, (span.end - span.start) * ppm.value),\n top: row.index * props.rowHeight,\n milestone: row.milestone,\n })\n }\n return out\n})\n\n// Baseline --------------------------------------------------------------------------\n\n/** How far bars move up to make room for the baseline under them, in px. */\nconst BASELINE_SHIFT = 4\n\n/** The planned spans, by task. */\nconst planned = computed(() =>\n props.baseline\n ? baselineSpans(props.baseline, (id) => !!tree.value.byId.get(id)?.milestone)\n : null,\n)\nconst barShift = computed(() => (planned.value ? BASELINE_SHIFT : 0))\n\n/** A thin line under each bar in view, where the baseline had it. */\nconst baselineBars = computed(() => {\n const plans = planned.value\n if (!plans) return []\n const out: { id: string; left: number; width: number; top: number; milestone: boolean }[] = []\n for (const bar of bars.value) {\n const span = plans.get(bar.row.task.id)\n if (!span) continue\n out.push({\n id: bar.row.task.id,\n left: xOf(span.start),\n width: Math.max(2, (span.end - span.start) * ppm.value),\n top: bar.top,\n milestone: bar.row.milestone,\n })\n }\n return out\n})\n\n/** How a task's end compares with its plan, as words; empty when on plan. */\nfunction planChange(row: GanttRow) {\n const plan = planned.value?.get(row.task.id)\n const span = row.span\n if (!plan || !span || plan.end === span.end) return ''\n const m = messages.value\n // in calendar time: a slip over a weekend is still a slip\n const gap = Math.abs(span.end - plan.end)\n const duration =\n plan.dateOnly && span.dateOnly\n ? format(m.days, { count: Math.round(gap / MINUTES_PER_DAY) })\n : format(m.hours, { count: Math.round((gap / 60) * 10) / 10 })\n return format(span.end > plan.end ? m.baseline.late : m.baseline.early, { duration })\n}\n\n// Links -----------------------------------------------------------------------------\n\n/** Half the width of a milestone's diamond, where lines meet it. */\nconst MILESTONE = 7\n\nfunction boxOf(row: GanttRow): BarBox | null {\n if (!row.span || row.index < 0) return null\n const y = row.index * props.rowHeight + props.rowHeight / 2 - barShift.value\n if (row.milestone) {\n const x = xOf(row.span.start)\n return { left: x - MILESTONE, right: x + MILESTONE, y }\n }\n const left = xOf(row.span.start)\n return { left, right: left + Math.max(2, (row.span.end - row.span.start) * ppm.value), y }\n}\n\nconst linkPaths = computed(() => {\n if (dayWidth.value < 1) return []\n const { from, to } = rowWindow.value\n const xFrom = scrollLeft.value - OVERSCAN_X\n const xTo = scrollLeft.value + (viewWidth.value || 800) + OVERSCAN_X\n const out: { link: GanttLink; d: string; arrow: string }[] = []\n for (const link of linkList.value) {\n const a = tree.value.byId.get(link.from)\n const b = tree.value.byId.get(link.to)\n if (!a || !b || a === b) continue\n if (Math.max(a.index, b.index) < from || Math.min(a.index, b.index) >= to) continue\n const boxA = boxOf(a)\n const boxB = boxOf(b)\n if (!boxA || !boxB) continue\n if (Math.max(boxA.right, boxB.right) + 24 < xFrom || Math.min(boxA.left, boxB.left) - 24 > xTo)\n continue\n const route = routeLink(boxA, boxB, link.type, props.rowHeight)\n out.push({\n link,\n d: roundedPath(route.points),\n arrow: arrowPath(route.tip, route.direction),\n })\n }\n return out\n})\n\n// Table ------------------------------------------------------------------------------\n\nconst narrow = computed(() => rootWidth.value > 0 && rootWidth.value < NARROW)\nconst showTable = computed(() => props.table && !narrow.value)\n/** The narrowest the table gets, in px. */\nconst TABLE_MIN = 160\nconst tableSize = ref(props.tableWidth)\n/** The table leaves the timeline at least 40% of the width. */\n/** The row below the table that adds a task. */\nconst showAddRow = computed(() => editable.value && props.addRow && showTable.value)\n/** Rows of height on both sides: the tasks, and the adding row. */\nconst bodyRows = computed(() => rows.value.length + (showAddRow.value ? 1 : 0))\nconst tableMax = computed(() => Math.max(TABLE_MIN, Math.round((rootWidth.value || 1000) * 0.6)))\nconst clampTable = (size: number) => Math.min(tableMax.value, Math.max(TABLE_MIN, size))\nwatch(tableMax, () => (tableSize.value = clampTable(tableSize.value)))\n\nfunction onDividerDown(event: PointerEvent) {\n if (event.button !== 0) return\n const el = event.currentTarget as HTMLElement\n el.setPointerCapture(event.pointerId)\n const from = event.clientX\n const size = tableSize.value\n const move = (e: PointerEvent) => (tableSize.value = clampTable(size + e.clientX - from))\n const up = () => {\n el.removeEventListener('pointermove', move)\n el.removeEventListener('pointerup', up)\n el.removeEventListener('pointercancel', up)\n }\n el.addEventListener('pointermove', move)\n el.addEventListener('pointerup', up)\n el.addEventListener('pointercancel', up)\n}\n\nfunction onDividerKey(event: KeyboardEvent) {\n const by = event.shiftKey ? 80 : 16\n if (event.key === 'ArrowLeft') tableSize.value = clampTable(tableSize.value - by)\n else if (event.key === 'ArrowRight') tableSize.value = clampTable(tableSize.value + by)\n else return\n event.preventDefault()\n}\n\nconst columns = computed<GanttColumn[]>(() => [\n ...(props.columns ?? [\n { key: 'title' },\n { key: 'start', width: 96 },\n { key: 'duration', width: 72 },\n ]),\n ...resolved.value.columns,\n])\n\n/** Columns without a width in px are counted at this, or at their `minWidth`. */\nconst FLEX_COLUMN = 64\nconst TITLE_MIN = 120\n\n/** A length in px, when it is one. */\nconst px = (value: number | string | undefined) =>\n typeof value === 'number' ? value : value?.endsWith('px') ? Number.parseFloat(value) : null\n\n/** The narrowest the columns go; past it the table scrolls sideways. */\nconst tableMinWidth = computed(() => {\n let total = 0\n for (const column of columns.value)\n total +=\n px(column.width) ?? px(column.minWidth) ?? (column.key === 'title' ? TITLE_MIN : FLEX_COLUMN)\n return total\n})\n\nconst tablePane = useTemplateRef<HTMLElement>('tablePane')\n/** Height of the table's own sideways scrollbar, in px. */\nconst tableScrollbar = ref(0)\nwatch(\n [tableSize, tableMinWidth, viewHeight, showTable],\n () => {\n const el = tablePane.value\n tableScrollbar.value = el ? el.offsetHeight - el.clientHeight : 0\n },\n { flush: 'post' },\n)\n\nfunction headerOf(column: GanttColumn) {\n if (column.title != null) return column.title\n const builtIn = messages.value.columns as Record<string, string>\n return builtIn[column.key] ?? column.key\n}\n\n/** A value of a column of your own, as text. */\nfunction valueText(column: GanttColumn, value: unknown, task: GanttTask) {\n if (column.format) return column.format(value, task)\n return value == null ? '' : String(value)\n}\n\nfunction cellText(row: GanttRow, column: GanttColumn) {\n if (column.value) return valueText(column, column.value(row.task), row.task)\n const m = messages.value\n const span = row.span\n switch (column.key) {\n case 'title':\n return row.task.title\n case 'start':\n return span\n ? dateLabel(\n span.start - (row.milestone && span.dateOnly ? MINUTES_PER_DAY / 2 : 0),\n span.dateOnly,\n m,\n )\n : ''\n case 'end':\n return span && !row.milestone ? endLabel(span, m) : ''\n case 'duration':\n return span && !row.milestone ? durationLabel(span, calendar.value, m) : ''\n case 'progress':\n return span && !row.milestone ? `${Math.round(row.progress * 100)}%` : ''\n default:\n return valueText(column, row.task.data?.[column.key], row.task)\n }\n}\n\nconst cssLength = (value: number | string | undefined) =>\n typeof value === 'number' ? `${value}px` : value\n\nfunction columnStyle(column: GanttColumn) {\n const width = cssLength(column.width)\n return {\n flex: width ? `0 0 ${width}` : '1 1 0',\n width,\n minWidth: cssLength(column.minWidth),\n }\n}\n\n// folding is a view change: it stays out of the undo history\nfunction toggle(row: GanttRow) {\n transact((tr) => tr.updateTask(row.task.id, { collapsed: !row.task.collapsed || undefined }), {\n history: false,\n })\n}\n\n// Editing in the table -------------------------------------------------------------\n\n/** The cell being edited; `id` is `null` for the row that adds a task. */\nconst editing = ref<{ id: string | null; key: string } | null>(null)\n\nconst BUILT_IN_EDITABLE = new Set(['title', 'start', 'end', 'duration', 'progress'])\n\n/** Whether a cell can be edited: built-in columns unless turned off, others when turned on. */\nfunction canEdit(row: GanttRow, column: GanttColumn) {\n if (!editable.value) return false\n if (column.editable === false) return false\n if (!BUILT_IN_EDITABLE.has(column.key) || column.value) return column.editable === true\n if (column.key === 'title') return true\n // a summary's dates and progress come from its children\n if (row.summary || row.task.locked) return false\n if (row.milestone) return column.key === 'start'\n return column.key === 'start' || !!row.span\n}\n\n/** Starts editing a cell, the task's title by default. */\nfunction editCell(id: string, key = 'title') {\n const row = tree.value.byId.get(id)\n const column = columns.value.find((item) => item.key === key)\n if (!row || !column || !canEdit(row, column)) return false\n if (row.index < 0) return false\n active.value = id\n scrollToTask(id)\n editing.value = { id, key }\n return true\n}\n\nfunction editorKind(row: GanttRow, column: GanttColumn) {\n const key = column.key\n if (key === 'start' || key === 'end') return row.span && !row.span.dateOnly ? 'datetime' : 'date'\n if (key === 'duration' || key === 'progress') return 'number'\n return BUILT_IN_EDITABLE.has(key) ? 'text' : (column.input ?? 'text')\n}\n\n/** What an editor starts with: the value as it would be typed. */\nfunction editorValue(row: GanttRow, column: GanttColumn) {\n const { task, span } = row\n const key = column.key\n switch (key) {\n case 'title':\n return task.title\n case 'start':\n return task.start ?? ''\n case 'end':\n return span\n ? // a date-only span ends on its last day; a summary ending on a milestone, on that day\n formatTime(span.dateOnly ? span.end - 1 : span.end, span.dateOnly)\n : ''\n case 'duration': {\n if (!span) return ''\n const work = span.dateOnly\n ? workdaysBetween(calendar.value, span.start, span.end)\n : workingMinutes(calendar.value, span.start, span.end) / 60\n return String(Math.round(work * 10) / 10)\n }\n case 'progress':\n return String(Math.round(row.progress * 100))\n default: {\n const value = task.data?.[key]\n return value == null ? '' : String(value)\n }\n }\n}\n\n/**\n * The change an edited value makes to its task: `null` when it makes\n * none, `false` when the value can't be read.\n */\nfunction editPatch(\n row: GanttRow,\n column: GanttColumn,\n text: string,\n): Partial<GanttTask> | null | false {\n const { task, span } = row\n const key = column.key\n const value = text.trim()\n switch (key) {\n case 'title':\n return value && value !== task.title ? { title: value } : null\n case 'start': {\n const at = parseTime(value)\n if (!at) return false\n if (row.milestone) return { start: value }\n if (!span)\n return writeSpan(\n { ...task, start: value },\n at.at,\n at.at + (at.dateOnly ? MINUTES_PER_DAY : 60),\n )\n // a new start moves the task, keeping its work\n return writeSpan(task, at.at, endKeepingWork(calendar.value, span, at.at))\n }\n case 'end': {\n const at = parseTime(value)\n if (!span) return null\n const end = at ? at.at + (at.dateOnly ? MINUTES_PER_DAY : 0) : -Infinity\n return end > span.start ? writeSpan(task, span.start, end) : false\n }\n case 'duration': {\n const count = Number(value)\n if (!span) return null\n if (!value || !Number.isFinite(count) || count <= 0) return false\n const end = span.dateOnly\n ? shiftWorkdays(calendar.value, span.start, count)\n : shiftWorkMinutes(calendar.value, span.start, Math.round(count * 60))\n return writeSpan(task, span.start, end)\n }\n case 'progress': {\n const count = Number(value)\n if (!value || !Number.isFinite(count)) return false\n return { progress: Math.min(100, Math.max(0, count)) / 100 }\n }\n default: {\n const next = column.parse ? column.parse(text, task) : value\n if (next === undefined) return false\n return next === task.data?.[key] ? null : { data: { ...task.data, [key]: next } }\n }\n }\n}\n\n/** Says a cell's value can't be read. */\nfunction onEditInvalid(column: GanttColumn) {\n announce(format(messages.value.invalidValue, { column: headerOf(column) }))\n}\n\nfunction onEditDone(row: GanttRow, column: GanttColumn, value: string | null) {\n editing.value = null\n focusTask(row.task.id)\n if (value == null) return\n const patch = editPatch(row, column, value)\n if (!patch) return\n if ('start' in patch) {\n const refusal = checkMove(new Map([[row.task.id, patch]]))\n if (refusal) return announce(refusal)\n }\n const done = transact((tr) => tr.updateTask(row.task.id, patch))\n announceTask(row.task.id, done)\n}\n\n/** Tab in the title field indents, Shift+Tab outdents, and editing goes on. */\nfunction onEditTab(row: GanttRow, back: boolean) {\n const id = row.task.id\n const moved = back ? outdentTask(id) : indentTask(id)\n if (moved) void nextTick(() => editCell(id))\n}\n\n/** Adds a task at the end, from the row below the table. */\nfunction onAddDone(value: string | null) {\n const title = value?.trim()\n if (value == null) {\n editing.value = null\n root.value?.focus({ preventScroll: true })\n return\n }\n if (!title) return\n const id = createId()\n const last = lastDated()\n const span = newTaskSpan(last && { after: last })\n transact((tr) => tr.addTask({ id, title, ...writeSpan({ id, title }, span.start, span.end) }))\n active.value = id\n void nextTick(() => scrollToTask(id))\n announce(format(messages.value.taskAdded, { title }))\n}\n\n/** The lowest row that has dates. */\nfunction lastDated() {\n const list = tree.value.rows\n for (let i = list.length - 1; i >= 0; i--) if (list[i]!.span) return list[i]\n return undefined\n}\n\n/**\n * Dates for a new task, so it shows on the timeline: a working day from\n * `at`, or right after the task above it (a working hour, if that one has\n * times), or from today.\n */\nfunction newTaskSpan(from?: { at: number } | { after: GanttRow }) {\n const cal = calendar.value\n const above = from && 'after' in from ? from.after.span : null\n if (above && !above.dateOnly) {\n const start = nextWorkTime(cal, above.end, false)\n return { start, end: shiftWorkMinutes(cal, start, 60) }\n }\n const day = from && 'at' in from ? dayStart(from.at) : (above?.end ?? dayStart(now.value))\n const start = nextWorkTime(cal, day, true)\n return { start, end: shiftWorkdays(cal, start, 1) }\n}\n\n/** Adds a task after another one, or as its last subtask, and starts editing its title. */\nfunction insertTask(near: string | null, options: GanttInsertOptions = {}) {\n const { where = 'after' } = options\n const id = createId()\n const title = messages.value.newTask\n const row = near == null ? undefined : tree.value.byId.get(near)\n const inside = !!row && where === 'inside'\n const at = typeof options.start === 'string' ? parseTime(options.start)?.at : options.start\n // the task the new one comes after: the last subtask when adding inside\n const children = row && inside ? (tree.value.childrenOf.get(row.task.id) ?? []) : []\n const above = inside\n ? children.length\n ? tree.value.byId.get(children[children.length - 1]!.id)\n : undefined\n : (row ?? lastDated())\n const span = newTaskSpan(\n at != null ? { at } : above ? { after: above } : row?.span ? { at: row.span.start } : undefined,\n )\n transact((tr) => {\n tr.addTask({ id, title, ...writeSpan({ id, title }, span.start, span.end) })\n if (!row) return\n const nearId = row.task.id\n if (inside) {\n tr.moveTask(id, nearId)\n if (row.task.collapsed) tr.updateTask(nearId, { collapsed: undefined })\n } else {\n const parent = parentOf(nearId)\n const siblings = tree.value.childrenOf.get(parent) ?? []\n tr.moveTask(id, parent, siblings.findIndex((task) => task.id === nearId) + 1)\n }\n })\n selection.value = [id]\n void nextTick(() => editCell(id))\n announce(format(messages.value.taskAdded, { title }))\n return id\n}\n\n/** A task's parent as laid out: a missing or looping parent counts as none. */\nfunction parentOf(id: string) {\n for (const [parent, children] of tree.value.childrenOf)\n if (children.some((task) => task.id === id)) return parent\n return undefined\n}\n\n/** Makes a task the last subtask of the task above it among its siblings. */\nfunction indentTask(id: string) {\n if (!editable.value) return false\n const siblings = tree.value.childrenOf.get(parentOf(id)) ?? []\n const index = siblings.findIndex((task) => task.id === id)\n const above = siblings[index - 1]\n if (index < 1 || !above || above.type === 'milestone') return false\n transact((tr) => {\n tr.moveTask(id, above.id)\n if (above.collapsed) tr.updateTask(above.id, { collapsed: undefined })\n })\n announceMoved(id)\n return true\n}\n\n/** Moves a task out of its parent, to just after it. */\nfunction outdentTask(id: string) {\n if (!editable.value) return false\n const parent = parentOf(id)\n if (parent == null) return false\n const grand = parentOf(parent)\n const index = (tree.value.childrenOf.get(grand) ?? []).findIndex((task) => task.id === parent)\n transact((tr) => tr.moveTask(id, grand, index + 1))\n announceMoved(id)\n return true\n}\n\n/** Says where a task went in the outline, once the change has reached the rows. */\nfunction announceMoved(id: string) {\n void nextTick(() => {\n const byId = tree.value.byId\n const task = byId.get(id)?.task.title ?? ''\n const up = parentOf(id)\n const parent = up == null ? undefined : byId.get(up)?.task.title\n const m = messages.value\n announce(parent ? format(m.movedUnder, { task, parent }) : format(m.movedTop, { task }))\n })\n}\n\n/** Removes tasks with their subtasks and links, as one undo step. */\nfunction removeTasks(ids: string[]) {\n if (!editable.value || !ids.length) return\n const titles = ids.map((id) => tree.value.byId.get(id)?.task.title).filter(Boolean)\n transact((tr) => {\n for (const id of ids) tr.removeTask(id)\n })\n if (active.value && !tasks.value.some((task) => task.id === active.value)) active.value = null\n announce(\n format(messages.value.tasksRemoved, { titles: titles.join(messages.value.listSeparator) }),\n )\n}\n\nfunction onCellDblclick(row: GanttRow, column: GanttColumn, event: MouseEvent) {\n if (!canEdit(row, column)) return\n event.stopPropagation()\n editCell(row.task.id, column.key)\n}\n\nfunction startAdding() {\n editing.value = { id: null, key: 'title' }\n}\n\n// Moving rows in the table -------------------------------------------------------------\n\ninterface RowDrag {\n id: string\n pointerId: number\n touch: boolean\n clientX: number\n y0: number\n /** Where the pointer is, in px from the top of the rows. */\n y: number\n clientY: number\n started: boolean\n}\n\nconst rowDrag = shallowRef<RowDrag | null>(null)\nlet rowTimer: ReturnType<typeof setTimeout> | undefined\nlet rowFrame = 0\n\n/** The rows moved together: the pressed one and everything under it. */\nconst rowDragged = computed(() => {\n const d = rowDrag.value\n if (!d?.started) return null\n const out = new Set([d.id])\n const stack = [d.id]\n while (stack.length)\n for (const child of tree.value.childrenOf.get(stack.pop()) ?? []) {\n out.add(child.id)\n stack.push(child.id)\n }\n return out\n})\n\n/** Where a row being dragged would go: before or after a row, or inside it. */\nconst rowDrop = computed(() => {\n const d = rowDrag.value\n const moved = rowDragged.value\n if (!d?.started || !moved) return null\n const list = rows.value\n if (!list.length) return null\n const h = props.rowHeight\n const index = Math.max(0, Math.min(list.length - 1, Math.floor(d.y / h)))\n const row = list[index]!\n if (moved.has(row.task.id)) return null\n const within = d.y / h - index\n let where: 'before' | 'after' | 'inside' =\n within < 0.3 ? 'before' : within > 0.7 || row.milestone ? 'after' : 'inside'\n if (row.milestone && within < 0.5) where = 'before'\n return { row, where }\n})\n\n/** The line showing where a dragged row goes, indented to the level it would take. */\nconst dropLine = computed(() => {\n const drop = rowDrop.value\n if (!drop || drop.where === 'inside') return null\n const { row, where } = drop\n const first = where === 'after' && row.summary && !row.task.collapsed\n return {\n transform: `translateY(${(row.index + (where === 'after' ? 1 : 0)) * props.rowHeight}px)`,\n left: `${(row.depth + (first ? 1 : 0)) * 16 + 30}px`,\n }\n})\n\nfunction onRowDown(event: PointerEvent, row: GanttRow) {\n if (event.button !== 0 || !editable.value || editing.value) return\n if ((event.target as HTMLElement).closest('button, input, .dou-gantt__editor')) return\n const body = tableBody.value!\n const y = event.clientY - body.getBoundingClientRect().top + body.scrollTop\n const d: RowDrag = {\n id: row.task.id,\n pointerId: event.pointerId,\n touch: event.pointerType === 'touch',\n clientX: event.clientX,\n y0: y,\n y,\n clientY: event.clientY,\n started: false,\n }\n rowDrag.value = d\n hold?.release()\n hold = holdPointer(event, root.value)\n window.addEventListener('pointermove', onRowMove)\n window.addEventListener('pointerup', onRowUp)\n window.addEventListener('pointercancel', stopRowDrag)\n window.addEventListener('keydown', onRowKey, true)\n // a finger picks a row up after a short rest\n if (d.touch)\n rowTimer = setTimeout(() => {\n if (rowDrag.value !== d) return\n beginRowDrag(d)\n navigator.vibrate?.(8)\n }, LONG_PRESS)\n}\n\nfunction beginRowDrag(d: RowDrag) {\n d.started = true\n hold?.grab()\n window.getSelection()?.removeAllRanges()\n rowFrame = requestAnimationFrame(rowAutoScroll)\n rowDrag.value = { ...d }\n}\n\nfunction onRowMove(event: PointerEvent) {\n const d = rowDrag.value\n if (!d || event.pointerId !== d.pointerId) return\n const body = tableBody.value!\n const y = event.clientY - body.getBoundingClientRect().top + body.scrollTop\n if (!d.started) {\n if (Math.abs(y - d.y0) <= THRESHOLD[d.touch ? 'touch' : 'mouse']) return\n if (d.touch) return stopRowDrag()\n beginRowDrag(d)\n }\n rowDrag.value = { ...rowDrag.value!, y, clientY: event.clientY }\n}\n\nfunction rowAutoScroll() {\n const d = rowDrag.value\n const body = tableBody.value\n if (!d?.started || !body) return\n const rect = body.getBoundingClientRect()\n let dy = -edgeSpeed(d.clientY - rect.top) || edgeSpeed(rect.bottom - d.clientY)\n if (Math.sign(dy) !== Math.sign(d.y - d.y0)) dy = 0\n if (dy) {\n body.scrollTop += dy\n onTableScroll()\n rowDrag.value = { ...d, y: d.clientY - rect.top + body.scrollTop }\n }\n rowFrame = requestAnimationFrame(rowAutoScroll)\n}\n\nfunction onRowUp(event: PointerEvent) {\n const d = rowDrag.value\n if (!d || event.pointerId !== d.pointerId) return\n const drop = rowDrop.value\n if (d.touch && d.started && Math.abs(d.y - d.y0) < THRESHOLD.touch) {\n // held, then let go without moving: the row's menu\n stopRowDrag()\n if (!menuAllowed(task(d.id), d.clientX, event.clientY)) return\n return void showMenu({ kind: 'task', task: task(d.id) }, d.clientX, event.clientY)\n }\n if (d.started) {\n dragged = true\n setTimeout(() => (dragged = false))\n if (drop) dropRow(d.id, drop.row, drop.where)\n }\n stopRowDrag()\n}\n\nfunction dropRow(id: string, target: GanttRow, where: 'before' | 'after' | 'inside') {\n const targetId = target.task.id\n transact((tr) => {\n if (where === 'inside') {\n tr.moveTask(id, targetId)\n if (target.task.collapsed) tr.updateTask(targetId, { collapsed: undefined })\n return\n }\n // after an open summary means first among its children\n if (where === 'after' && target.summary && !target.task.collapsed)\n return void tr.moveTask(id, targetId, 0)\n const parent = parentOf(targetId)\n const siblings = (tree.value.childrenOf.get(parent) ?? []).filter((task) => task.id !== id)\n const index = siblings.findIndex((task) => task.id === targetId)\n tr.moveTask(id, parent, where === 'before' ? index : index + 1)\n })\n announceTask(id)\n}\n\nfunction onRowKey(event: KeyboardEvent) {\n if (event.key !== 'Escape' || !rowDrag.value) return\n event.preventDefault()\n event.stopPropagation()\n stopRowDrag()\n}\n\nfunction stopRowDrag() {\n clearTimeout(rowTimer)\n rowTimer = undefined\n cancelAnimationFrame(rowFrame)\n hold?.release()\n hold = null\n window.removeEventListener('pointermove', onRowMove)\n window.removeEventListener('pointerup', onRowUp)\n window.removeEventListener('pointercancel', stopRowDrag)\n window.removeEventListener('keydown', onRowKey, true)\n rowDrag.value = null\n}\n\nonBeforeUnmount(stopRowDrag)\n\n// Selection and events ------------------------------------------------------------\n\nconst hovered = ref<string | null>(null)\n\nfunction select(task: GanttTask, event: MouseEvent) {\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) {\n selection.value = [id]\n }\n}\n\nfunction onTaskClick(row: GanttRow, event: MouseEvent) {\n if (dragged) return\n selectedLink.value = null\n active.value = row.task.id\n select(row.task, event)\n emit('taskClick', row.task, event)\n}\n\nconst highlightRows = computed(() =>\n visibleRows.value.filter(\n (row) => selected.value.has(row.task.id) || hovered.value === row.task.id,\n ),\n)\n\n// Dragging ---------------------------------------------------------------------------\n\ntype DragMode = 'move' | 'start' | 'end' | 'progress' | 'link' | 'draw'\ntype End = 'start' | 'end'\n\ninterface Drag {\n mode: DragMode\n /** The task pressed. */\n id: string\n pointerId: number\n touch: boolean\n /** Where the press was, in timeline px. */\n x0: number\n /** Where the pointer is now, in timeline px and on screen. */\n x: number\n clientX: number\n clientY: number\n /** Past the threshold: a drag, not a click. */\n started: boolean\n /** The tasks that move, with their spans when the drag began. */\n spans: Map<string, { start: number; end: number; dateOnly: boolean }>\n /** For `progress`: the bar's box when the drag began. */\n box?: { left: number; width: number }\n /** For `link`: the end it starts from, and what is under the pointer. */\n from?: End\n target?: { id: string; end: End; refusal: GanttLinkRefusal | null; reason?: string }\n /** For `draw`: the row's y centre. */\n y?: number\n}\n\n/** Movement in px before a press becomes a drag. */\nconst THRESHOLD = { mouse: 3, touch: 6 }\n/** How long a finger rests on a bar before it picks it up, in ms. */\nconst LONG_PRESS = 300\n/** How close to the edge a drag starts scrolling, in px. */\nconst EDGE = 32\n\n/** The last drag ended a moment ago: swallow the click that follows it. */\nlet dragged = false\nlet pressTimer: ReturnType<typeof setTimeout> | undefined\nlet frame = 0\n\n/** The step a drag snaps to for these spans: whole days when any is date-only. */\nfunction stepFor(spans: Iterable<{ dateOnly: boolean }>) {\n const step = snapStep(dayWidth.value)\n for (const span of spans) if (span.dateOnly) return Math.max(step, MINUTES_PER_DAY)\n return step\n}\n\nconst snap = (at: number, step: number) => Math.round(at / step) * step\nconst task = (id: string) => tasks.value.find((item) => item.id === id)!\n/** Auto-scroll speed in px per frame, `gap` px from the edge. */\nconst edgeSpeed = (gap: number) => (gap < EDGE ? Math.ceil(((EDGE - gap) / EDGE) * 16) : 0)\n\n/** The changes the current drag would make, by task id. */\nconst dragPreview = computed<Map<string, Partial<GanttTask>> | null>(() => {\n const d = drag.value\n if (!d?.started) return null\n const out = new Map<string, Partial<GanttTask>>()\n const delta = (d.x - d.x0) / ppm.value\n switch (d.mode) {\n case 'move': {\n const unit = stepFor(d.spans.values())\n const first = d.spans.get(d.id) ?? d.spans.values().next().value!\n const shift = snap(first.start + delta, unit) - first.start\n // the work stays the same: moving across a weekend stretches the bar\n for (const [id, span] of d.spans) {\n const start = span.start + shift\n out.set(id, writeSpan(task(id), start, endKeepingWork(calendar.value, span, start)))\n }\n break\n }\n case 'start':\n case 'end': {\n const span = d.spans.get(d.id)!\n const unit = stepFor([span])\n if (d.mode === 'start') {\n const start = Math.min(snap(span.start + delta, unit), span.end - unit)\n out.set(d.id, writeSpan(task(d.id), start, span.end))\n } else {\n const end = Math.max(snap(span.end + delta, unit), span.start + unit)\n out.set(d.id, writeSpan(task(d.id), span.start, end))\n }\n break\n }\n case 'progress': {\n const { left, width } = d.box!\n const value = Math.round(Math.min(1, Math.max(0, (d.x - left) / width)) * 20) / 20\n out.set(d.id, { progress: value })\n break\n }\n case 'draw': {\n const unit = Math.max(snapStep(dayWidth.value), MINUTES_PER_DAY)\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 out.set(d.id, writeSpan(task(d.id), start, end))\n break\n }\n case 'link':\n return null\n }\n return out\n})\n\n/** The leaf tasks a drag on `row` moves: the row itself, its subtasks, or the whole selection. */\nfunction movers(row: GanttRow) {\n const out = new Map<string, { start: number; end: number; dateOnly: boolean }>()\n const add = (id: string) => {\n const item = tree.value.byId.get(id)\n if (!item || item.task.locked) return\n if (item.summary) {\n for (const child of tree.value.childrenOf.get(id) ?? []) add(child.id)\n } else if (item.span) out.set(id, { ...item.span })\n }\n const ids = selected.value.has(row.task.id) ? selection.value : [row.task.id]\n for (const id of ids) add(id)\n return out\n}\n\nfunction startDrag(event: PointerEvent, row: GanttRow, mode: DragMode, from?: End) {\n if (event.button !== 0 || !editable.value) return\n const touch = event.pointerType === 'touch'\n hold?.release()\n hold = holdPointer(event, root.value)\n const x = contentX(event.clientX)\n const d: Drag = {\n mode,\n id: row.task.id,\n pointerId: event.pointerId,\n touch,\n x0: x,\n x,\n clientX: event.clientX,\n clientY: event.clientY,\n started: false,\n spans: new Map(),\n from,\n }\n if (mode === 'move') {\n d.spans = movers(row)\n if (!d.spans.size) return\n } else if (mode === 'start' || mode === 'end') {\n if (!row.span || row.task.locked) return\n d.spans.set(row.task.id, { ...row.span })\n } else if (mode === 'progress') {\n if (!row.span || row.task.locked) return\n d.box = {\n left: xOf(row.span.start),\n width: Math.max(1, (row.span.end - row.span.start) * ppm.value),\n }\n }\n drag.value = d\n window.addEventListener('pointermove', onDragMove)\n window.addEventListener('pointerup', onDragEnd)\n window.addEventListener('pointercancel', onDragCancel)\n window.addEventListener('keydown', onDragKey, true)\n // a finger picks a bar up after a short rest; moving sooner scrolls instead\n if (touch && mode === 'move') {\n pressTimer = setTimeout(() => {\n if (drag.value !== d) return\n begin(d)\n navigator.vibrate?.(8)\n }, LONG_PRESS)\n }\n}\n\nfunction begin(d: Drag) {\n d.started = true\n hold?.grab()\n frame = requestAnimationFrame(autoScroll)\n drag.value = { ...d }\n}\n\n/*\n * A press on a bar or a row: once it turns into a drag, the touch stops\n * scrolling and stays with the chart, also when the element under the\n * finger is drawn anew (a bar's title steps outside a shrinking bar, a row\n * scrolls out of the rendered window).\n */\nlet hold: PointerHold | null = null\n\nconst onTouchMove = (event: TouchEvent) => void axis.pinchMove(event)\nconst onTouchEnd = (event: TouchEvent) => axis.pinchEnd(event)\n\nfunction onTouchStart(event: TouchEvent) {\n // the second finger turns a press on a bar into a pinch\n if (!axis.pinchStart(event)) return\n stopDrag()\n stopRowDrag()\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})\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 if (!d.started) {\n const far = Math.abs(x - d.x0) > THRESHOLD[d.touch ? 'touch' : 'mouse']\n if (!far) return\n // a finger that moves before the long press scrolls the chart\n if (d.touch && d.mode === 'move') return stopDrag()\n begin(d)\n }\n update(drag.value!, x)\n}\n\nfunction update(d: Drag, x: number) {\n const next = { ...d, x }\n if (d.mode === 'link') next.target = linkTarget(d)\n drag.value = next\n}\n\nlet canLinkWarned = false\n// a `canLink` that forgets its last `return true` refuses every link\nfunction warnCanLink() {\n if (process.env.NODE_ENV === 'production' || canLinkWarned) return\n canLinkWarned = true\n console.warn(\n '[@dou-ui/gantt] canLink returned undefined, so the link is refused. Return true to allow it.',\n )\n}\n\n/** The bar end under the pointer, and whether a link to it would be refused. */\nfunction linkTarget(d: Drag): Drag['target'] {\n // look through lines and handles to the bar underneath\n const el = document\n .elementsFromPoint(d.clientX, d.clientY)\n .find((item): item is HTMLElement => item instanceof HTMLElement && !!item.dataset.task)\n const id = el?.dataset.task\n if (!el || !id) return undefined\n const rect = el.getBoundingClientRect()\n const end: End = d.clientX < rect.left + rect.width / 2 ? 'start' : 'end'\n return { id, end, ...checkLink(d.id, id, linkTypeOf(d.from!, end)) }\n}\n\n/** Whether a link may be made, and if not, why: the built-in rules, then `can-link` and the extensions'. */\nfunction checkLink(from: string, to: string, type: GanttLink['type']) {\n let refusal = linkRefusal(new Map(tasks.value.map((t) => [t.id, t])), links.value, from, to)\n let reason: string | undefined\n if (refusal && refusal !== 'self' && refusal !== 'custom')\n reason = messages.value.linkRefusals[refusal]\n if (refusal) return { refusal, reason }\n const link = { from, to, type }\n for (const rule of [...(props.canLink ? [props.canLink] : []), ...resolved.value.linkRules]) {\n const answer = rule(link, docOf())\n if (answer === true) continue\n if (answer === undefined) warnCanLink()\n return {\n refusal: 'custom' as const,\n reason: typeof answer === 'string' && answer ? answer : undefined,\n }\n }\n return { refusal: null, reason }\n}\n\n/**\n * Why new dates can't be written, from `can-move` and the extensions'; a\n * refusal without words gets a plain one. `null` when they can.\n */\nfunction checkMove(changes: Map<string, Partial<GanttTask>>) {\n const rules = [...(props.canMove ? [props.canMove] : []), ...resolved.value.moveRules]\n if (!rules.length) return null\n const doc = docOf()\n for (const [id, patch] of changes) {\n if (patch.start === undefined) continue\n const move: GanttMove = { task: task(id), start: patch.start, end: patch.end }\n for (const rule of rules) {\n const answer = rule(move, doc)\n if (answer === true) continue\n return typeof answer === 'string' && answer ? answer : messages.value.moveRefused\n }\n }\n return null\n}\n\n/** Why the current drag's dates would be refused, its own or those of the tasks it pushes. */\nconst dragRefusal = computed(() => {\n const changes = dragPreview.value\n if (!changes || drag.value?.mode === 'progress') return null\n const pushes = pushPreview.value?.moves\n return checkMove(pushes?.size ? new Map([...changes, ...pushes]) : changes)\n})\n\n// scroll while the pointer rests near an edge, and keep the drag under it\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: 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 const dy = -edgeSpeed(d.clientY - rect.top - HEADER) || edgeSpeed(rect.bottom - d.clientY)\n if (dx || (dy && d.mode === 'link')) {\n el.scrollLeft += dx\n if (d.mode === 'link') el.scrollTop += dy\n update(d, contentX(d.clientX))\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 stopDrag()\n if (!menuAllowed(task(d.id), event.clientX, event.clientY)) return\n return void showMenu({ kind: 'task', task: task(d.id) }, event.clientX, event.clientY)\n }\n if (d.started) {\n dragged = true\n setTimeout(() => (dragged = false))\n if (d.mode === 'link') finishLink(d)\n else if (dragRefusal.value) announce(dragRefusal.value)\n else {\n const changes = dragPreview.value\n if (changes) {\n const done = transact((tr) => {\n for (const [id, patch] of changes) tr.updateTask(id, patch)\n })\n announceTask(d.id, done)\n }\n }\n }\n stopDrag()\n}\n\nfunction finishLink(d: Drag) {\n const target = d.target\n if (!target || target.refusal) return\n const link: GanttLink = { id: createId(), from: d.id, to: target.id }\n const type = linkTypeOf(d.from!, target.end)\n if (type !== 'fs') link.type = type\n const done = transact((tr) => tr.addLink(link))\n emit('link', link)\n announceLinked(link, done)\n}\n\nfunction announceLinked(link: GanttLink, tr: GanttTransaction) {\n const m = messages.value\n announce(\n withPushed(format(m.linked, { from: task(link.from).title, to: task(link.to).title }), tr),\n )\n}\n\n/** Adds how many tasks automatic scheduling pushed, if any. */\nfunction withPushed(text: string, tr: GanttTransaction) {\n const count = pushedBy.get(tr)\n return count ? format(messages.value.pushed, { task: text, count }) : text\n}\n\nconst onDragCancel = () => stopDrag()\n\nfunction onDragKey(event: KeyboardEvent) {\n if (event.key !== 'Escape' || !drag.value) return\n event.preventDefault()\n event.stopPropagation()\n if (drag.value.started) {\n dragged = true\n setTimeout(() => (dragged = false))\n }\n stopDrag()\n}\n\nfunction stopDrag() {\n clearTimeout(pressTimer)\n cancelAnimationFrame(frame)\n hold?.release()\n hold = null\n window.removeEventListener('pointermove', onDragMove)\n window.removeEventListener('pointerup', onDragEnd)\n window.removeEventListener('pointercancel', onDragCancel)\n window.removeEventListener('keydown', onDragKey, true)\n drag.value = null\n}\n\nonBeforeUnmount(stopDrag)\n\nfunction onBarDown(event: PointerEvent, row: GanttRow) {\n const handle = (event.target as HTMLElement).closest<HTMLElement>('[data-handle]')?.dataset\n .handle as DragMode | `link-${End}` | undefined\n if (handle?.startsWith('link-')) {\n event.stopPropagation()\n return startDrag(event, row, 'link', handle.slice(5) as End)\n }\n if (handle) event.stopPropagation()\n startDrag(event, row, (handle as DragMode) ?? 'move')\n}\n\n/** A press on an empty row of a task without dates draws its dates. */\nfunction onBodyDown(event: PointerEvent) {\n if (\n event.target !== event.currentTarget &&\n !(event.target as HTMLElement).matches(\n '.dou-gantt__off, .dou-gantt__gridline, .dou-gantt__row-highlight',\n )\n )\n return\n const y =\n event.clientY - chart.value!.getBoundingClientRect().top - HEADER + chart.value!.scrollTop\n const row = rows.value[Math.floor(y / props.rowHeight)]\n if (!row || row.span || row.summary || row.task.locked) return\n if (event.pointerType === 'touch') return\n startDrag(event, row, 'draw')\n}\n\n/** The bar a link being drawn would end at, and whether it is refused. */\nconst linkHighlight = computed(() => {\n const target = drag.value?.target\n if (target) return { id: target.id, refused: !!target.refusal }\n const link = keyLink.value\n return link?.to ? { id: link.to, refused: !!link.refusal } : null\n})\n\n/** The line drawn while linking: from the end it started at to the pointer or target. */\nconst draftLink = computed(() => {\n const link = keyLink.value\n if (link?.to) {\n const a = tree.value.byId.get(link.from)\n const b = tree.value.byId.get(link.to)\n const boxA = a && boxOf(a)\n const boxB = b && boxOf(b)\n if (!boxA || !boxB) return null\n return {\n d: roundedPath(routeLink(boxA, boxB, 'fs', props.rowHeight).points),\n refused: !!link.refusal,\n }\n }\n const d = drag.value\n if (d?.mode !== 'link' || !d.started) return null\n const row = tree.value.byId.get(d.id)\n const box = row && boxOf(row)\n if (!box) return null\n const x = d.from === 'start' ? box.left : box.right\n const rect = chart.value!.getBoundingClientRect()\n const y = d.clientY - rect.top - HEADER + chart.value!.scrollTop\n return {\n d: `M${x} ${box.y} L${d.x} ${y}`,\n refused: !!d.target?.refusal,\n }\n})\n\n/** Keeps a tip inside the view: one that starts left of it, or runs past its right edge. */\nfunction tipLeft(x: number, text: string) {\n const right = scrollLeft.value + viewWidth.value - textWidth(text) - 24\n return Math.max(Math.min(x, right), scrollLeft.value + 8)\n}\n\n/** What a drag shows beside the pointer: new dates, the progress, or why it can't. */\nconst dragTip = computed(() => {\n const d = drag.value\n if (!d?.started) return null\n if (d.mode === 'link') {\n // say why the bar under the pointer can't take the link\n const target = d.target?.reason ? tree.value.byId.get(d.target.id) : undefined\n if (!target?.span || target.index < 0) return null\n return {\n text: d.target!.reason!,\n refused: true,\n left: tipLeft(xOf(target.span.start), d.target!.reason!),\n top: target.index * props.rowHeight,\n }\n }\n const row = tree.value.byId.get(d.id)\n if (!row?.span || row.index < 0) return null\n const m = messages.value\n const text = dragRefusal.value\n ? dragRefusal.value\n : d.mode === 'progress'\n ? format(m.progressTip, { value: Math.round(row.progress * 100) })\n : row.milestone\n ? dateLabel(\n row.span.start - (row.span.dateOnly ? MINUTES_PER_DAY / 2 : 0),\n row.span.dateOnly,\n m,\n )\n : format(m.rangeTip, {\n start: dateLabel(row.span.start, row.span.dateOnly, m),\n end: endLabel(row.span, m),\n duration: durationLabel(row.span, calendar.value, m),\n })\n return {\n text,\n refused: !!dragRefusal.value,\n left: tipLeft(xOf(row.span.start), text),\n top: row.index * props.rowHeight,\n }\n})\n\n// Menus -----------------------------------------------------------------------------------\n\nconst menu = useTemplateRef<InstanceType<typeof GanttMenu>>('menu')\nconst menuItems = shallowRef<ContextMenuItem[]>([])\nconst menuOpen = ref(false)\n\n/** The built-in items for a target. */\nfunction builtInItems(target: GanttMenuTarget): ContextMenuItem[] {\n const m = messages.value.menu\n if (target.kind === 'link')\n return [\n {\n key: 'remove-link',\n label: m.removeLink,\n icon: TrashIcon,\n tone: 'danger',\n run: () => {\n transact((tr) => tr.removeLink(target.link.id))\n announce(messages.value.linkRemoved)\n },\n },\n ]\n if (target.kind === 'canvas')\n return [\n {\n key: 'add',\n label: m.addTask,\n icon: PlusIcon,\n run: () => insertTask(null, { start: target.time }),\n },\n ]\n const id = target.task.id\n const row = tree.value.byId.get(id)\n const siblings = tree.value.childrenOf.get(parentOf(id)) ?? []\n const index = siblings.findIndex((item) => item.id === id)\n const ids = selected.value.has(id) ? selection.value : [id]\n return [\n { key: 'rename', label: m.rename, icon: EditIcon, shortcut: 'f2', run: () => editCell(id) },\n { key: 'add-after', label: m.addAfter, icon: PlusIcon, run: () => insertTask(id) },\n {\n key: 'add-inside',\n label: m.addInside,\n icon: ListTreeIcon,\n disabled: !!row?.milestone,\n run: () => insertTask(id, { where: 'inside' }),\n },\n {\n key: 'indent',\n label: m.indent,\n icon: IndentIcon,\n shortcut: 'mod+]',\n divided: true,\n disabled: index < 1 || siblings[index - 1]?.type === 'milestone',\n run: () => indentTask(id),\n },\n {\n key: 'outdent',\n label: m.outdent,\n icon: OutdentIcon,\n shortcut: 'mod+[',\n disabled: parentOf(id) == null,\n run: () => outdentTask(id),\n },\n {\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: true,\n run: () => removeTasks(ids),\n },\n ]\n}\n\n/** Your items, the extensions', then the built-in ones, a separator between each group. */\nfunction itemsFor(target: GanttMenuTarget) {\n const items: ContextMenuItem[] = []\n const groups = [\n props.contextMenu?.(target) ?? [],\n ...resolved.value.menus.map((fn) => fn(context, target)),\n ]\n if (props.menu && editable.value) groups.push(builtInItems(target))\n for (const list of groups)\n list.forEach((item, i) =>\n items.push(i === 0 && items.length ? { ...item, divided: true } : item),\n )\n return items\n}\n\ntype MenuOn =\n { kind: 'task'; task: GanttTask } | { kind: 'link'; link: GanttLink } | { kind: 'canvas' }\n\n/**\n * Where a menu opened, on the timeline: under the pointer when it is over\n * the timeline, otherwise at the task's start.\n */\nfunction menuPoint(on: MenuOn, x: number, y: number) {\n const rect = chart.value!.getBoundingClientRect()\n const point = {\n x: x - rect.left + chart.value!.scrollLeft,\n y: y - rect.top - HEADER + chart.value!.scrollTop,\n }\n if (on.kind === 'task' && (x < rect.left || x > rect.right)) {\n const row = tree.value.byId.get(on.task.id)\n if (row?.span) point.x = xOf(row.span.start)\n if (row && row.index >= 0) point.y = (row.index + 0.5) * props.rowHeight\n }\n return { point, time: Math.round(origin.value + point.x / ppm.value) }\n}\n\n/** Opens the menu for a target at a point on screen; `false` when it has nothing to show. */\nfunction showMenu(on: MenuOn, x: number, y: number) {\n const target = { ...on, ...menuPoint(on, x, y) } as GanttMenuTarget\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 } else if (target.kind === 'link') selectedLink.value = target.link.id\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(row: GanttRow, 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', row.task, event)\n if (event.defaultPrevented) return\n if (showMenu({ kind: 'task', task: row.task }, event.clientX, event.clientY))\n event.preventDefault()\n}\n\nfunction onLinkContextmenu(link: GanttLink, event: MouseEvent) {\n emit('linkContextmenu', link, event)\n if (event.defaultPrevented) return\n if (showMenu({ kind: 'link', link }, event.clientX, event.clientY)) event.preventDefault()\n}\n\nfunction onBodyContextmenu(event: MouseEvent) {\n if (event.defaultPrevented || event.target !== event.currentTarget) {\n if (!(event.target as HTMLElement).matches('.dou-gantt__off, .dou-gantt__gridline')) return\n }\n if (Date.now() - touchedAt < 1000) return event.preventDefault()\n if (showMenu({ kind: 'canvas' }, event.clientX, event.clientY)) event.preventDefault()\n}\n\n/** The empty timeline below the last row opens the same menu as the rows' blank space. */\nfunction onCanvasContextmenu(event: MouseEvent) {\n if (event.target !== event.currentTarget || event.defaultPrevented) return\n if (Date.now() - touchedAt < 1000) return event.preventDefault()\n if (showMenu({ kind: 'canvas' }, 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 id = active.value\n const row = id ? tree.value.byId.get(id) : undefined\n if (!row) return false\n const el =\n root.value?.querySelector(`.dou-gantt__bar[data-task=\"${CSS.escape(row.task.id)}\"]`) ??\n root.value?.querySelector(`.dou-gantt__tr[data-row=\"${CSS.escape(row.task.id)}\"]`)\n const rect = el?.getBoundingClientRect()\n if (!rect) return false\n if (!menuAllowed(row.task, rect.left + 8, rect.bottom + 4)) return true\n return showMenu({ kind: 'task', task: row.task }, rect.left + 8, rect.bottom + 4)\n}\n\n// Keys ----------------------------------------------------------------------------------\n\n/** The task the keyboard is on. */\nconst active = ref<string | null>(null)\n\n/** Puts the keyboard on a task: scrolls it into view and focuses its bar, or its table row. */\nfunction focusTask(id: string) {\n if (!tree.value.byId.get(id)) return\n active.value = id\n scrollToTask(id)\n void nextTick(() => {\n const el =\n root.value?.querySelector<HTMLElement>(`.dou-gantt__bar[data-task=\"${CSS.escape(id)}\"]`) ??\n root.value?.querySelector<HTMLElement>(`.dou-gantt__tr[data-row=\"${CSS.escape(id)}\"]`)\n ;(el ?? root.value)?.focus({ preventScroll: true })\n })\n}\n\n/** Reads a task out as it is once the change has reached the rows. */\nfunction announceTask(id: string, tr?: GanttTransaction) {\n void nextTick(() => {\n const row = tree.value.byId.get(id)\n if (!row) return\n let text = tr ? withPushed(describe(row), tr) : describe(row)\n // say so when the change leaves links into or out of the task not kept\n const count = links.value.filter(\n (link) => (link.from === id || link.to === id) && brokenLinks.value.has(link.id),\n ).length\n if (count) text = format(messages.value.broken, { task: text, count })\n announce(text)\n })\n}\n\n/** Moves the task the keyboard is on (or the selection) by a step, or its end. */\nfunction keyMove(direction: 1 | -1, which: 'move' | 'end') {\n const row = active.value ? tree.value.byId.get(active.value) : undefined\n if (!row || !editable.value) return\n let changes = new Map<string, Partial<GanttTask>>()\n if (which === 'move') {\n const spans = movers(row)\n if (!spans.size) return\n const unit = stepFor(spans.values()) * direction\n // step over time off, so a task doesn't start on a weekend\n const first = spans.get(row.task.id) ?? spans.values().next().value!\n let shift = unit\n for (\n let i = 0;\n i < 366 && !isWorkTime(calendar.value, first.start + shift, first.dateOnly);\n i++\n )\n shift += unit\n for (const [id, span] of spans) {\n const start = span.start + shift\n changes.set(id, writeSpan(task(id), start, endKeepingWork(calendar.value, span, start)))\n }\n } else {\n const span = row.span\n if (!span || row.summary || row.milestone || row.task.locked) return\n const unit = stepFor([span])\n let end = span.end + unit * direction\n // a whole-day task doesn't end on a day off\n while (span.dateOnly && end > span.start + unit && !calendar.value.isWorkday(dayIndex(end - 1)))\n end += unit * direction\n end = Math.max(end, span.start + unit)\n if (end === span.end) return\n changes = new Map([[row.task.id, writeSpan(row.task, span.start, end)]])\n }\n const refusal = checkMove(changes)\n if (refusal) return announce(refusal)\n // repeated presses undo as one step\n const done = transact(\n (tr) => {\n for (const [id, patch] of changes) tr.updateTask(id, patch)\n },\n { group: `keys:${which}:${row.task.id}` },\n )\n scrollToTask(row.task.id)\n announceTask(row.task.id, done)\n}\n\n/** Linking by keys: from the task the keyboard was on, to the one picked with Up and Down. */\nconst keyLink = ref<{\n from: string\n to: string | null\n refusal: GanttLinkRefusal | null\n reason?: string\n} | null>(null)\n\nfunction pickLinkTarget(step: 1 | -1) {\n const link = keyLink.value!\n const list = rows.value\n let index = list.findIndex((row) => row.task.id === (link.to ?? link.from))\n for (let i = 0; i < list.length; i++) {\n index = (index + step + list.length) % list.length\n const row = list[index]!\n if (row.task.id !== link.from && row.span) break\n }\n const to = list[index]!.task.id\n if (to === link.from) return\n keyLink.value = { from: link.from, to, ...checkLink(link.from, to, 'fs') }\n scrollToTask(to)\n const title = task(to).title\n announce(keyLink.value.reason ? `${title},${keyLink.value.reason}` : title)\n}\n\nfunction onKeyLink(event: KeyboardEvent) {\n const link = keyLink.value!\n if (event.key === 'ArrowDown' || event.key === 'ArrowUp')\n pickLinkTarget(event.key === 'ArrowDown' ? 1 : -1)\n else if (event.key === 'Enter') {\n if (!link.to) return\n if (link.refusal) {\n announce(link.reason ?? messages.value.linkRefusals.cycle)\n } else {\n const made: GanttLink = { id: createId(), from: link.from, to: link.to }\n const done = transact((tr) => tr.addLink(made))\n emit('link', made)\n announceLinked(made, done)\n keyLink.value = null\n }\n } else if (event.key === 'Escape') {\n keyLink.value = null\n announce(messages.value.cancelled)\n } else if (event.key !== 'Tab') return\n event.preventDefault()\n}\n\nconst keymaps = computed(() => resolved.value.keymaps.map((keymap) => keymap(context)))\n\nfunction onKeydown(event: KeyboardEvent) {\n if ((event.target as HTMLElement).closest('input, textarea, select, [contenteditable]')) return\n if (keyLink.value) return onKeyLink(event)\n for (const keymap of keymaps.value)\n for (const [hotkey, handler] of Object.entries(keymap))\n if (matchesHotkey(event, hotkey) && handler(event) !== false) {\n event.preventDefault()\n return\n }\n if (onEditKey(event) || onNavigationKey(event)) event.preventDefault()\n}\n\n/** Undoes or redoes, and says what changed back. */\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 void nextTick(() => {\n let change = ''\n if (step.type === 'addLink' || step.type === 'removeLink') {\n const link =\n step.type === 'addLink' ? step.link : tr.before.links.find((l) => l.id === step.id)\n const from = link && tree.value.byId.get(link.from)?.task.title\n const to = link && tree.value.byId.get(link.to)?.task.title\n change =\n step.type === 'addLink' && from && to ? format(m.linked, { from, to }) : m.linkRemoved\n } else if (step.type === 'updateLink') change = m.linkChanged\n else {\n const id = step.type === 'addTask' ? step.task.id : step.id\n const row = tree.value.byId.get(id)\n change = row\n ? describe(row)\n : format(m.taskRemoved, { title: tr.before.tasks.find((t) => t.id === id)?.title ?? '' })\n }\n announce(format(m[direction === 'undo' ? 'undone' : 'redone'], { change }))\n })\n}\n\nfunction onEditKey(event: KeyboardEvent) {\n if (!editable.value) return false\n if (matchesHotkey(event, 'mod+z')) {\n announceTravel('undo')\n } else if (matchesHotkey(event, 'mod+shift+z') || matchesHotkey(event, 'mod+y')) {\n announceTravel('redo')\n } else if ((event.key === 'Delete' || event.key === 'Backspace') && selectedLink.value) {\n const id = selectedLink.value\n transact((tr) => tr.removeLink(id))\n announce(messages.value.linkRemoved)\n } else if (event.key === 'Delete' && selection.value.length) removeTasks(selection.value)\n else if (event.key === 'F2' && active.value) editCell(active.value)\n else if (matchesHotkey(event, 'mod+]') && active.value) indentTask(active.value)\n else if (matchesHotkey(event, 'mod+[') && active.value) outdentTask(active.value)\n else if (event.key === 'ContextMenu' || matchesHotkey(event, 'shift+f10')) openKeyMenu()\n else if (matchesHotkey(event, 'alt+arrowleft')) keyMove(-1, 'move')\n else if (matchesHotkey(event, 'alt+arrowright')) keyMove(1, 'move')\n else if (matchesHotkey(event, 'alt+shift+arrowleft')) keyMove(-1, 'end')\n else if (matchesHotkey(event, 'alt+shift+arrowright')) keyMove(1, 'end')\n else if (matchesHotkey(event, 'l') && active.value) {\n keyLink.value = { from: active.value, to: null, refusal: null }\n announce(format(messages.value.linkMode, { title: task(active.value).title }))\n } else return false\n return true\n}\n\nfunction onNavigationKey(event: KeyboardEvent) {\n if (event.altKey || event.metaKey || event.ctrlKey) return false\n const list = rows.value\n if (!list.length) return false\n const row = active.value ? tree.value.byId.get(active.value) : undefined\n const index = row && row.index >= 0 ? row.index : -1\n const go = (i: number) => focusTask(list[Math.max(0, Math.min(list.length - 1, i))]!.task.id)\n switch (event.key) {\n case 'ArrowDown':\n go(index < 0 ? 0 : index + 1)\n break\n case 'ArrowUp':\n go(index < 0 ? 0 : index - 1)\n break\n case 'Home':\n go(0)\n break\n case 'End':\n go(list.length - 1)\n break\n case 'ArrowRight':\n if (!row) return false\n if (row.summary && row.task.collapsed) toggle(row)\n else if (row.summary) go(index + 1)\n break\n case 'ArrowLeft':\n if (!row) return false\n if (row.summary && !row.task.collapsed) toggle(row)\n else if (row.task.parent != null && tree.value.byId.has(row.task.parent))\n focusTask(row.task.parent)\n break\n case ' ':\n case 'Enter':\n if (!row) return false\n selectedLink.value = null\n if (event.key === 'Enter') selection.value = [row.task.id]\n else\n selection.value = selected.value.has(row.task.id)\n ? selection.value.filter((id) => id !== row.task.id)\n : [...selection.value, row.task.id]\n break\n default:\n return false\n }\n return true\n}\n\n// Announcements for screen readers.\nconst live = ref('')\n/** Set while a cancelled change is being told of, so what follows doesn't say it was made. */\nlet refusing = false\nfunction announce(text: string) {\n if (refusing) return\n live.value = ''\n void nextTick(() => (live.value = text))\n}\n/** Why the last change didn't go ahead, shown for a moment over the editor. */\nconst notice = ref('')\nlet noticeTimer: ReturnType<typeof setTimeout> | undefined\nonBeforeUnmount(() => clearTimeout(noticeTimer))\n/** Says why a change didn't go ahead, once while the same one repeats, as in a drag. */\nlet lastRefusal = { text: '', at: 0 }\nfunction refuse(text: string) {\n const at = Date.now()\n if (refusing || (text === lastRefusal.text && at - lastRefusal.at < 1000)) return\n lastRefusal = { text, at }\n announce(text)\n notice.value = text\n clearTimeout(noticeTimer)\n noticeTimer = setTimeout(() => (notice.value = ''), 3200)\n refusing = true\n void nextTick(() => (refusing = false))\n}\nconst keysHint = `${useId()}-keys`\n\n// Markers ---------------------------------------------------------------------------\n\nconst markers = computed(() => {\n const list = [\n ...(props.markers ?? []),\n ...resolved.value.markers.flatMap((fromExtension) => fromExtension(context)),\n ]\n const inRange = list.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// Export and print --------------------------------------------------------------------\n\n/** The widest drawing made, in px; wider ones are zoomed out to fit. */\nconst EXPORT_MAX = 16000\n/** Room on a printed page, in px at 96 per inch: A4 less 10mm margins. */\nconst PAGE = { landscape: [1047, 718], portrait: [718, 1047] } as const\n\n/** The columns of the table as drawn, at their widths on screen. */\nfunction exportColumns(): ExportColumn[] {\n const heads = root.value?.querySelectorAll<HTMLElement>('.dou-gantt__th')\n return columns.value.map((column, i) => ({\n title: headerOf(column),\n width:\n heads?.[i]?.offsetWidth ||\n px(column.width) ||\n (column.key === 'title' ? TITLE_MIN + 80 : FLEX_COLUMN + 32),\n align: column.align ?? 'start',\n isTitle: column.key === 'title',\n text: (row: GanttRow) => cellText(row, column),\n }))\n}\n\nlet measurer: CanvasRenderingContext2D | null | undefined\nfunction exportTextWidth(theme: ExportTheme) {\n if (measurer === undefined)\n try {\n measurer = document.createElement('canvas').getContext('2d')\n } catch {\n measurer = null\n }\n return (text: string, bold = false) => {\n if (!measurer) return text.length * theme.fontSize\n measurer.font = `${bold ? theme.fontWeight : 400} ${theme.fontSize}px ${theme.fontFamily}`\n return measurer.measureText(text).width\n }\n}\n\nfunction drawChart(options: {\n range: 'all' | 'view'\n table: boolean\n dayWidth?: number\n background?: boolean\n}) {\n const theme = readTheme(root.value!)\n let width = options.dayWidth ?? dayWidth.value\n let shownRows: readonly GanttRow[] = rows.value\n let start: number\n let end: number\n if (options.range === 'view') {\n start = origin.value + scrollLeft.value / ppm.value\n end = start + (viewWidth.value || 800) / ppm.value\n const top = scrollTop.value\n const bottom = top + bodyHeight.value\n shownRows = rows.value.filter(\n (row) => (row.index + 1) * props.rowHeight > top && row.index * props.rowHeight < bottom,\n )\n } else {\n const known = content.value ?? { start: now.value, end: now.value + MINUTES_PER_DAY }\n if ((known.end - known.start) * (width / MINUTES_PER_DAY) > EXPORT_MAX)\n width = (EXPORT_MAX / (known.end - known.start)) * MINUTES_PER_DAY\n ;({ start, end } = paddedRange(known.start, known.end, width, weekStart.value))\n // titles beside bars that end last need room too\n const pxPerMinute = width / MINUTES_PER_DAY\n const measure = exportTextWidth(theme)\n let right = known.end\n for (const row of rows.value) {\n if (!row.span || props.barLabel === 'none') continue\n const barWidth = (row.span.end - row.span.start) * pxPerMinute\n const outside =\n row.milestone ||\n row.summary ||\n props.barLabel === 'outside' ||\n (props.barLabel === 'auto' && measure(row.task.title) + 20 > barWidth)\n if (outside)\n right = Math.max(\n right,\n row.span.end + (measure(row.task.title) + 24 + LINKED_END_ROOM) / pxPerMinute,\n )\n }\n if (right > end) end = paddedRange(known.start, right, width, weekStart.value).end\n }\n return drawGantt(\n {\n rows: shownRows,\n links: linkList.value,\n columns: options.table ? exportColumns() : [],\n rowHeight: props.rowHeight,\n dayWidth: width,\n start,\n end,\n calendar: calendar.value,\n messages: messages.value,\n weekStart: weekStart.value,\n now: props.today ? now.value : null,\n markers: markers.value.map((item) => ({\n at: parseTime(item.marker.date)!.at,\n label: item.marker.label,\n tone: item.marker.tone,\n })),\n critical: critical.value,\n broken: brokenLinks.value,\n baseline: planned.value,\n barLabel: props.barLabel,\n background: options.background !== false,\n linkedEnds: linkedEnds.value,\n textWidth: exportTextWidth(theme),\n },\n theme,\n )\n}\n\nconst exportOptions = (options: GanttExportOptions = {}) => ({\n range: options.range ?? 'all',\n table: options.table ?? showTable.value,\n dayWidth: options.scale ? levelByName(options.scale).base : undefined,\n background: options.background,\n})\n\nfunction exportSVG(options?: GanttExportOptions) {\n return svgDocument(drawChart(exportOptions(options)))\n}\n\nasync function exportPNG(options: GanttImageOptions = {}) {\n const drawing = drawChart(exportOptions(options))\n return svgToPNG(svgDocument(drawing), {\n width: drawing.width,\n height: EXPORT_HEADER + drawing.bodyHeight,\n pixelRatio: options.pixelRatio,\n scope: '@dou-ui/gantt',\n hint: \"export range 'view', a coarser scale, or use exportSVG() / toCSV()\",\n })\n}\n\n/** What a spreadsheet program would start a formula with; kept in step with `@dou-ui/sheet`. */\nconst FORMULA_START = /^[=+\\-@\\t\\r]/\n\n/**\n * A field of CSV: quoted when it has to be, and unless `escape` is off,\n * text that a spreadsheet program would run as a formula (`=…`, `-复盘`)\n * behind a `'`. Numbers stay as they are.\n */\nfunction csvCell(text: string, escape = true) {\n const safe =\n escape && FORMULA_START.test(text) && !Number.isFinite(Number(text.trim())) ? `'${text}` : text\n return /[\",\\n\\r]/.test(safe) ? `\"${safe.replace(/\"/g, '\"\"')}\"` : safe\n}\n\n/** A cell as a spreadsheet reads it: dates as `YYYY-MM-DD`, numbers without units. */\nfunction csvValue(row: GanttRow, column: GanttColumn, mixed: boolean) {\n if (column.value) {\n const value = column.value(row.task)\n if (value == null) return ''\n return typeof value === 'object' ? JSON.stringify(value) : String(value)\n }\n const span = row.span\n if (column.key === 'start')\n return span\n ? formatTime(\n span.start - (row.milestone && span.dateOnly ? MINUTES_PER_DAY / 2 : 0),\n span.dateOnly,\n )\n : ''\n if (row.milestone && ['end', 'duration', 'progress'].includes(column.key)) return ''\n if (column.key === 'duration' && mixed && span)\n return durationLabel(span, calendar.value, messages.value)\n return editorValue(row, column)\n}\n\nfunction toCSV(options: GanttCSVExportOptions = {}) {\n const list = columns.value\n const m = messages.value\n // durations are working days or working hours; the header says which, or each value does when both are in\n const timed = new Set<boolean>()\n for (const row of tree.value.byId.values())\n if (row.span && !row.milestone) timed.add(!row.span.dateOnly)\n const mixed = timed.size > 1\n const header = (column: GanttColumn) => {\n const title = headerOf(column)\n if (options.text || column.value || column.key !== 'duration' || timed.size !== 1) return title\n return format(timed.has(true) ? m.hoursColumn : m.daysColumn, { title })\n }\n const cell = (row: GanttRow, column: GanttColumn) =>\n options.text ? cellText(row, column) : csvValue(row, column, mixed)\n const ids = options.ids ? [m.idColumn, m.parentColumn] : []\n const escape = options.escapeFormulas !== false\n const field = (text: string) => csvCell(text, escape)\n const lines = [[...ids, ...list.map(header)].map(field).join(',')]\n // every task in order, those under a collapsed parent too\n for (const row of tree.value.byId.values()) {\n const own = options.ids ? [row.task.id, row.task.parent ?? ''] : []\n lines.push([...own, ...list.map((column) => cell(row, column))].map(field).join(','))\n }\n // the byte order mark tells spreadsheets the file is UTF-8\n return (options.bom === false ? '' : '\\uFEFF') + lines.join('\\r\\n')\n}\n\nfunction readCSV(text: string, outer?: (id: string) => boolean) {\n return readGanttCSV(text, {\n columns: columns.value,\n messages: messages.value,\n calendar: calendar.value,\n outer,\n })\n}\n\nfunction importCSV(text: string, options: GanttCSVImportOptions = {}) {\n if (!editable.value) return null\n const mode = options.mode ?? 'replace'\n // appended tasks may go under the chart's own\n const result = readCSV(text, mode === 'append' ? (id) => tree.value.byId.has(id) : undefined)\n transact((tr) => applyGanttCSV(tr, result, mode, calendar.value))\n return result\n}\n\nconst escapeHTML = (text: string) =>\n text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')\n\nfunction print(options: GanttPrintOptions = {}) {\n const orientation = options.orientation ?? 'landscape'\n const [pageWidth, pageHeight] = PAGE[orientation]\n const table = options.table ?? true\n const columnsWidth = table ? exportColumns().reduce((sum, column) => sum + column.width, 0) : 0\n const known = content.value ?? { start: now.value, end: now.value + MINUTES_PER_DAY }\n // the day width that fits the time into what the table leaves of the page\n let fitted = ((pageWidth - columnsWidth - 48) / (known.end - known.start)) * MINUTES_PER_DAY\n fitted = Math.min(DAY_WIDTH_RANGE[1], Math.max(DAY_WIDTH_RANGE[0], fitted))\n const drawing = drawChart({ range: 'all', table, dayWidth: fitted })\n const perPage = Math.max(\n 1,\n Math.floor(\n (pageHeight * Math.max(1, drawing.width / pageWidth) - EXPORT_HEADER) / props.rowHeight,\n ),\n )\n const pages: string[] = []\n for (let top = 0; top < Math.max(1, drawing.bodyHeight); top += perPage * props.rowHeight)\n pages.push(\n `<div class=\"page\">${svgDocument(drawing, { top, height: Math.min(perPage * props.rowHeight, drawing.bodyHeight - top) })}</div>`,\n )\n const iframe = document.createElement('iframe')\n iframe.setAttribute('aria-hidden', 'true')\n iframe.style.cssText = 'position:fixed;width:0;height:0;border:0;visibility:hidden'\n document.body.append(iframe)\n const doc = iframe.contentDocument!\n doc.open()\n doc.write(\n `<!doctype html><html><head><meta charset=\"utf-8\"><title>${escapeHTML(document.title)}</title><style>` +\n `@page{size:A4 ${orientation};margin:10mm}html,body{margin:0}` +\n `.page{break-after:page;print-color-adjust:exact;-webkit-print-color-adjust:exact}` +\n `.page:last-child{break-after:auto}svg{display:block;width:100%;height:auto}` +\n `</style></head><body>${pages.join('')}</body></html>`,\n )\n doc.close()\n const win = iframe.contentWindow!\n const done = () => iframe.remove()\n win.addEventListener('afterprint', done)\n // browsers that print without a dialog, or never send afterprint\n setTimeout(done, 60_000)\n win.focus()\n win.print()\n}\n\nfunction onLinkClick(link: GanttLink, event: MouseEvent) {\n selectedLink.value = link.id\n root.value?.focus({ preventScroll: true })\n emit('linkClick', link, event)\n}\n\n// Exposed ---------------------------------------------------------------------------\n\nfunction scrollToTask(id: string) {\n const row = tree.value.byId.get(id)\n if (!row) return\n const el = chart.value\n if (el && row.index >= 0) {\n const top = row.index * props.rowHeight\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 }\n if (row.span) {\n const left = xOf(row.span.start)\n const right = xOf(row.span.end)\n if (left < scrollLeft.value || right > scrollLeft.value + viewWidth.value)\n scrollToTime(row.span.start)\n }\n}\n\ndefineExpose<GanttExpose>({\n transact,\n undo,\n redo,\n schedule,\n editCell,\n insertTask,\n indentTask,\n outdentTask,\n get canUndo() {\n return canUndo.value\n },\n get canRedo() {\n return canRedo.value\n },\n fit,\n scrollToDate,\n scrollToTask,\n scrollToToday,\n focusTask,\n zoomIn,\n zoomOut,\n exportSVG,\n exportPNG,\n toCSV,\n readCSV: (text: string) => readCSV(text),\n importCSV,\n print,\n})\n\nconst context: GanttContext = {\n doc: source,\n tasks,\n links,\n selection,\n active,\n readonly: computed(() => !editable.value),\n messages,\n scale: computed(() => level.value.name),\n dayWidth,\n rowHeight: computed(() => props.rowHeight),\n rows,\n preview,\n critical,\n brokenLinks,\n schedule,\n editCell,\n insertTask,\n indentTask,\n outdentTask,\n transact,\n undo,\n redo,\n canUndo,\n canRedo,\n fit,\n zoomIn,\n zoomOut,\n scrollToDate,\n scrollToTask,\n scrollToToday,\n focusTask,\n xOf: (time) => {\n const at = typeof time === 'number' ? time : parseTime(time)?.at\n return at == null ? null : xOf(at)\n },\n rowTop: (id) => {\n const row = tree.value.byId.get(id)\n return row && row.index >= 0 ? row.index * props.rowHeight : null\n },\n announce,\n}\nprovide(ganttKey, context)\n\n// Extensions that hook into the chart's life: set up once mounted, again when the list changes\nlet cleanups: (() => void)[] = []\nlet mounted = false\nfunction teardownExtensions() {\n for (const cleanup of cleanups.splice(0)) cleanup()\n customHistory = null\n}\nlet setUp = false\nlet setupTimes: number[] = []\nlet setupWarned = false\n/** An extension made in the template is new on every render, and set up again each time. */\nfunction warnRepeatedSetup() {\n if (process.env.NODE_ENV === 'production' || setupWarned) return\n const at = Date.now()\n setupTimes = [...setupTimes.filter((time) => at - time < 1000), at]\n if (setupTimes.length < 4) return\n setupWarned = true\n console.warn(\n '[@dou-ui/gantt] the extensions keep being set up again, which also clears undo each time. Create them once in <script setup> instead of in the template.',\n )\n}\nfunction setupExtensions() {\n if (setUp) warnRepeatedSetup()\n setUp = true\n teardownExtensions()\n customHistory = resolved.value.history?.(context) ?? null\n for (const setup of resolved.value.setups) {\n const cleanup = setup(context)\n if (typeof cleanup === 'function') cleanups.push(cleanup)\n }\n syncHistory()\n}\nonMounted(() => {\n mounted = true\n setupExtensions()\n})\n// A list written inline is new on every render; set up again only when the hooks change.\nwatch(\n () => [resolved.value.history, ...resolved.value.setups],\n (hooks, before) => {\n if (mounted && (hooks.length !== before.length || hooks.some((hook, i) => hook !== before[i])))\n setupExtensions()\n },\n)\nonBeforeUnmount(teardownExtensions)\n\nconst rowStyle = (row: GanttRow) => ({ transform: `translateY(${row.index * props.rowHeight}px)` })\n</script>\n\n<template>\n <div\n ref=\"root\"\n class=\"dou-gantt\"\n :class=\"{\n 'is-narrow': narrow,\n 'is-editable': editable,\n 'is-dragging': drag?.started || rowDrag?.started,\n 'is-dragging-row': rowDrag?.started,\n [`is-dragging-${drag?.mode}`]: drag?.started,\n 'is-linking': !!keyLink,\n 'has-baseline': !!planned,\n }\"\n :style=\"{ '--dou-gantt-row-height': `${rowHeight}px` }\"\n role=\"group\"\n tabindex=\"0\"\n :aria-label=\"ariaLabel ?? messages.chart\"\n :aria-describedby=\"keysHint\"\n @keydown=\"onKeydown\"\n @pointerdown.capture=\"onRootPointerdown\"\n >\n <span :id=\"keysHint\" class=\"dou-gantt__sr-only\">{{ messages.keys }}</span>\n <div class=\"dou-gantt__sr-only\" aria-live=\"polite\">{{ live }}</div>\n <div v-if=\"notice\" class=\"dou-gantt__notice\" aria-hidden=\"true\">{{ notice }}</div>\n <div\n v-if=\"showTable\"\n ref=\"tablePane\"\n class=\"dou-gantt__table-pane\"\n :style=\"{ width: `${tableSize}px` }\"\n >\n <div\n class=\"dou-gantt__table\"\n role=\"treegrid\"\n :aria-rowcount=\"rows.length\"\n :style=\"{ minWidth: `${tableMinWidth}px` }\"\n >\n <div class=\"dou-gantt__table-head\" role=\"row\">\n <div\n v-for=\"column in columns\"\n :key=\"column.key\"\n class=\"dou-gantt__th\"\n :class=\"[column.align && `is-${column.align}`, { 'is-title': column.key === 'title' }]\"\n :style=\"columnStyle(column)\"\n role=\"columnheader\"\n >\n {{ headerOf(column) }}\n </div>\n </div>\n <div ref=\"tableBody\" class=\"dou-gantt__table-body\" @scroll=\"onTableScroll\">\n <div\n class=\"dou-gantt__table-rows\"\n :style=\"{\n height: `${bodyRows * rowHeight + Math.max(0, scrollbar - tableScrollbar)}px`,\n }\"\n >\n <div\n v-for=\"row in visibleRows\"\n :key=\"row.task.id\"\n class=\"dou-gantt__tr\"\n :class=\"{\n 'is-selected': selected.has(row.task.id),\n 'is-hovered': hovered === row.task.id,\n 'is-summary': row.summary,\n 'is-active': active === row.task.id,\n 'is-moving': rowDragged?.has(row.task.id),\n 'is-drop-inside': rowDrop?.row === row && rowDrop.where === 'inside',\n }\"\n :style=\"rowStyle(row)\"\n :data-row=\"row.task.id\"\n tabindex=\"-1\"\n role=\"row\"\n :aria-level=\"row.depth + 1\"\n :aria-rowindex=\"row.index + 1\"\n :aria-expanded=\"row.summary ? !row.task.collapsed : undefined\"\n :aria-selected=\"selected.has(row.task.id)\"\n @pointerenter=\"hovered = row.task.id\"\n @pointerleave=\"hovered = null\"\n @pointerdown=\"onRowDown($event, row)\"\n @click=\"onTaskClick(row, $event)\"\n @dblclick=\"emit('taskDblclick', row.task, $event)\"\n @contextmenu=\"onTaskContextmenu(row, $event)\"\n >\n <div\n v-for=\"column in columns\"\n :key=\"column.key\"\n class=\"dou-gantt__td\"\n :class=\"[\n column.align && `is-${column.align}`,\n { 'is-title': column.key === 'title' },\n ]\"\n :style=\"columnStyle(column)\"\n role=\"gridcell\"\n @dblclick=\"onCellDblclick(row, column, $event)\"\n >\n <template v-if=\"column.key === 'title'\">\n <span class=\"dou-gantt__indent\" :style=\"{ width: `${row.depth * 16}px` }\" />\n <button\n v-if=\"row.summary\"\n type=\"button\"\n class=\"dou-gantt__toggle\"\n tabindex=\"-1\"\n :aria-label=\"row.task.collapsed ? messages.expand : messages.collapse\"\n @click.stop=\"toggle(row)\"\n >\n <component :is=\"row.task.collapsed ? ChevronRightIcon : ChevronDownIcon\" />\n </button>\n <span v-else class=\"dou-gantt__toggle-space\" />\n </template>\n <CellEditor\n v-if=\"editing?.id === row.task.id && editing.key === column.key\"\n :kind=\"editorKind(row, column)\"\n :value=\"editorValue(row, column)\"\n :label=\"format(messages.editCell, { column: headerOf(column) })\"\n :check=\"(text) => editPatch(row, column, text) !== false\"\n @done=\"onEditDone(row, column, $event)\"\n @invalid=\"onEditInvalid(column)\"\n @tab=\"onEditTab(row, $event)\"\n />\n <span v-else class=\"dou-gantt__cell-text\">\n <slot\n name=\"cell\"\n :task=\"row.task\"\n :row=\"row\"\n :column=\"column\"\n :text=\"cellText(row, column)\"\n >\n {{ cellText(row, column) }}\n </slot>\n </span>\n </div>\n </div>\n <div\n v-if=\"showAddRow\"\n class=\"dou-gantt__add-row\"\n :style=\"{ transform: `translateY(${rows.length * rowHeight}px)` }\"\n role=\"row\"\n >\n <div class=\"dou-gantt__td is-title\" role=\"gridcell\">\n <span class=\"dou-gantt__toggle-space\" />\n <CellEditor\n v-if=\"editing && editing.id === null\"\n kind=\"text\"\n value=\"\"\n repeat\n :label=\"messages.addRow\"\n :placeholder=\"messages.addRow\"\n @done=\"onAddDone\"\n />\n <button v-else type=\"button\" class=\"dou-gantt__add\" @click=\"startAdding\">\n <PlusIcon />\n {{ messages.addRow }}\n </button>\n </div>\n </div>\n <div v-if=\"dropLine\" class=\"dou-gantt__drop-line\" :style=\"dropLine\" />\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=\"onChartScroll\"\n @wheel=\"onWheel\"\n >\n <div\n class=\"dou-gantt__canvas\"\n :style=\"{ width: `${totalWidth}px` }\"\n @contextmenu=\"onCanvasContextmenu\"\n >\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: `${bodyRows * rowHeight}px` }\"\n @pointerdown=\"onBodyDown\"\n @contextmenu=\"onBodyContextmenu\"\n >\n <div v-if=\"!showTable\" class=\"dou-gantt__row-titles\">\n <span\n v-for=\"item in rowTitles\"\n :key=\"item.row.task.id\"\n class=\"dou-gantt__row-title\"\n :class=\"{ 'is-summary': item.row.summary }\"\n :style=\"rowStyle(item.row)\"\n >\n {{ item.row.task.title }}\n </span>\n </div>\n <TimeGrid :cells=\"bottomCells\" :shaded=\"shaded\" />\n <div\n v-for=\"row in highlightRows\"\n :key=\"row.task.id\"\n class=\"dou-gantt__row-highlight\"\n :class=\"{ 'is-selected': selected.has(row.task.id) }\"\n :style=\"rowStyle(row)\"\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 <component :is=\"layer\" v-for=\"(layer, i) in resolved.below\" :key=\"`below-${i}`\" />\n <svg class=\"dou-gantt__links\" :width=\"totalWidth\" :height=\"rows.length * rowHeight\">\n <g\n v-for=\"item in linkPaths\"\n :key=\"item.link.id\"\n class=\"dou-gantt__link\"\n :class=\"{\n 'is-related': selected.has(item.link.from) || selected.has(item.link.to),\n 'is-selected': selectedLink === item.link.id,\n 'is-broken': brokenLinks.has(item.link.id),\n 'is-critical': critical?.links.has(item.link.id),\n }\"\n @click=\"onLinkClick(item.link, $event)\"\n @contextmenu=\"onLinkContextmenu(item.link, $event)\"\n >\n <path class=\"dou-gantt__link-hit\" :d=\"item.d\" />\n <path class=\"dou-gantt__link-line\" :d=\"item.d\" />\n <path class=\"dou-gantt__link-arrow\" :d=\"item.arrow\" />\n </g>\n <path\n v-if=\"draftLink\"\n class=\"dou-gantt__link-draft\"\n :class=\"{ 'is-refused': draftLink.refused }\"\n :d=\"draftLink.d\"\n />\n </svg>\n <div\n v-for=\"plan in baselineBars\"\n :key=\"`plan:${plan.id}`\"\n class=\"dou-gantt__baseline\"\n :class=\"{ 'is-milestone': plan.milestone }\"\n :style=\"{\n transform: `translate(${plan.left}px, ${plan.top}px)${plan.milestone ? ' rotate(45deg)' : ''}`,\n width: plan.milestone ? undefined : `${plan.width}px`,\n }\"\n />\n <div\n v-for=\"bar in bars\"\n :key=\"bar.row.task.id\"\n class=\"dou-gantt__bar-row\"\n :class=\"{\n 'is-selected': selected.has(bar.row.task.id),\n 'is-active': drag?.started && drag.id === bar.row.task.id,\n }\"\n :style=\"{ transform: `translateY(${bar.top}px)` }\"\n @pointerenter=\"hovered = bar.row.task.id\"\n @pointerleave=\"hovered = null\"\n >\n <div\n class=\"dou-gantt__bar\"\n :class=\"[\n bar.row.task.tone && `is-${bar.row.task.tone}`,\n {\n 'is-summary': bar.row.summary,\n 'is-milestone': bar.row.milestone,\n 'is-selected': selected.has(bar.row.task.id),\n 'is-locked': bar.row.task.locked,\n 'is-link-target': linkHighlight?.id === bar.row.task.id && !linkHighlight.refused,\n 'is-link-refused': linkHighlight?.id === bar.row.task.id && linkHighlight.refused,\n 'is-move-refused':\n !!dragRefusal && drag?.started && drag.spans.has(bar.row.task.id),\n 'is-active': active === bar.row.task.id,\n 'is-critical': critical?.tasks.has(bar.row.task.id),\n },\n ]\"\n :data-task=\"bar.row.task.id\"\n :style=\"{\n left: `${bar.left}px`,\n width: bar.row.milestone ? undefined : `${bar.width}px`,\n }\"\n role=\"img\"\n tabindex=\"-1\"\n :aria-label=\"bar.aria\"\n @pointerdown=\"onBarDown($event, bar.row)\"\n @click=\"onTaskClick(bar.row, $event)\"\n @dblclick=\"emit('taskDblclick', bar.row.task, $event)\"\n @contextmenu=\"onTaskContextmenu(bar.row, $event)\"\n >\n <span\n v-if=\"!bar.row.milestone && !bar.row.summary && bar.row.progress > 0\"\n class=\"dou-gantt__progress\"\n :style=\"{ width: `${bar.row.progress * 100}%` }\"\n />\n <span v-if=\"bar.label === 'inside'\" class=\"dou-gantt__bar-label\">\n <slot name=\"bar\" :task=\"bar.row.task\" :row=\"bar.row\">{{ bar.row.task.title }}</slot>\n </span>\n </div>\n <template v-if=\"editable && !bar.row.task.locked\">\n <template v-if=\"!bar.row.milestone && !bar.row.summary\">\n <span\n class=\"dou-gantt__handle is-start\"\n data-handle=\"start\"\n :style=\"{ left: `${bar.left}px` }\"\n @pointerdown=\"onBarDown($event, bar.row)\"\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.row)\"\n />\n <span\n class=\"dou-gantt__progress-handle\"\n data-handle=\"progress\"\n :style=\"{ left: `${bar.left + bar.width * bar.row.progress}px` }\"\n @pointerdown=\"onBarDown($event, bar.row)\"\n />\n </template>\n </template>\n <template v-if=\"editable\">\n <span\n class=\"dou-gantt__dot is-start\"\n data-handle=\"link-start\"\n :style=\"{ left: `${bar.left - (bar.row.milestone ? 7 : 0)}px` }\"\n @pointerdown=\"onBarDown($event, bar.row)\"\n />\n <span\n class=\"dou-gantt__dot is-end\"\n data-handle=\"link-end\"\n :style=\"{ left: `${bar.left + (bar.row.milestone ? 7 : bar.width)}px` }\"\n @pointerdown=\"onBarDown($event, bar.row)\"\n />\n </template>\n <span\n v-if=\"bar.label === 'outside' && !titled.has(bar.row.task.id)\"\n class=\"dou-gantt__bar-side\"\n :class=\"{ 'is-summary': bar.row.summary }\"\n :style=\"{ left: `${bar.left + sideOffset(bar)}px` }\"\n >\n <slot name=\"bar\" :task=\"bar.row.task\" :row=\"bar.row\">{{ bar.row.task.title }}</slot>\n </span>\n </div>\n <div\n v-for=\"ghost in ghosts\"\n :key=\"ghost.id\"\n class=\"dou-gantt__ghost\"\n :class=\"{ 'is-milestone': ghost.milestone }\"\n :style=\"{\n transform: `translate(${ghost.left}px, ${ghost.top}px)${ghost.milestone ? ' rotate(45deg)' : ''}`,\n width: ghost.milestone ? undefined : `${ghost.width}px`,\n }\"\n />\n <component :is=\"layer\" v-for=\"(layer, i) in resolved.above\" :key=\"`above-${i}`\" />\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 <!-- after the chart, so Tab reaches it last; CSS puts it between the panes -->\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 <component :is=\"overlay\" v-for=\"(overlay, i) in resolved.overlays\" :key=\"`overlay-${i}`\" />\n <GanttMenu ref=\"menu\" v-model:open=\"menuOpen\" :items=\"menuItems\" />\n <slot />\n </div>\n</template>\n"],"mappings":""}
|