@starlove/orbit 1.4.1 → 2.0.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/Dockerfile +10 -8
- package/README.md +146 -164
- package/dist/full/csp.txt +1 -0
- package/dist/full/fonts/JetBrains_Mono/JetBrainsMono-subset.woff2 +0 -0
- package/dist/full/fonts/JetBrains_Mono/README.txt +5 -0
- package/dist/full/fonts/Mona_Sans/MonaSans-subset.woff2 +0 -0
- package/{public/fonts/Open_Sans → dist/full/fonts/Mona_Sans}/OFL.txt +93 -93
- package/dist/full/fonts/Mona_Sans/README.txt +10 -0
- package/{public/fonts/JetBrains_Mono → dist/full/fonts/Newsreader}/OFL.txt +93 -93
- package/dist/full/fonts/Newsreader/README.txt +8 -0
- package/dist/full/fonts/Newsreader/newsreader.woff2 +0 -0
- package/dist/full/index.html +36 -653
- package/dist/full/js/app.BmuponPJ.js +32 -0
- package/dist/full/js/app.BmuponPJ.js.map +1 -0
- package/dist/full/js/chunk.1fJGQGbW.js +2 -0
- package/dist/full/js/chunk.1fJGQGbW.js.map +1 -0
- package/dist/full/js/chunk.5D7JO2zp.js +2 -0
- package/dist/full/js/chunk.5D7JO2zp.js.map +1 -0
- package/dist/full/js/chunk.BrNn_D79.js +2 -0
- package/dist/full/js/chunk.BrNn_D79.js.map +1 -0
- package/dist/full/js/chunk.CokJXIqM.js +2 -0
- package/dist/full/js/chunk.CokJXIqM.js.map +1 -0
- package/dist/full/js/chunk.D_Haqy6R.js +2 -0
- package/dist/full/js/chunk.D_Haqy6R.js.map +1 -0
- package/dist/full/js/chunk.RO0jsH6I.js +15 -0
- package/dist/full/js/chunk.RO0jsH6I.js.map +1 -0
- package/dist/full/js/chunk.dqWOEtBM.js +2 -0
- package/dist/full/js/chunk.dqWOEtBM.js.map +1 -0
- package/dist/full/js/warm.f260bbbc.js +15 -0
- package/dist/full/orbits-favicon.png +0 -0
- package/dist/full/orbits-logo-bg-wide.png +0 -0
- package/dist/full/orbits-logo-bg.png +0 -0
- package/dist/full/orbits-logo.png +0 -0
- package/dist/full/styles.2b154ffe.css +1 -0
- package/dist/full/vendor/starlove-ui/theme-init.js +66 -0
- package/dist/full/version.json +2 -2
- package/docs/AGENT_GUIDE.md +114 -0
- package/docs/UPDATING.md +249 -64
- package/lib/adapters/indexeddb/blobs.d.ts +3 -0
- package/lib/adapters/indexeddb/blobs.js +54 -0
- package/lib/adapters/indexeddb/database.d.ts +60 -0
- package/lib/adapters/indexeddb/database.js +126 -0
- package/lib/adapters/indexeddb/index.d.ts +5 -0
- package/lib/adapters/indexeddb/index.js +3 -0
- package/lib/adapters/indexeddb/locks.d.ts +14 -0
- package/lib/adapters/indexeddb/locks.js +40 -0
- package/lib/adapters/indexeddb/store.d.ts +40 -0
- package/lib/adapters/indexeddb/store.js +424 -0
- package/lib/adapters/indexeddb/tables.d.ts +43 -0
- package/lib/adapters/indexeddb/tables.js +127 -0
- package/lib/adapters/memory/blobs.d.ts +3 -0
- package/lib/adapters/memory/blobs.js +44 -0
- package/lib/adapters/memory/board.d.ts +41 -0
- package/lib/adapters/memory/board.js +521 -0
- package/lib/adapters/memory/index.d.ts +2 -0
- package/lib/adapters/memory/index.js +2 -0
- package/lib/adapters/memory/search.d.ts +6 -0
- package/lib/adapters/memory/search.js +30 -0
- package/lib/adapters/memory/store.d.ts +34 -0
- package/lib/adapters/memory/store.js +266 -0
- package/lib/adapters/sqlite/backups.d.ts +53 -0
- package/lib/adapters/sqlite/backups.js +232 -0
- package/lib/adapters/sqlite/blobs.d.ts +14 -0
- package/lib/adapters/sqlite/blobs.js +109 -0
- package/lib/adapters/sqlite/board.d.ts +77 -0
- package/lib/adapters/sqlite/board.js +328 -0
- package/lib/adapters/sqlite/database.d.ts +67 -0
- package/lib/adapters/sqlite/database.js +285 -0
- package/lib/adapters/sqlite/index.d.ts +3 -0
- package/lib/adapters/sqlite/index.js +2 -0
- package/lib/adapters/sqlite/lock.d.ts +11 -0
- package/lib/adapters/sqlite/lock.js +34 -0
- package/lib/adapters/sqlite/records.d.ts +54 -0
- package/lib/adapters/sqlite/records.js +546 -0
- package/lib/adapters/sqlite/schema.d.ts +54 -0
- package/lib/adapters/sqlite/schema.js +276 -0
- package/lib/adapters/sqlite/store.d.ts +39 -0
- package/lib/adapters/sqlite/store.js +385 -0
- package/lib/browser/in-page.d.ts +31 -0
- package/lib/browser/in-page.js +46 -0
- package/lib/browser/old-preview.d.ts +13 -0
- package/lib/browser/old-preview.js +80 -0
- package/lib/browser/preview-board.d.ts +8 -0
- package/lib/browser/preview-board.js +69 -0
- package/lib/cli/main.d.ts +7 -0
- package/lib/cli/main.js +343 -0
- package/lib/cli/orbit.d.ts +8 -0
- package/lib/cli/orbit.js +25 -0
- package/lib/client/http.d.ts +38 -0
- package/lib/client/http.js +334 -0
- package/lib/client/local.d.ts +26 -0
- package/lib/client/local.js +190 -0
- package/lib/client/object-url.d.ts +8 -0
- package/lib/client/object-url.js +21 -0
- package/lib/client/types.d.ts +268 -0
- package/lib/client/types.js +8 -0
- package/lib/commands/agent-tools.d.ts +31 -0
- package/lib/commands/agent-tools.js +100 -0
- package/lib/commands/index.d.ts +56 -0
- package/lib/commands/index.js +298 -0
- package/lib/commands/parse.d.ts +73 -0
- package/lib/commands/parse.js +231 -0
- package/lib/commands/present.d.ts +134 -0
- package/lib/commands/present.js +198 -0
- package/lib/commands/session.d.ts +85 -0
- package/lib/commands/session.js +138 -0
- package/lib/commands/verbs.d.ts +35 -0
- package/lib/commands/verbs.js +842 -0
- package/lib/core/actors.d.ts +18 -0
- package/lib/core/actors.js +45 -0
- package/lib/core/agent.d.ts +128 -0
- package/lib/core/agent.js +323 -0
- package/lib/core/boards.d.ts +113 -0
- package/lib/core/boards.js +288 -0
- package/lib/core/bootstrap.d.ts +47 -0
- package/lib/core/bootstrap.js +100 -0
- package/lib/core/bytes.d.ts +10 -0
- package/lib/core/bytes.js +52 -0
- package/lib/core/card-paths.d.ts +25 -0
- package/lib/core/card-paths.js +44 -0
- package/lib/core/context.d.ts +60 -0
- package/lib/core/context.js +42 -0
- package/lib/core/cross-board.d.ts +81 -0
- package/lib/core/cross-board.js +228 -0
- package/lib/core/describe.d.ts +21 -0
- package/lib/core/describe.js +81 -0
- package/lib/core/errors.d.ts +159 -0
- package/lib/core/errors.js +160 -0
- package/lib/core/files.d.ts +79 -0
- package/lib/core/files.js +262 -0
- package/lib/core/history.d.ts +20 -0
- package/lib/core/history.js +53 -0
- package/lib/core/image-types.d.ts +16 -0
- package/lib/core/image-types.js +36 -0
- package/lib/core/model.d.ts +263 -0
- package/lib/core/model.js +34 -0
- package/lib/core/nebula-link.d.ts +14 -0
- package/lib/core/nebula-link.js +39 -0
- package/lib/core/relations.d.ts +58 -0
- package/lib/core/relations.js +112 -0
- package/lib/core/search.d.ts +38 -0
- package/lib/core/search.js +92 -0
- package/lib/core/sessions.d.ts +44 -0
- package/lib/core/sessions.js +158 -0
- package/lib/core/snapshot-schema.d.ts +295 -0
- package/lib/core/snapshot-schema.js +82 -0
- package/lib/core/snapshots.d.ts +75 -0
- package/lib/core/snapshots.js +409 -0
- package/lib/core/states.d.ts +25 -0
- package/lib/core/states.js +126 -0
- package/lib/core/text.d.ts +40 -0
- package/lib/core/text.js +113 -0
- package/lib/core/tickets.d.ts +69 -0
- package/lib/core/tickets.js +311 -0
- package/lib/core/trello.d.ts +17 -0
- package/lib/core/trello.js +269 -0
- package/lib/core/verdicts.d.ts +28 -0
- package/lib/core/verdicts.js +92 -0
- package/lib/core/views.d.ts +95 -0
- package/lib/core/views.js +186 -0
- package/lib/http/app.d.ts +46 -0
- package/lib/http/app.js +491 -0
- package/lib/mcp-server.d.ts +10 -0
- package/lib/mcp-server.js +120 -0
- package/lib/node/assets.d.ts +7 -0
- package/lib/node/assets.js +68 -0
- package/lib/node/data-folder.d.ts +21 -0
- package/lib/node/data-folder.js +34 -0
- package/lib/node/env.d.ts +38 -0
- package/lib/node/env.js +49 -0
- package/lib/node/host-check.d.ts +24 -0
- package/lib/node/host-check.js +53 -0
- package/lib/node/host-services.d.ts +9 -0
- package/lib/node/host-services.js +150 -0
- package/lib/node/local.d.ts +22 -0
- package/lib/node/local.js +61 -0
- package/lib/node/migrate.d.ts +53 -0
- package/lib/node/migrate.js +635 -0
- package/lib/node/package.d.ts +7 -0
- package/lib/node/package.js +14 -0
- package/lib/node/repo-board.d.ts +49 -0
- package/lib/node/repo-board.js +245 -0
- package/lib/node/serve.d.ts +8 -0
- package/lib/node/serve.js +106 -0
- package/lib/orbit.d.ts +47 -0
- package/lib/orbit.js +44 -0
- package/lib/quiet-sqlite-warning.d.ts +1 -0
- package/lib/quiet-sqlite-warning.js +18 -0
- package/lib/store/blob-path.d.ts +6 -0
- package/lib/store/blob-path.js +12 -0
- package/lib/store/conformance.d.ts +52 -0
- package/lib/store/conformance.js +1449 -0
- package/lib/store/search-words.d.ts +10 -0
- package/lib/store/search-words.js +19 -0
- package/lib/store/types.d.ts +452 -0
- package/lib/store/types.js +34 -0
- package/llms.txt +63 -0
- package/package.json +109 -25
- package/src/cli/orbit.js +11 -658
- package/src/mcp-server.js +16 -876
- package/SKILL-ORBIT.md +0 -151
- package/build.mjs +0 -210
- package/dist/full/js/app.4e6fbe20.js +0 -952
- package/dist/full/js/app.4e6fbe20.js.map +0 -7
- package/dist/full/styles.4e60d824.css +0 -1
- package/docs/AGENT_PROTOCOL.md +0 -424
- package/docs/MCP_SETUP.md +0 -54
- package/docs/ORBIT_DISPATCH.md +0 -280
- package/docs/SS-Orbit-Dark.png +0 -0
- package/docs/SS-Orbit-Light.png +0 -0
- package/docs/mcp-client.example.json +0 -13
- package/docs/setup/orbit-init-wizard.md +0 -174
- package/public/apple-touch-icon.png +0 -0
- package/public/cursors/live_arrow.cur +0 -0
- package/public/cursors/live_link.cur +0 -0
- package/public/cursors/live_move.cur +0 -0
- package/public/cursors/live_select.cur +0 -0
- package/public/cursors/live_unavail.cur +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Bold.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-BoldItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Italic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Light.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-LightItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Medium.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-MediumItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Regular.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-SemiBold.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-SemiBoldItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/OFL.txt +0 -93
- package/public/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf +0 -0
- package/public/fonts/Inter/Inter-VariableFont_opsz,wght.ttf +0 -0
- package/public/fonts/Inter/OFL.txt +0 -93
- package/public/fonts/Inter/README.txt +0 -118
- package/public/fonts/JetBrains_Mono/JetBrainsMono-Italic-VariableFont_wght.ttf +0 -0
- package/public/fonts/JetBrains_Mono/JetBrainsMono-VariableFont_wght.ttf +0 -0
- package/public/fonts/JetBrains_Mono/README.txt +0 -79
- package/public/fonts/OpenRing-Regular.ttf +0 -0
- package/public/fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf +0 -0
- package/public/fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf +0 -0
- package/public/fonts/Open_Sans/README.txt +0 -100
- package/public/fonts/Orbitron/OFL.txt +0 -93
- package/public/fonts/Orbitron/Orbitron-VariableFont_wght.ttf +0 -0
- package/public/fonts/Orbitron/README.txt +0 -68
- package/public/fonts/Rajdhani/OFL.txt +0 -93
- package/public/fonts/Rajdhani/Rajdhani-Bold.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-Light.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-Medium.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-Regular.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-SemiBold.ttf +0 -0
- package/public/fonts/runescape-uf.ttf +0 -0
- package/public/index.html +0 -769
- package/public/js/actor-labels.js +0 -65
- package/public/js/api.js +0 -39
- package/public/js/app.js +0 -45
- package/public/js/artifact-drawer.js +0 -79
- package/public/js/board-export.js +0 -161
- package/public/js/board-menu.js +0 -374
- package/public/js/card-actions.js +0 -545
- package/public/js/config.js +0 -20
- package/public/js/create-card.js +0 -190
- package/public/js/dom.js +0 -19
- package/public/js/drawer.js +0 -273
- package/public/js/font-preference.js +0 -230
- package/public/js/format.js +0 -433
- package/public/js/help-menu.js +0 -31
- package/public/js/icons.js +0 -16
- package/public/js/kanban.js +0 -694
- package/public/js/local-backend.js +0 -1015
- package/public/js/main.js +0 -161
- package/public/js/modal.js +0 -33
- package/public/js/motion-preference.js +0 -64
- package/public/js/panel.js +0 -58
- package/public/js/router.js +0 -134
- package/public/js/search.js +0 -56
- package/public/js/settings-tabs.js +0 -7
- package/public/js/settings.js +0 -1634
- package/public/js/sse.js +0 -159
- package/public/js/starscape.js +0 -332
- package/public/js/state.js +0 -129
- package/public/js/text-editing.js +0 -128
- package/public/js/theme-preference.js +0 -95
- package/public/js/ticket-detail.js +0 -1516
- package/public/js/ticket-typeahead.js +0 -49
- package/public/js/toast.js +0 -89
- package/public/js/topbar-notes.js +0 -155
- package/public/js/transport.js +0 -22
- package/public/js/typeahead.js +0 -206
- package/public/js/unread.js +0 -121
- package/public/js/update-check.js +0 -58
- package/public/js/url-routes.js +0 -94
- package/public/orbits-favicon.png +0 -0
- package/public/orbits-logo-bg-wide.png +0 -0
- package/public/orbits-logo-bg.png +0 -0
- package/public/orbits-logo.png +0 -0
- package/public/styles.css +0 -7709
- package/src/core/agent.js +0 -387
- package/src/core/agents-md.js +0 -109
- package/src/core/artifacts.js +0 -149
- package/src/core/attachments.js +0 -238
- package/src/core/auth.js +0 -85
- package/src/core/backups.js +0 -142
- package/src/core/board.js +0 -148
- package/src/core/boards.js +0 -409
- package/src/core/bootstrap.js +0 -170
- package/src/core/css-bundle.js +0 -30
- package/src/core/db.js +0 -315
- package/src/core/delete-board.js +0 -163
- package/src/core/dispatch.js +0 -1007
- package/src/core/events.js +0 -314
- package/src/core/http.js +0 -115
- package/src/core/migrate.js +0 -198
- package/src/core/paths.js +0 -55
- package/src/core/provision-repo-board.js +0 -119
- package/src/core/queries.js +0 -246
- package/src/core/registry.js +0 -211
- package/src/core/relations.js +0 -163
- package/src/core/review-verdicts.js +0 -138
- package/src/core/router.js +0 -556
- package/src/core/search.js +0 -183
- package/src/core/seed.js +0 -234
- package/src/core/snapshots.js +0 -692
- package/src/core/states.js +0 -167
- package/src/core/system-picker.js +0 -109
- package/src/core/tickets.js +0 -400
- package/src/core/util.js +0 -106
- package/src/core/version-check.js +0 -154
- package/src/mcp/orbit-client.js +0 -287
- package/src/server.js +0 -115
package/public/js/kanban.js
DELETED
|
@@ -1,694 +0,0 @@
|
|
|
1
|
-
// Kanban board rendering plus the drag-to-pan interaction. `renderBoard()`
|
|
2
|
-
// rebuilds every column from current state and rewires drop zones, card
|
|
3
|
-
// click/drag handlers, and the per-column "+" button. Card detail is
|
|
4
|
-
// owned by ticket-detail.js.
|
|
5
|
-
|
|
6
|
-
import { kanban } from "./dom.js";
|
|
7
|
-
import { state, visibleStatesForProject, ticketsForProject } from "./state.js";
|
|
8
|
-
import {
|
|
9
|
-
escapeHtml,
|
|
10
|
-
ticketLabel,
|
|
11
|
-
canonicalTicketType,
|
|
12
|
-
typeLabel,
|
|
13
|
-
priorityLabel,
|
|
14
|
-
priorityKeyFor,
|
|
15
|
-
renderLabels,
|
|
16
|
-
renderMarkdown
|
|
17
|
-
} from "./format.js";
|
|
18
|
-
import { api, withBoardQuery } from "./api.js";
|
|
19
|
-
import { navigate } from "./router.js";
|
|
20
|
-
import { closeCreateFlyout, openCreateFlyout } from "./create-card.js";
|
|
21
|
-
import { load } from "./app.js";
|
|
22
|
-
import { bindCardContextMenuDelegated, closeCardActionMenu, createCardActionHandlers, setCardActionDragInFlight } from "./card-actions.js";
|
|
23
|
-
import { unreadCount } from "./unread.js";
|
|
24
|
-
|
|
25
|
-
// Lookup for the current board's tickets, keyed by id. Refreshed on every
|
|
26
|
-
// renderBoard() so the single, persistent set of delegated listeners can
|
|
27
|
-
// resolve a card's ticket in O(1) without re-binding anything per render.
|
|
28
|
-
let boardTicketsById = new Map();
|
|
29
|
-
// Created once (lazily) and reused — handlers are stateless w.r.t. a given card.
|
|
30
|
-
let cardActionHandlers = null;
|
|
31
|
-
// HTML5 DnD ends with a synthetic click on the drag source; this flag lets the
|
|
32
|
-
// delegated click handler swallow that one click so a drop doesn't also open
|
|
33
|
-
// the ticket drawer. Module-scoped because there's now one shared listener.
|
|
34
|
-
let suppressClickAfterDrag = false;
|
|
35
|
-
|
|
36
|
-
// renderBoard() is hot: it runs on every drag-drop, every SSE board event, and
|
|
37
|
-
// every settings toggle. Keep it to a single innerHTML write plus a Map refresh.
|
|
38
|
-
// All interaction wiring lives in enableKanbanInteractions(), attached ONCE to
|
|
39
|
-
// the persistent `kanban` element (see below), so re-rendering never re-binds
|
|
40
|
-
// per-card/column/button listeners or rescans the ticket list.
|
|
41
|
-
export function renderBoard() {
|
|
42
|
-
const states = visibleStatesForProject();
|
|
43
|
-
const tickets = ticketsForProject();
|
|
44
|
-
boardTicketsById = new Map(tickets.map((ticket) => [ticket.id, ticket]));
|
|
45
|
-
kanban.innerHTML = states.map((column) => renderColumn(column, tickets)).join("");
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function toggleCardExpansion(card) {
|
|
49
|
-
if (!card) return;
|
|
50
|
-
const id = card.dataset.ticketId;
|
|
51
|
-
const willExpand = !state.expandedCardIds.has(id);
|
|
52
|
-
if (willExpand) {
|
|
53
|
-
state.expandedCardIds.add(id);
|
|
54
|
-
// Build the expandable body (markdown parse + labels + relations) the first
|
|
55
|
-
// time a card opens. Collapsed cards — the overwhelming majority on render —
|
|
56
|
-
// never pay this cost. Once built it stays cached in the DOM for re-toggles.
|
|
57
|
-
if (!card.querySelector(".card-expandable")) {
|
|
58
|
-
const ticket = boardTicketsById.get(id);
|
|
59
|
-
const meta = card.querySelector(".card-meta");
|
|
60
|
-
if (ticket && meta) meta.insertAdjacentHTML("afterend", renderCardExpandable(ticket));
|
|
61
|
-
}
|
|
62
|
-
} else {
|
|
63
|
-
state.expandedCardIds.delete(id);
|
|
64
|
-
}
|
|
65
|
-
card.classList.toggle("is-expanded", willExpand);
|
|
66
|
-
const toggleBtn = card.querySelector("button.card-expand-trigger");
|
|
67
|
-
if (toggleBtn) toggleBtn.setAttribute("aria-expanded", willExpand ? "true" : "false");
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// One-time setup: attach delegated listeners to the persistent board container.
|
|
71
|
-
// Because events bubble to `kanban`, these survive every innerHTML swap, so
|
|
72
|
-
// renderBoard() does zero listener work regardless of card count.
|
|
73
|
-
export function enableKanbanInteractions() {
|
|
74
|
-
cardActionHandlers = cardActionHandlers || createCardActionHandlers({ navigator: navigate });
|
|
75
|
-
|
|
76
|
-
// --- Drag & drop (HTML5 DnD events bubble, so one set covers all cards) ---
|
|
77
|
-
let dragOverColumn = null;
|
|
78
|
-
const setDragOverColumn = (column) => {
|
|
79
|
-
if (dragOverColumn === column) return;
|
|
80
|
-
if (dragOverColumn) dragOverColumn.classList.remove("drag-over");
|
|
81
|
-
dragOverColumn = column;
|
|
82
|
-
if (dragOverColumn) dragOverColumn.classList.add("drag-over");
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
kanban.addEventListener("dragstart", (event) => {
|
|
86
|
-
const card = event.target instanceof Element ? event.target.closest(".card") : null;
|
|
87
|
-
if (!card) return;
|
|
88
|
-
closeCardActionMenu();
|
|
89
|
-
setCardActionDragInFlight(true);
|
|
90
|
-
event.dataTransfer.setData("text/plain", card.dataset.ticketId);
|
|
91
|
-
event.dataTransfer.effectAllowed = "move";
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
kanban.addEventListener("dragover", (event) => {
|
|
95
|
-
const column = event.target instanceof Element ? event.target.closest(".column") : null;
|
|
96
|
-
if (!column) return;
|
|
97
|
-
event.preventDefault();
|
|
98
|
-
setDragOverColumn(column);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
kanban.addEventListener("dragend", () => {
|
|
102
|
-
setCardActionDragInFlight(false);
|
|
103
|
-
setDragOverColumn(null);
|
|
104
|
-
suppressClickAfterDrag = true;
|
|
105
|
-
// If no synthetic click follows (e.g. drag cancelled oddly), don't eat the
|
|
106
|
-
// next real click.
|
|
107
|
-
setTimeout(() => {
|
|
108
|
-
suppressClickAfterDrag = false;
|
|
109
|
-
}, 0);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
kanban.addEventListener("drop", async (event) => {
|
|
113
|
-
const column = event.target instanceof Element ? event.target.closest(".column") : null;
|
|
114
|
-
if (!column) return;
|
|
115
|
-
event.preventDefault();
|
|
116
|
-
setDragOverColumn(null);
|
|
117
|
-
const ticketId = event.dataTransfer.getData("text/plain");
|
|
118
|
-
if (!ticketId) return;
|
|
119
|
-
closeCreateFlyout();
|
|
120
|
-
await api(withBoardQuery(`/api/tickets/${ticketId}`), {
|
|
121
|
-
method: "PATCH",
|
|
122
|
-
body: { state_id: column.dataset.stateId }
|
|
123
|
-
});
|
|
124
|
-
// Refresh board only; do not open the ticket drawer (DnD also fires a
|
|
125
|
-
// synthetic click on the card — suppressed by suppressClickAfterDrag).
|
|
126
|
-
await load();
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
// --- Clicks (precedence: add-button > expand toggle > link > epic header > card) ---
|
|
130
|
-
kanban.addEventListener("click", (event) => {
|
|
131
|
-
const target = event.target instanceof Element ? event.target : event.target?.parentElement;
|
|
132
|
-
if (!target) return;
|
|
133
|
-
|
|
134
|
-
const addBtn = target.closest("[data-add-to-state]");
|
|
135
|
-
if (addBtn) {
|
|
136
|
-
event.stopPropagation();
|
|
137
|
-
openCreateFlyout(addBtn.dataset.addToState || "");
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const toggle = target.closest("button.card-expand-trigger");
|
|
142
|
-
if (toggle) {
|
|
143
|
-
event.stopPropagation();
|
|
144
|
-
event.preventDefault();
|
|
145
|
-
toggleCardExpansion(toggle.closest(".card"));
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Anchors inside a card keep their native behavior.
|
|
150
|
-
if (target.closest("a")) {
|
|
151
|
-
event.stopPropagation();
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const epicHeader = target.closest(".epic-mini-header[data-ticket-id]");
|
|
156
|
-
if (epicHeader) {
|
|
157
|
-
closeCreateFlyout();
|
|
158
|
-
navigate({ boardId: state.boardId, view: "ticket", ticketId: epicHeader.dataset.ticketId });
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const card = target.closest(".card");
|
|
163
|
-
if (!card) return;
|
|
164
|
-
if (suppressClickAfterDrag) {
|
|
165
|
-
suppressClickAfterDrag = false;
|
|
166
|
-
event.preventDefault();
|
|
167
|
-
event.stopPropagation();
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
closeCreateFlyout();
|
|
171
|
-
navigate({ boardId: state.boardId, view: "ticket", ticketId: card.dataset.ticketId });
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
// --- Context menu ---
|
|
175
|
-
bindCardContextMenuDelegated(kanban, (id) => boardTicketsById.get(id), {
|
|
176
|
-
handlers: cardActionHandlers,
|
|
177
|
-
onBeforeOpen: closeCreateFlyout
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
export function renderBoardSelection() {
|
|
182
|
-
kanban.querySelectorAll(".card.selected").forEach((card) => card.classList.remove("selected"));
|
|
183
|
-
if (!state.selectedTicketId) return;
|
|
184
|
-
kanban
|
|
185
|
-
.querySelectorAll(`.card[data-ticket-id="${CSS.escape(state.selectedTicketId)}"]`)
|
|
186
|
-
.forEach((card) => card.classList.add("selected"));
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function renderEmptyState(columnName) {
|
|
190
|
-
const name = columnName.toLowerCase();
|
|
191
|
-
let flair = {
|
|
192
|
-
icon: `<svg class="empty-state-icon" viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12" opacity="0.5" /></svg>`,
|
|
193
|
-
title: "Nothing here",
|
|
194
|
-
subtitle: "Tasks will appear when ready"
|
|
195
|
-
};
|
|
196
|
-
|
|
197
|
-
if (name === "ai ready") {
|
|
198
|
-
flair = {
|
|
199
|
-
icon: `<svg class="empty-state-icon ai-sparkle" viewBox="0 0 24 24">
|
|
200
|
-
<path d="M12 3L14.5 9L21 11.5L14.5 14L12 20L9.5 14L3 11.5L9.5 9L12 3Z" fill="currentColor" opacity="0.65" />
|
|
201
|
-
<circle cx="12" cy="11.5" r="1.8" fill="#fff" />
|
|
202
|
-
</svg>`,
|
|
203
|
-
title: "Ready for agents",
|
|
204
|
-
subtitle: "Drop unblocked cards here — the claim-next tool picks them up"
|
|
205
|
-
};
|
|
206
|
-
} else if (name.includes("progress")) {
|
|
207
|
-
flair = {
|
|
208
|
-
icon: `<svg class="empty-state-icon orbit" viewBox="0 0 100 100">
|
|
209
|
-
<circle cx="50" cy="50" r="10" fill="currentColor" opacity="0.9" />
|
|
210
|
-
<circle class="ring" cx="50" cy="50" r="30" stroke="currentColor" stroke-dasharray="4 6" />
|
|
211
|
-
<circle class="satellite" cx="80" cy="50" r="4" fill="currentColor" />
|
|
212
|
-
</svg>`,
|
|
213
|
-
title: "Work in progress",
|
|
214
|
-
subtitle: "Active work will appear here"
|
|
215
|
-
};
|
|
216
|
-
} else if (name.includes("review")) {
|
|
217
|
-
flair = {
|
|
218
|
-
icon: `<svg class="empty-state-icon star" viewBox="0 0 24 24">
|
|
219
|
-
<path d="M12 2L14.5 9.5L22 12L14.5 14.5L12 22L9.5 14.5L2 12L9.5 9.5L12 2Z" fill="currentColor" />
|
|
220
|
-
</svg>`,
|
|
221
|
-
title: "Under review",
|
|
222
|
-
subtitle: "Quality gate before merge"
|
|
223
|
-
};
|
|
224
|
-
} else if (name.includes("todo") || name.includes("backlog")) {
|
|
225
|
-
flair = {
|
|
226
|
-
icon: `<svg class="empty-state-icon ai-sparkle" viewBox="0 0 24 24">
|
|
227
|
-
<path d="M12 3L14.5 9L21 11.5L14.5 14L12 20L9.5 14L3 11.5L9.5 9L12 3Z" fill="currentColor" opacity="0.5" />
|
|
228
|
-
<path d="M19 8L20 10L21 8L20 6L19 8ZM19 16L20 18L21 16L20 14L19 16ZM5 8L6 10L7 8L6 6L5 8Z" fill="currentColor" />
|
|
229
|
-
</svg>`,
|
|
230
|
-
title: "Ready for launch",
|
|
231
|
-
subtitle: "Create a card manually or task an AI to generate tasks"
|
|
232
|
-
};
|
|
233
|
-
} else if (name.includes("done") || name.includes("finished")) {
|
|
234
|
-
flair = {
|
|
235
|
-
icon: `<svg class="empty-state-icon" viewBox="0 0 24 24">
|
|
236
|
-
<circle cx="12" cy="12" r="10" />
|
|
237
|
-
<path d="M8 12L11 15L16 9" />
|
|
238
|
-
</svg>`,
|
|
239
|
-
title: "Done",
|
|
240
|
-
subtitle: "Move completed tickets here"
|
|
241
|
-
};
|
|
242
|
-
} else if (name.includes("cancel")) {
|
|
243
|
-
flair = {
|
|
244
|
-
icon: `<svg class="empty-state-icon" viewBox="0 0 24 24">
|
|
245
|
-
<circle cx="12" cy="12" r="10" opacity="0.2" />
|
|
246
|
-
<path d="M15 9L9 15M9 9l6 6" />
|
|
247
|
-
</svg>`,
|
|
248
|
-
title: "Not moving forward",
|
|
249
|
-
subtitle: "Parked or replaced"
|
|
250
|
-
};
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
return `
|
|
254
|
-
<div class="column-empty-state">
|
|
255
|
-
<div class="empty-state-visual">
|
|
256
|
-
<div class="empty-state-glow"></div>
|
|
257
|
-
${flair.icon}
|
|
258
|
-
</div>
|
|
259
|
-
<div class="empty-state-title">${escapeHtml(flair.title)}</div>
|
|
260
|
-
<div class="empty-state-subtitle">${escapeHtml(flair.subtitle)}</div>
|
|
261
|
-
</div>
|
|
262
|
-
`;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function renderColumn(column, tickets) {
|
|
266
|
-
const columnTickets = tickets.filter((ticket) => ticket.state_id === column.id);
|
|
267
|
-
const isEmpty = columnTickets.length === 0;
|
|
268
|
-
// The "AI Ready" lane is a contract between humans and agents: cards here
|
|
269
|
-
// are the queue the claim-next MCP tool pulls from. Mark it so humans can
|
|
270
|
-
// instantly see where agent-facing staging lives.
|
|
271
|
-
const isAiReady = column.name === "AI Ready";
|
|
272
|
-
const columnClasses = ["column", "lane"];
|
|
273
|
-
if (isAiReady) columnClasses.push("column--ai-ready");
|
|
274
|
-
const accentAttr = isAiReady ? ' data-accent="ai-ready"' : "";
|
|
275
|
-
|
|
276
|
-
return `
|
|
277
|
-
<section class="${columnClasses.join(" ")}" data-flat${accentAttr} data-state-id="${column.id}" data-state-name="${escapeHtml(column.name)}">
|
|
278
|
-
<div class="column-head lane-head">
|
|
279
|
-
<h2 class="lane-title"${isAiReady ? ' title="Agent staging — cards here are picked up by the claim-next tool when unblocked"' : ""}>
|
|
280
|
-
${isAiReady ? '<span class="agent-dot" aria-hidden="true"></span>' : ""}${escapeHtml(column.name)}
|
|
281
|
-
</h2>
|
|
282
|
-
<div class="column-head-actions lane-actions">
|
|
283
|
-
<button type="button" class="column-add-btn" data-variant="plus" data-add-to-state="${escapeHtml(column.id)}" title="New card">+</button>
|
|
284
|
-
<span class="count">${columnTickets.length}</span>
|
|
285
|
-
</div>
|
|
286
|
-
</div>
|
|
287
|
-
${isEmpty ? renderEmptyState(column.name) : renderColumnBody(columnTickets, tickets)}
|
|
288
|
-
<button type="button" class="add-card-phantom" data-add-to-state="${escapeHtml(column.id)}">+ Add a card</button>
|
|
289
|
-
</section>
|
|
290
|
-
`;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* Lay out the cards inside a column with epic-aware grouping. Children of an
|
|
295
|
-
* epic that's also in this column slide under the epic; children whose epic
|
|
296
|
-
* lives in a different column appear under a thin "mini epic header" so the
|
|
297
|
-
* relationship is still visible. Children get pulled to their group even if
|
|
298
|
-
* the user dropped them out of order — the grouping always wins for clarity.
|
|
299
|
-
*/
|
|
300
|
-
function renderColumnBody(columnTickets, allTickets) {
|
|
301
|
-
const epicById = new Map(
|
|
302
|
-
allTickets
|
|
303
|
-
.filter((t) => canonicalTicketType(t.type) === "epic")
|
|
304
|
-
.map((t) => [t.id, t])
|
|
305
|
-
);
|
|
306
|
-
const epicIdsInColumn = new Set(
|
|
307
|
-
columnTickets
|
|
308
|
-
.filter((t) => canonicalTicketType(t.type) === "epic")
|
|
309
|
-
.map((t) => t.id)
|
|
310
|
-
);
|
|
311
|
-
const childrenByEpicId = new Map();
|
|
312
|
-
for (const t of columnTickets) {
|
|
313
|
-
if (t.parent_ticket_id && epicById.has(t.parent_ticket_id)) {
|
|
314
|
-
const list = childrenByEpicId.get(t.parent_ticket_id) || [];
|
|
315
|
-
list.push(t);
|
|
316
|
-
childrenByEpicId.set(t.parent_ticket_id, list);
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
const rendered = new Set();
|
|
321
|
-
const blocks = [];
|
|
322
|
-
for (const t of columnTickets) {
|
|
323
|
-
if (rendered.has(t.id)) continue;
|
|
324
|
-
|
|
325
|
-
if (canonicalTicketType(t.type) === "epic") {
|
|
326
|
-
const children = childrenByEpicId.get(t.id) || [];
|
|
327
|
-
if (children.length) {
|
|
328
|
-
const childrenHtml = children.map((c) => renderCard(c, { asEpicChild: true })).join("");
|
|
329
|
-
blocks.push(`${renderCard(t)}<div class="epic-children">${childrenHtml}</div>`);
|
|
330
|
-
children.forEach((c) => rendered.add(c.id));
|
|
331
|
-
} else {
|
|
332
|
-
blocks.push(renderCard(t));
|
|
333
|
-
}
|
|
334
|
-
rendered.add(t.id);
|
|
335
|
-
continue;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// Defer children whose epic is in this column — they'll render under the
|
|
339
|
-
// epic when the loop reaches it. Without this skip, a child iterated before
|
|
340
|
-
// its epic would render standalone here, then again inside epic-children
|
|
341
|
-
// (rendered.add happens at the epic, which may come later in updated_at order).
|
|
342
|
-
if (t.parent_ticket_id && epicIdsInColumn.has(t.parent_ticket_id)) {
|
|
343
|
-
continue;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
if (t.parent_ticket_id && epicById.has(t.parent_ticket_id) && !epicIdsInColumn.has(t.parent_ticket_id)) {
|
|
347
|
-
const epic = epicById.get(t.parent_ticket_id);
|
|
348
|
-
const siblings = childrenByEpicId.get(t.parent_ticket_id) || [];
|
|
349
|
-
const childrenHtml = siblings.map((c) => renderCard(c, { asEpicChild: true })).join("");
|
|
350
|
-
blocks.push(`${renderEpicMiniHeader(epic)}<div class="epic-children">${childrenHtml}</div>`);
|
|
351
|
-
siblings.forEach((c) => rendered.add(c.id));
|
|
352
|
-
continue;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
blocks.push(renderCard(t));
|
|
356
|
-
rendered.add(t.id);
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
return blocks.join("");
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
function renderEpicMiniHeader(epic) {
|
|
363
|
-
return `
|
|
364
|
-
<div class="epic-mini-header" data-ticket-id="${escapeHtml(epic.id)}" title="Open epic">
|
|
365
|
-
<div class="card-type-id type-pill-epic">
|
|
366
|
-
${renderTypeIcon("epic")}
|
|
367
|
-
<span class="card-type-label">EPIC</span>
|
|
368
|
-
</div>
|
|
369
|
-
<span class="ticket-number">${escapeHtml(ticketLabel(epic))}</span>
|
|
370
|
-
<span class="epic-mini-title">${escapeHtml(epic.title)}</span>
|
|
371
|
-
</div>
|
|
372
|
-
`;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/** Pill mirroring the structure/feel of the Type pill, colored per priority. */
|
|
376
|
-
export function renderPriorityPill(priority) {
|
|
377
|
-
const key = priorityKeyFor(priority);
|
|
378
|
-
const label = priorityLabel(priority);
|
|
379
|
-
return `<div class="card-priority-id priority-pill" data-variant="${escapeHtml(key)}"><span class="card-priority-label">${escapeHtml(label)}</span></div>`;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
export function renderTypeIcon(type) {
|
|
383
|
-
const canonical = canonicalTicketType(type);
|
|
384
|
-
let icon = "";
|
|
385
|
-
let color = "currentColor";
|
|
386
|
-
|
|
387
|
-
switch (canonical) {
|
|
388
|
-
case "epic":
|
|
389
|
-
color = "#c084fc"; // Purple
|
|
390
|
-
icon = `<path d="M12 2L14.5 9.5L22 12L14.5 14.5L12 22L9.5 14.5L2 12L9.5 9.5L12 2Z" fill="currentColor" />`;
|
|
391
|
-
break;
|
|
392
|
-
case "feature":
|
|
393
|
-
color = "#4ade80"; // Green
|
|
394
|
-
icon = `<path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2v16z" fill="currentColor" />`;
|
|
395
|
-
break;
|
|
396
|
-
case "bug":
|
|
397
|
-
color = "#f87171"; // Red
|
|
398
|
-
icon = `<circle cx="12" cy="12" r="10" /><path d="M12 8v4M12 16h.01" />`;
|
|
399
|
-
break;
|
|
400
|
-
default: // task
|
|
401
|
-
color = "#60a5fa"; // Blue
|
|
402
|
-
icon = `<rect x="3" y="3" width="18" height="18" rx="2" /><path d="M9 12l2 2 4-4" />`;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
return `
|
|
406
|
-
<svg class="card-type-icon" viewBox="0 0 24 24" style="color: ${color}">
|
|
407
|
-
${icon}
|
|
408
|
-
</svg>
|
|
409
|
-
`;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
function renderCard(ticket, { asEpicChild = false } = {}) {
|
|
413
|
-
const canonicalType = canonicalTicketType(ticket.type);
|
|
414
|
-
const priorityKey = priorityKeyFor(ticket.priority);
|
|
415
|
-
const typeText = typeLabel(ticket.type).toUpperCase();
|
|
416
|
-
const isExpanded = state.expandedCardIds.has(ticket.id);
|
|
417
|
-
const hasDescription = Boolean(String(ticket.description || "").trim());
|
|
418
|
-
const hasExpandable = hasDescription || (ticket.labels && ticket.labels.length) || ticket.parent_ticket || ticket.child_count;
|
|
419
|
-
|
|
420
|
-
const triggerInner = `
|
|
421
|
-
<span class="ticket-number">${escapeHtml(ticketLabel(ticket))}</span>
|
|
422
|
-
<div class="card-type-id type-pill-${escapeHtml(canonicalType)}">
|
|
423
|
-
${renderTypeIcon(ticket.type)}
|
|
424
|
-
<span class="card-type-label">${escapeHtml(typeText)}</span>
|
|
425
|
-
</div>
|
|
426
|
-
${state.showPriority ? renderPriorityPill(ticket.priority) : ""}
|
|
427
|
-
${hasExpandable ? `<svg class="card-expand-chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 9l6 6 6-6" /></svg>` : ""}
|
|
428
|
-
`;
|
|
429
|
-
|
|
430
|
-
const unreadN = unreadCount(ticket);
|
|
431
|
-
const unreadDot = unreadN === 0
|
|
432
|
-
? ""
|
|
433
|
-
: unreadN === 1
|
|
434
|
-
? `<span class="card-unread-dot" role="status" aria-label="1 unread update"></span>`
|
|
435
|
-
: `<span class="card-unread-dot has-count" role="status" aria-label="${unreadN} unread updates">${unreadN > 99 ? "99+" : unreadN}</span>`;
|
|
436
|
-
|
|
437
|
-
return `
|
|
438
|
-
<article class="card type-${escapeHtml(canonicalType)} priority-${escapeHtml(priorityKey)}${isExpanded ? " is-expanded" : ""}${asEpicChild ? " is-epic-child" : ""} ${ticket.id === state.selectedTicketId ? "selected" : ""}" data-variant="${escapeHtml(canonicalType)}" draggable="true" data-ticket-id="${ticket.id}">
|
|
439
|
-
${unreadDot}
|
|
440
|
-
<h3>${escapeHtml(ticket.title)}</h3>
|
|
441
|
-
|
|
442
|
-
<div class="card-meta">
|
|
443
|
-
${hasExpandable
|
|
444
|
-
? `<button type="button" class="card-expand-trigger" data-variant="card-accordion" data-card-toggle aria-expanded="${isExpanded ? "true" : "false"}">${triggerInner}</button>`
|
|
445
|
-
: `<div class="card-expand-trigger card-expand-trigger--static">${triggerInner}</div>`}
|
|
446
|
-
</div>
|
|
447
|
-
|
|
448
|
-
${hasExpandable && isExpanded ? renderCardExpandable(ticket) : ""}
|
|
449
|
-
</article>
|
|
450
|
-
`;
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
// The expandable body holds the only genuinely expensive per-card work: a full
|
|
454
|
-
// markdown parse of the description. It's rendered eagerly only for cards that
|
|
455
|
-
// are already expanded; collapsed cards get it injected on first open (see
|
|
456
|
-
// toggleCardExpansion). Kept as its own function so both paths share one source.
|
|
457
|
-
function renderCardExpandable(ticket) {
|
|
458
|
-
const hasDescription = Boolean(String(ticket.description || "").trim());
|
|
459
|
-
return `
|
|
460
|
-
<div class="card-expandable">
|
|
461
|
-
${hasDescription ? `<div class="card-description markdown-body">${renderMarkdown(ticket.description)}</div>` : ""}
|
|
462
|
-
${renderLabels(ticket.labels)}
|
|
463
|
-
${ticket.parent_ticket ? `<div class="parent-line">-> ${escapeHtml(ticketLabel(ticket.parent_ticket))}</div>` : ""}
|
|
464
|
-
${ticket.child_count ? `<div class="child-count">${ticket.child_count} feature${ticket.child_count === 1 ? "" : "s"} inside</div>` : ""}
|
|
465
|
-
</div>
|
|
466
|
-
`;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
/**
|
|
470
|
-
* Click-and-drag horizontal panning on the kanban container.
|
|
471
|
-
* Skipped when the pointer starts on a .card (cards own their own click + HTML5 DnD),
|
|
472
|
-
* or on any interactive control inside a column head.
|
|
473
|
-
*/
|
|
474
|
-
export function enableKanbanDragScroll() {
|
|
475
|
-
const DRAG_THRESHOLD = 4; // px before we consider it a real drag (avoids eating clicks)
|
|
476
|
-
const LINE_SCROLL_PX = 16;
|
|
477
|
-
const PAGE_SCROLL_PX = () => Math.max(kanban.clientWidth, 1);
|
|
478
|
-
let active = false;
|
|
479
|
-
let dragged = false;
|
|
480
|
-
let startX = 0;
|
|
481
|
-
let startScroll = 0;
|
|
482
|
-
let pointerId = 0;
|
|
483
|
-
|
|
484
|
-
const shouldIgnore = (target) => {
|
|
485
|
-
if (!(target instanceof Element)) return false;
|
|
486
|
-
// Let cards keep their click/drag behavior untouched.
|
|
487
|
-
if (target.closest(".card")) return true;
|
|
488
|
-
// Don't hijack interactive controls that might land in the board area later.
|
|
489
|
-
if (target.closest("a, button, input, select, textarea, [draggable='true']")) return true;
|
|
490
|
-
return false;
|
|
491
|
-
};
|
|
492
|
-
|
|
493
|
-
const wheelPixels = (value, mode) => {
|
|
494
|
-
if (mode === WheelEvent.DOM_DELTA_LINE) return value * LINE_SCROLL_PX;
|
|
495
|
-
if (mode === WheelEvent.DOM_DELTA_PAGE) return value * PAGE_SCROLL_PX();
|
|
496
|
-
return value;
|
|
497
|
-
};
|
|
498
|
-
|
|
499
|
-
kanban.addEventListener(
|
|
500
|
-
"wheel",
|
|
501
|
-
(event) => {
|
|
502
|
-
if (event.defaultPrevented || event.ctrlKey) return;
|
|
503
|
-
const horizontalDelta = event.deltaX || (event.shiftKey ? event.deltaY : 0);
|
|
504
|
-
if (!horizontalDelta) return;
|
|
505
|
-
const horizontal = Math.abs(horizontalDelta) >= Math.abs(event.deltaY) || event.shiftKey;
|
|
506
|
-
if (!horizontal || kanban.scrollWidth <= kanban.clientWidth) return;
|
|
507
|
-
|
|
508
|
-
event.preventDefault();
|
|
509
|
-
kanban.scrollLeft += wheelPixels(horizontalDelta, event.deltaMode);
|
|
510
|
-
},
|
|
511
|
-
{ passive: false }
|
|
512
|
-
);
|
|
513
|
-
|
|
514
|
-
kanban.addEventListener("pointerdown", (event) => {
|
|
515
|
-
if (event.button !== 0) return;
|
|
516
|
-
// Clear stale drag-click suppression on any fresh primary press — even over
|
|
517
|
-
// a card — so a both-button / two-finger pan that emitted no trailing click
|
|
518
|
-
// can't swallow the next genuine click.
|
|
519
|
-
dragged = false;
|
|
520
|
-
if (shouldIgnore(event.target)) return;
|
|
521
|
-
|
|
522
|
-
active = true;
|
|
523
|
-
pointerId = event.pointerId;
|
|
524
|
-
startX = event.clientX;
|
|
525
|
-
startScroll = kanban.scrollLeft;
|
|
526
|
-
kanban.classList.add("is-grabbing");
|
|
527
|
-
});
|
|
528
|
-
|
|
529
|
-
kanban.addEventListener("pointermove", (event) => {
|
|
530
|
-
if (!active || event.pointerId !== pointerId) return;
|
|
531
|
-
const delta = event.clientX - startX;
|
|
532
|
-
if (!dragged && Math.abs(delta) > DRAG_THRESHOLD) {
|
|
533
|
-
dragged = true;
|
|
534
|
-
// Capture so movement outside the element still pans smoothly.
|
|
535
|
-
try {
|
|
536
|
-
kanban.setPointerCapture(pointerId);
|
|
537
|
-
} catch {
|
|
538
|
-
/* no-op: some browsers can't capture here */
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
if (dragged) {
|
|
542
|
-
event.preventDefault();
|
|
543
|
-
kanban.scrollLeft = startScroll - delta;
|
|
544
|
-
}
|
|
545
|
-
});
|
|
546
|
-
|
|
547
|
-
const endDrag = (event) => {
|
|
548
|
-
if (!active || (event && event.pointerId !== pointerId)) return;
|
|
549
|
-
active = false;
|
|
550
|
-
kanban.classList.remove("is-grabbing");
|
|
551
|
-
try {
|
|
552
|
-
kanban.releasePointerCapture(pointerId);
|
|
553
|
-
} catch {
|
|
554
|
-
/* no-op */
|
|
555
|
-
}
|
|
556
|
-
};
|
|
557
|
-
|
|
558
|
-
kanban.addEventListener("pointerup", endDrag);
|
|
559
|
-
kanban.addEventListener("pointercancel", endDrag);
|
|
560
|
-
kanban.addEventListener("pointerleave", endDrag);
|
|
561
|
-
|
|
562
|
-
// Suppress the synthetic click at the end of a real drag so empty-area panning
|
|
563
|
-
// doesn't accidentally trigger downstream click handlers.
|
|
564
|
-
kanban.addEventListener(
|
|
565
|
-
"click",
|
|
566
|
-
(event) => {
|
|
567
|
-
if (dragged) {
|
|
568
|
-
event.stopPropagation();
|
|
569
|
-
event.preventDefault();
|
|
570
|
-
dragged = false;
|
|
571
|
-
}
|
|
572
|
-
},
|
|
573
|
-
true
|
|
574
|
-
);
|
|
575
|
-
|
|
576
|
-
// --- Both-button mouse drag + two-finger touch drag -----------------------
|
|
577
|
-
// A second panning affordance that works ANYWHERE on the board, including
|
|
578
|
-
// directly over cards. A plain left-click / single-finger tap is left to the
|
|
579
|
-
// cards (so they still open + drag); holding *both* mouse buttons, or dragging
|
|
580
|
-
// with *two fingers*, pans the board horizontally without engaging a card.
|
|
581
|
-
let bothBtnPanning = false;
|
|
582
|
-
let bothBtnLastX = 0;
|
|
583
|
-
let suppressContextMenu = false;
|
|
584
|
-
|
|
585
|
-
// Pressing the second mouse button (buttons bitmask 1|2 === 3) arms the pan
|
|
586
|
-
// and cancels any single-button drag already in flight (see `active` above).
|
|
587
|
-
kanban.addEventListener("pointerdown", (event) => {
|
|
588
|
-
if (event.pointerType !== "mouse" || (event.buttons & 3) !== 3) return;
|
|
589
|
-
bothBtnPanning = true;
|
|
590
|
-
bothBtnLastX = event.clientX;
|
|
591
|
-
suppressContextMenu = true;
|
|
592
|
-
active = false; // hand control off from the left-button drag
|
|
593
|
-
kanban.classList.add("is-grabbing");
|
|
594
|
-
});
|
|
595
|
-
|
|
596
|
-
kanban.addEventListener("pointermove", (event) => {
|
|
597
|
-
if (event.pointerType !== "mouse") return;
|
|
598
|
-
const bothDown = (event.buttons & 3) === 3;
|
|
599
|
-
if (bothDown) {
|
|
600
|
-
event.preventDefault();
|
|
601
|
-
if (!bothBtnPanning) {
|
|
602
|
-
bothBtnPanning = true;
|
|
603
|
-
bothBtnLastX = event.clientX;
|
|
604
|
-
suppressContextMenu = true;
|
|
605
|
-
active = false;
|
|
606
|
-
kanban.classList.add("is-grabbing");
|
|
607
|
-
return;
|
|
608
|
-
}
|
|
609
|
-
const delta = event.clientX - bothBtnLastX;
|
|
610
|
-
if (delta) {
|
|
611
|
-
kanban.scrollLeft -= delta;
|
|
612
|
-
bothBtnLastX = event.clientX;
|
|
613
|
-
dragged = true; // eat the trailing synthetic click
|
|
614
|
-
}
|
|
615
|
-
} else if (bothBtnPanning) {
|
|
616
|
-
bothBtnPanning = false;
|
|
617
|
-
kanban.classList.remove("is-grabbing");
|
|
618
|
-
}
|
|
619
|
-
});
|
|
620
|
-
|
|
621
|
-
kanban.addEventListener("pointerup", (event) => {
|
|
622
|
-
if (event.pointerType === "mouse" && bothBtnPanning && (event.buttons & 3) !== 3) {
|
|
623
|
-
bothBtnPanning = false;
|
|
624
|
-
kanban.classList.remove("is-grabbing");
|
|
625
|
-
}
|
|
626
|
-
});
|
|
627
|
-
|
|
628
|
-
// Right-click handling on the board:
|
|
629
|
-
// - after a both-button pan, swallow the trailing context menu;
|
|
630
|
-
// - otherwise suppress the native browser menu everywhere EXCEPT over a card,
|
|
631
|
-
// which owns its own context menu (see bindCardContextMenuDelegated).
|
|
632
|
-
kanban.addEventListener("contextmenu", (event) => {
|
|
633
|
-
if (suppressContextMenu) {
|
|
634
|
-
event.preventDefault();
|
|
635
|
-
event.stopPropagation();
|
|
636
|
-
suppressContextMenu = false;
|
|
637
|
-
return;
|
|
638
|
-
}
|
|
639
|
-
const onCard = event.target instanceof Element && event.target.closest(".card");
|
|
640
|
-
if (!onCard) {
|
|
641
|
-
event.preventDefault();
|
|
642
|
-
event.stopPropagation();
|
|
643
|
-
}
|
|
644
|
-
});
|
|
645
|
-
|
|
646
|
-
// Two-finger touch pan. Touch events (not pointer events) are used here so we
|
|
647
|
-
// can reliably preventDefault the browser's native two-finger scroll/zoom.
|
|
648
|
-
let touchPanning = false;
|
|
649
|
-
let touchPanLastX = 0;
|
|
650
|
-
const twoFingerCentroidX = (touches) => (touches[0].clientX + touches[1].clientX) / 2;
|
|
651
|
-
|
|
652
|
-
kanban.addEventListener(
|
|
653
|
-
"touchstart",
|
|
654
|
-
(event) => {
|
|
655
|
-
// Fresh gesture — clear any leftover drag-click suppression so a real tap
|
|
656
|
-
// is never swallowed when the prior two-finger pan emitted no click.
|
|
657
|
-
dragged = false;
|
|
658
|
-
if (event.touches.length === 2) {
|
|
659
|
-
touchPanning = true;
|
|
660
|
-
touchPanLastX = twoFingerCentroidX(event.touches);
|
|
661
|
-
active = false; // cancel any single-finger drag that started
|
|
662
|
-
kanban.classList.add("is-grabbing");
|
|
663
|
-
} else {
|
|
664
|
-
touchPanning = false;
|
|
665
|
-
}
|
|
666
|
-
},
|
|
667
|
-
{ passive: true }
|
|
668
|
-
);
|
|
669
|
-
|
|
670
|
-
kanban.addEventListener(
|
|
671
|
-
"touchmove",
|
|
672
|
-
(event) => {
|
|
673
|
-
if (!touchPanning || event.touches.length !== 2) return;
|
|
674
|
-
const x = twoFingerCentroidX(event.touches);
|
|
675
|
-
const delta = x - touchPanLastX;
|
|
676
|
-
if (delta) {
|
|
677
|
-
event.preventDefault();
|
|
678
|
-
kanban.scrollLeft -= delta;
|
|
679
|
-
touchPanLastX = x;
|
|
680
|
-
dragged = true; // eat the trailing tap so a card doesn't open
|
|
681
|
-
}
|
|
682
|
-
},
|
|
683
|
-
{ passive: false }
|
|
684
|
-
);
|
|
685
|
-
|
|
686
|
-
const endTouchPan = (event) => {
|
|
687
|
-
if (event.touches.length < 2 && touchPanning) {
|
|
688
|
-
touchPanning = false;
|
|
689
|
-
kanban.classList.remove("is-grabbing");
|
|
690
|
-
}
|
|
691
|
-
};
|
|
692
|
-
kanban.addEventListener("touchend", endTouchPan, { passive: true });
|
|
693
|
-
kanban.addEventListener("touchcancel", endTouchPan, { passive: true });
|
|
694
|
-
}
|