@dou-ui/dashboard 0.1.0-alpha.3 → 0.1.0-alpha.4
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/dist/core/document.js +10 -1
- package/dist/core/document.js.map +1 -1
- package/dist/core/history.js +23 -0
- package/dist/core/history.js.map +1 -1
- package/dist/core/transaction.js +8 -2
- package/dist/core/transaction.js.map +1 -1
- package/dist/dashboard/comment-button.js +6 -0
- package/dist/dashboard/comment-button.js.map +1 -0
- package/dist/dashboard/comment-button.vue_vue_type_script_setup_true_lang.js +59 -0
- package/dist/dashboard/comment-button.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/dashboard/comment-card.js +6 -0
- package/dist/dashboard/comment-card.js.map +1 -0
- package/dist/dashboard/comment-card.vue_vue_type_script_setup_true_lang.js +128 -0
- package/dist/dashboard/comment-card.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/dashboard/comments.js +144 -0
- package/dist/dashboard/comments.js.map +1 -0
- package/dist/dashboard/dashboard.js.map +1 -1
- package/dist/dashboard/dashboard.vue_vue_type_script_setup_true_lang.js +107 -18
- package/dist/dashboard/dashboard.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/dashboard/frame-context.js.map +1 -1
- package/dist/dashboard/widget-frame.js.map +1 -1
- package/dist/dashboard/widget-frame.vue_vue_type_script_setup_true_lang.js +10 -0
- package/dist/dashboard/widget-frame.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/locale.js +6 -0
- package/dist/locale.js.map +1 -1
- package/dist/style.css +1 -1
- package/dist/style.js +2 -3
- package/dist/types/core/document.d.ts +3 -2
- package/dist/types/core/history.d.ts +7 -1
- package/dist/types/core/transaction.d.ts +10 -1
- package/dist/types/core/types.d.ts +5 -0
- package/dist/types/dashboard/comment-button.vue.d.ts +15 -0
- package/dist/types/dashboard/comment-card.vue.d.ts +13 -0
- package/dist/types/dashboard/comments.d.ts +44 -0
- package/dist/types/dashboard/dashboard.vue.d.ts +10 -0
- package/dist/types/dashboard/frame-context.d.ts +2 -0
- package/dist/types/dashboard/types.d.ts +30 -0
- package/dist/types/index.d.ts +20 -1
- package/dist/types/locale.d.ts +4 -0
- package/package.json +8 -8
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"comments.js","names":[],"sources":["../../src/dashboard/comments.ts"],"sourcesContent":["import { applyCommentAction, type CommentAction, type CommentThread } from 'dou-ui'\nimport { computed, shallowRef, type ComputedRef, type Ref } from 'vue'\nimport type { DashboardStep, DashboardTransactionMeta } from '../core/transaction'\nimport type { DashboardDocument } from '../core/types'\nimport type { DashboardCommentAnchor, DashboardEmits, DashboardProps } from './types'\n\ntype Emit = <K extends keyof DashboardEmits>(event: K, ...args: DashboardEmits[K]) => void\n\n/** The thread ids on a widget; nothing when a hand-written document has something else there. */\nexport const commentIdsOf = (widget: { comments?: unknown }): string[] =>\n Array.isArray(widget.comments) ? widget.comments.filter((id) => typeof id === 'string') : []\n\n/**\n * The comments of a dashboard: which widget each thread is about, the thread\n * open now, and the one being started.\n */\nexport function useDashboardComments(options: {\n props: Omit<DashboardProps, 'modelValue' | 'filters'>\n emit: Emit\n model: Ref<DashboardDocument>\n run: (steps: DashboardStep[], meta: DashboardTransactionMeta) => unknown\n /** Keeps a change out of undo on the widget as undo and redo bring it back. */\n carry: (id: string, patch: { comments: string[] | undefined }) => void\n}) {\n const { props, emit, model, run, carry } = options\n\n const threads = computed<readonly CommentThread[]>(() => props.comments ?? [])\n const enabled = computed(() => props.comments !== undefined)\n const writable = computed(() => enabled.value && !!props.author && props.commentable !== false)\n\n const innerActive = shallowRef<string | null>(null)\n const active = computed(() =>\n props.activeComment === undefined ? innerActive.value : props.activeComment,\n )\n /** The last thread this dashboard opened, so a `v-model:activeComment` echoing it isn't taken for one from outside. */\n let ownOpen: string | null = null\n function open(id: string | null) {\n if (id === active.value) return\n innerActive.value = id\n ownOpen = id\n emit('update:activeComment', id)\n }\n const openedHere = (id: string | null) => id === ownOpen\n\n /** The widget a thread is being started on, until its first comment is sent. */\n const pending = shallowRef<string | null>(null)\n /** Goes up when the open card should take the focus, ready for a reply. */\n const focusRequest = shallowRef(0)\n\n /** The open threads on each widget, oldest first. */\n const openOn: ComputedRef<Map<string, string[]>> = computed(() => {\n const live = new Set(threads.value.filter((t) => !t.resolved).map((t) => t.id))\n const out = new Map<string, string[]>()\n for (const widget of model.value.widgets) {\n const ids = commentIdsOf(widget).filter((id) => live.has(id))\n if (ids.length) out.set(widget.id, ids)\n }\n return out\n })\n\n function widgetOf(id: string): string | null {\n return model.value.widgets.find((widget) => commentIdsOf(widget).includes(id))?.id ?? null\n }\n\n /** The widget the card is for: the one a thread is being started on, or the open thread's. */\n const current = computed<string | null>(() => {\n if (pending.value) return pending.value\n const id = active.value\n return id ? widgetOf(id) : null\n })\n\n /** The threads the card shows: every open one on the widget, or the open one if it is resolved. */\n const shown = computed<CommentThread[]>(() => {\n const widget = current.value\n if (!widget || pending.value) return []\n const ids = openOn.value.get(widget) ?? []\n const list = active.value && !ids.includes(active.value) ? [...ids, active.value] : ids\n const byId = new Map(threads.value.map((t) => [t.id, t]))\n return list.flatMap((id) => byId.get(id) ?? [])\n })\n\n /**\n * Opens the discussions on a widget, or the box to start one. `focus`\n * puts the focus in the reply box: from the keyboard, not from a tap,\n * which would bring up the keyboard on a phone.\n */\n function start(widget: string, focus = true): boolean {\n if (!enabled.value || !model.value.widgets.some((w) => w.id === widget)) return false\n // one already there: open it, ready to reply\n const [first] = openOn.value.get(widget) ?? []\n if (first) {\n pending.value = null\n open(first)\n if (focus) focusRequest.value++\n return true\n }\n if (!writable.value) return false\n open(null)\n pending.value = widget\n focusRequest.value++\n return true\n }\n\n function close() {\n pending.value = null\n open(null)\n }\n\n /** Adds or takes away a thread's id on its widget; not an undo step. */\n function mark(widget: string, id: string, add: boolean) {\n const found = model.value.widgets.find((w) => w.id === widget)\n if (!found) return\n const had = commentIdsOf(found)\n const next = add ? [...had.filter((x) => x !== id), id] : had.filter((x) => x !== id)\n const patch = { comments: next.length ? next : undefined }\n run([{ type: 'update', id: widget, patch }], { history: false, origin: 'comment' })\n carry(widget, patch)\n }\n\n function apply(action: CommentAction) {\n if (action.type === 'create') {\n const widget = pending.value\n if (!widget) return\n mark(widget, action.thread.id, true)\n pending.value = null\n } else if (action.type === 'remove') {\n const widget = widgetOf(action.threadId)\n if (widget) mark(widget, action.threadId, false)\n }\n emit('update:comments', applyCommentAction(threads.value, action))\n emit('commentAction', action)\n if (action.type === 'create') {\n open(action.thread.id)\n focusRequest.value++\n } else if (action.type === 'remove' || action.type === 'resolve') open(null)\n }\n\n function anchors(): Record<string, DashboardCommentAnchor | null> {\n return Object.fromEntries(\n threads.value.map((thread) => {\n const widget = widgetOf(thread.id)\n return [thread.id, widget ? { widget } : null]\n }),\n )\n }\n\n return {\n enabled,\n writable,\n threads,\n author: computed(() => props.author),\n active,\n pending,\n focusRequest,\n openOn,\n current,\n shown,\n start,\n apply,\n open,\n close,\n openedHere,\n anchors,\n widgetOf,\n }\n}\n\nexport type DashboardComments = ReturnType<typeof useDashboardComments>\n"],"mappings":";;;;AASA,IAAa,gBAAgB,WAC3B,MAAM,QAAQ,OAAO,QAAQ,IAAI,OAAO,SAAS,QAAQ,OAAO,OAAO,OAAO,QAAQ,IAAI,CAAC;;;;;AAM7F,SAAgB,qBAAqB,SAOlC;CACD,MAAM,EAAE,OAAO,MAAM,OAAO,KAAK,UAAU;CAE3C,MAAM,UAAU,eAAyC,MAAM,YAAY,CAAC,CAAC;CAC7E,MAAM,UAAU,eAAe,MAAM,aAAa,KAAA,CAAS;CAC3D,MAAM,WAAW,eAAe,QAAQ,SAAS,CAAC,CAAC,MAAM,UAAU,MAAM,gBAAgB,KAAK;CAE9F,MAAM,cAAc,WAA0B,IAAI;CAClD,MAAM,SAAS,eACb,MAAM,kBAAkB,KAAA,IAAY,YAAY,QAAQ,MAAM,aAChE;;CAEA,IAAI,UAAyB;CAC7B,SAAS,KAAK,IAAmB;EAC/B,IAAI,OAAO,OAAO,OAAO;EACzB,YAAY,QAAQ;EACpB,UAAU;EACV,KAAK,wBAAwB,EAAE;CACjC;CACA,MAAM,cAAc,OAAsB,OAAO;;CAGjD,MAAM,UAAU,WAA0B,IAAI;;CAE9C,MAAM,eAAe,WAAW,CAAC;;CAGjC,MAAM,SAA6C,eAAe;EAChE,MAAM,OAAO,IAAI,IAAI,QAAQ,MAAM,QAAQ,MAAM,CAAC,EAAE,QAAQ,CAAC,CAAC,KAAK,MAAM,EAAE,EAAE,CAAC;EAC9E,MAAM,sBAAM,IAAI,IAAsB;EACtC,KAAK,MAAM,UAAU,MAAM,MAAM,SAAS;GACxC,MAAM,MAAM,aAAa,MAAM,CAAC,CAAC,QAAQ,OAAO,KAAK,IAAI,EAAE,CAAC;GAC5D,IAAI,IAAI,QAAQ,IAAI,IAAI,OAAO,IAAI,GAAG;EACxC;EACA,OAAO;CACT,CAAC;CAED,SAAS,SAAS,IAA2B;EAC3C,OAAO,MAAM,MAAM,QAAQ,MAAM,WAAW,aAAa,MAAM,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,EAAE,MAAM;CACxF;;CAGA,MAAM,UAAU,eAA8B;EAC5C,IAAI,QAAQ,OAAO,OAAO,QAAQ;EAClC,MAAM,KAAK,OAAO;EAClB,OAAO,KAAK,SAAS,EAAE,IAAI;CAC7B,CAAC;;CAGD,MAAM,QAAQ,eAAgC;EAC5C,MAAM,SAAS,QAAQ;EACvB,IAAI,CAAC,UAAU,QAAQ,OAAO,OAAO,CAAC;EACtC,MAAM,MAAM,OAAO,MAAM,IAAI,MAAM,KAAK,CAAC;EACzC,MAAM,OAAO,OAAO,SAAS,CAAC,IAAI,SAAS,OAAO,KAAK,IAAI,CAAC,GAAG,KAAK,OAAO,KAAK,IAAI;EACpF,MAAM,OAAO,IAAI,IAAI,QAAQ,MAAM,KAAK,MAAM,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC;EACxD,OAAO,KAAK,SAAS,OAAO,KAAK,IAAI,EAAE,KAAK,CAAC,CAAC;CAChD,CAAC;;;;;;CAOD,SAAS,MAAM,QAAgB,QAAQ,MAAe;EACpD,IAAI,CAAC,QAAQ,SAAS,CAAC,MAAM,MAAM,QAAQ,MAAM,MAAM,EAAE,OAAO,MAAM,GAAG,OAAO;EAEhF,MAAM,CAAC,SAAS,OAAO,MAAM,IAAI,MAAM,KAAK,CAAC;EAC7C,IAAI,OAAO;GACT,QAAQ,QAAQ;GAChB,KAAK,KAAK;GACV,IAAI,OAAO,aAAa;GACxB,OAAO;EACT;EACA,IAAI,CAAC,SAAS,OAAO,OAAO;EAC5B,KAAK,IAAI;EACT,QAAQ,QAAQ;EAChB,aAAa;EACb,OAAO;CACT;CAEA,SAAS,QAAQ;EACf,QAAQ,QAAQ;EAChB,KAAK,IAAI;CACX;;CAGA,SAAS,KAAK,QAAgB,IAAY,KAAc;EACtD,MAAM,QAAQ,MAAM,MAAM,QAAQ,MAAM,MAAM,EAAE,OAAO,MAAM;EAC7D,IAAI,CAAC,OAAO;EACZ,MAAM,MAAM,aAAa,KAAK;EAC9B,MAAM,OAAO,MAAM,CAAC,GAAG,IAAI,QAAQ,MAAM,MAAM,EAAE,GAAG,EAAE,IAAI,IAAI,QAAQ,MAAM,MAAM,EAAE;EACpF,MAAM,QAAQ,EAAE,UAAU,KAAK,SAAS,OAAO,KAAA,EAAU;EACzD,IAAI,CAAC;GAAE,MAAM;GAAU,IAAI;GAAQ;EAAM,CAAC,GAAG;GAAE,SAAS;GAAO,QAAQ;EAAU,CAAC;EAClF,MAAM,QAAQ,KAAK;CACrB;CAEA,SAAS,MAAM,QAAuB;EACpC,IAAI,OAAO,SAAS,UAAU;GAC5B,MAAM,SAAS,QAAQ;GACvB,IAAI,CAAC,QAAQ;GACb,KAAK,QAAQ,OAAO,OAAO,IAAI,IAAI;GACnC,QAAQ,QAAQ;EAClB,OAAO,IAAI,OAAO,SAAS,UAAU;GACnC,MAAM,SAAS,SAAS,OAAO,QAAQ;GACvC,IAAI,QAAQ,KAAK,QAAQ,OAAO,UAAU,KAAK;EACjD;EACA,KAAK,mBAAmB,mBAAmB,QAAQ,OAAO,MAAM,CAAC;EACjE,KAAK,iBAAiB,MAAM;EAC5B,IAAI,OAAO,SAAS,UAAU;GAC5B,KAAK,OAAO,OAAO,EAAE;GACrB,aAAa;EACf,OAAO,IAAI,OAAO,SAAS,YAAY,OAAO,SAAS,WAAW,KAAK,IAAI;CAC7E;CAEA,SAAS,UAAyD;EAChE,OAAO,OAAO,YACZ,QAAQ,MAAM,KAAK,WAAW;GAC5B,MAAM,SAAS,SAAS,OAAO,EAAE;GACjC,OAAO,CAAC,OAAO,IAAI,SAAS,EAAE,OAAO,IAAI,IAAI;EAC/C,CAAC,CACH;CACF;CAEA,OAAO;EACL;EACA;EACA;EACA,QAAQ,eAAe,MAAM,MAAM;EACnC;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;CACF;AACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dashboard.js","names":["_sfc_main"],"sources":["../../src/dashboard/dashboard.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { LayoutGridPlusIcon, fileIconOf } from '@dou-ui/icons'\nimport type { SheetExporter, SheetWorkbook } from '@dou-ui/sheet/core'\nimport {\n DouDialog,\n DouDrawer,\n DouEmpty,\n DouInput,\n downloadBlob,\n matchesHotkey,\n useLocale,\n} from 'dou-ui'\nimport {\n computed,\n nextTick,\n onBeforeUnmount,\n onMounted,\n provide,\n ref,\n shallowReactive,\n shallowRef,\n toRaw,\n watch,\n} from 'vue'\nimport { emptyDocument } from '../core/document'\nimport { DashboardHistory } from '../core/history'\nimport {\n applySteps,\n cloneJson,\n DashboardTransaction,\n replaceWidgets,\n sameValue,\n} from '../core/transaction'\nimport type { DashboardStep, DashboardTransactionMeta } from '../core/transaction'\nimport type { DashboardDocument, DashboardPlace, DashboardWidget } from '../core/types'\nimport type {\n DashboardPeer,\n DashboardPresence,\n DashboardPresenceAction,\n} from '../core/collaboration'\nimport { dataContextKey } from '../data/context'\nimport { filterText, isEmptyFilter } from '../data/filters'\nimport { createSourceStore, sameSource } from '../data/store'\nimport type {\n DashboardFilters,\n DashboardLinkedFilter,\n DashboardSetFilterOptions,\n} from '../data/types'\nimport GridLayout from '../grid/grid-layout.vue'\nimport { DEFAULT_BREAKPOINTS, DEFAULT_COLUMNS, gridTiers } from '../grid/tiers'\nimport type { GridDragEvent, GridDropEvent, GridLayoutExpose, GridLayouts } from '../grid/types'\nimport { findPlace, fitItem, moveItem } from '../layout/engine'\nimport type { GridItem, GridLayoutOptions } from '../layout/types'\nimport { format, useDashboardMessages } from '../locale'\nimport { DEFAULT_WIDGET_SIZE, defaultPropsOf } from '../widget/define'\nimport type { DashboardWidgetType } from '../widget/define'\nimport type {\n DashboardAddOptions,\n DashboardEmits,\n DashboardExportErrorEvent,\n DashboardExportOptions,\n DashboardExpose,\n DashboardSaveOptions,\n DashboardProps,\n} from './types'\nimport { createObservers, observersKey } from './observers'\nimport { elementToPNG } from './export'\nimport type { DashboardImageOptions } from './export'\nimport WidgetBody from './widget-body.vue'\nimport WidgetFrame from './widget-frame.vue'\nimport WidgetSettings from './widget-settings.vue'\nimport { frameContextKey } from './frame-context'\nimport type { FrameExporter, WidgetAction } from './frame-context'\n\ndefineOptions({ name: 'DouDashboard' })\n\nconst props = withDefaults(defineProps<Omit<DashboardProps, 'modelValue' | 'filters'>>(), {\n widgets: () => [],\n columns: () => DEFAULT_COLUMNS,\n breakpoints: () => DEFAULT_BREAKPOINTS,\n rowHeight: 80,\n gap: 16,\n compact: 'vertical',\n})\nconst emit = defineEmits<Omit<DashboardEmits, 'update:modelValue' | 'update:filters'>>()\nconst model = defineModel<DashboardDocument>({ default: emptyDocument })\nconst filters = defineModel<DashboardFilters>('filters', { default: () => ({}) })\nconst messages = useDashboardMessages(() => props.messages)\n\nconst grid = ref<GridLayoutExpose>()\nconst observers = createObservers()\nprovide(observersKey, observers)\nonBeforeUnmount(() => observers.dispose())\n/** This many widgets are drawn in full at once, the rest when they come near the view. */\nconst EAGER = 16\nconst root = ref<HTMLElement>()\n\nconst typeMap = computed(() => new Map(props.widgets.map((type) => [type.type, type])))\nconst typeOf = (widget: DashboardWidget) => typeMap.value.get(widget.type)\nconst widgetMap = computed(() => new Map(model.value.widgets.map((widget) => [widget.id, widget])))\n/** The first widgets of the document, drawn in full without waiting to be seen. */\nconst eagerIds = computed(() => new Set(model.value.widgets.slice(0, EAGER).map((w) => w.id)))\n/** Widgets added once the dashboard is up are drawn in full straight away (not reactive on purpose). */\nlet mounted = false\nonMounted(() => (mounted = true))\nconst isEager = (id: string) => mounted || eagerIds.value.has(id)\nconst titleOf = (widget: DashboardWidget) => widget.title || typeOf(widget)?.label || widget.type\n\nconst tiers = computed(() => gridTiers(props.columns, props.breakpoints))\n/** The widest breakpoint: its places are the main ones. */\nconst main = computed(() => tiers.value[0]!)\nconst mainOptions = computed<GridLayoutOptions>(() => ({\n columns: Math.max(1, Math.round(main.value.columns)),\n compact: props.compact,\n}))\n\n// ---------------------------------------------------------------------------\n// Changes and history\n\nconst history = new DashboardHistory()\n/** Bumped whenever the history changes, so `canUndo` and `canRedo` update. */\nconst historyVersion = ref(0)\n/** The document last emitted, to tell our own changes from the app's. */\nlet emitted: DashboardDocument | undefined\nwatch(model, (doc) => {\n // the app may hand back a reactive copy of what was emitted\n // a document the app put in itself: the old steps no longer fit it\n if (toRaw(doc) === emitted) return\n history.clear()\n historyVersion.value++\n})\n\nfunction commit(tr: DashboardTransaction) {\n emitted = toRaw(tr.doc)\n model.value = tr.doc\n emit('change', tr)\n}\n\n/** A transaction on the document as it is now, to `dispatch`. */\nfunction transaction() {\n return new DashboardTransaction(model.value)\n}\n\n/** Makes the changes of a transaction, as one undo step (or part of one, by `meta.group`). */\nfunction dispatch(tr: DashboardTransaction) {\n if (toRaw(tr.before) !== toRaw(model.value))\n throw new Error(\n '[@dou-ui/dashboard] the transaction was made on another version of the dashboard',\n )\n if (!tr.changed) return\n const shared = props.collaboration\n if (!shared) {\n history.record(tr)\n historyVersion.value++\n }\n commit(tr)\n shared?.transaction(tr)\n}\n\n/** Steps made into a transaction and dispatched, for the dashboard's own changes. */\nfunction run(steps: DashboardStep[], meta: DashboardTransactionMeta) {\n const tr = applySteps(model.value, steps, meta)\n dispatch(tr)\n return tr\n}\n\nfunction undo() {\n if (props.collaboration) return props.collaboration.undo()\n const tr = history.undo(model.value)\n historyVersion.value++\n if (!tr) return false\n if (tr.changed) commit(tr)\n return true\n}\n\nfunction redo() {\n if (props.collaboration) return props.collaboration.redo()\n const tr = history.redo(model.value)\n historyVersion.value++\n if (!tr) return false\n if (tr.changed) commit(tr)\n return true\n}\n\nconst canUndo = computed(() =>\n props.collaboration ? props.collaboration.canUndo() : (historyVersion.value, history.canUndo),\n)\nconst canRedo = computed(() =>\n props.collaboration ? props.collaboration.canRedo() : (historyVersion.value, history.canRedo),\n)\n\n// ---------------------------------------------------------------------------\n// The grid: widgets as items, places by breakpoint\n\nfunction limitsOf(type: DashboardWidgetType<any> | undefined) {\n const size = type?.size ?? DEFAULT_WIDGET_SIZE\n return {\n w: size.w,\n h: size.h,\n minW: size.minW,\n maxW: size.maxW,\n minH: size.minH,\n maxH: size.maxH,\n }\n}\n\n/** The widgets on the widest breakpoint; those without a place there get the first spot they fit. */\nconst placeOf = (item: DashboardPlace): DashboardPlace => ({\n x: item.x,\n y: item.y,\n w: item.w,\n h: item.h,\n})\n\n// The item of a widget that didn't change is the same object as before, so\n// the grid and its other items have nothing to draw again.\nlet itemCache = new WeakMap<DashboardWidget, GridItem>()\nwatch([typeMap, main], () => (itemCache = new WeakMap()), { flush: 'sync' })\nconst items = computed<GridItem[]>(() => {\n const name = main.value.name\n const options = mainOptions.value\n const placed: GridItem[] = []\n const result = new Map<string, GridItem>()\n const missing: DashboardWidget[] = []\n for (const widget of model.value.widgets) {\n const raw = toRaw(widget)\n const place = raw.place[name]\n if (!place) {\n missing.push(raw)\n continue\n }\n let item = itemCache.get(raw)\n if (!item) {\n const { minW, maxW, minH, maxH } = limitsOf(typeOf(raw))\n item = { id: raw.id, ...placeOf(place), minW, maxW, minH, maxH }\n itemCache.set(raw, item)\n }\n placed.push(item)\n result.set(raw.id, item)\n }\n for (const widget of missing) {\n const fitted = fitItem(\n { id: widget.id, x: 0, y: 0, ...limitsOf(typeOf(widget)) },\n options.columns,\n )\n const item = { ...fitted, ...findPlace(placed, fitted, options) }\n placed.push(item)\n result.set(widget.id, item)\n }\n return model.value.widgets.map((widget) => result.get(widget.id)!)\n})\n\n/** The places on the narrower breakpoints. */\nconst layouts = computed<GridLayouts>(() => {\n const result: GridLayouts = {}\n for (const tier of tiers.value.slice(1)) {\n const list = []\n for (const widget of model.value.widgets) {\n const place = widget.place[tier.name]\n if (place) list.push({ id: widget.id, ...place })\n }\n if (list.length) result[tier.name] = list\n }\n return result\n})\n\n// The grid tells a move with `update:modelValue`, `update:layouts` and\n// `drop` one after another; they are gathered into one change.\nlet pending: { items?: GridItem[]; layouts?: GridLayouts; drop?: { id: string; type: string } } = {}\nlet flushing = false\nfunction gather(part: typeof pending) {\n Object.assign(pending, part)\n if (flushing) return\n flushing = true\n queueMicrotask(flush)\n}\n\nfunction flush() {\n flushing = false\n const { items: list, layouts: saved, drop } = pending\n pending = {}\n const doc = model.value\n const places = new Map<string, DashboardWidget['place']>()\n const setPlace = (id: string, name: string, item: DashboardPlace) => {\n let place = places.get(id)\n if (!place) places.set(id, (place = { ...widgetMap.value.get(id)?.place }))\n place[name] = placeOf(item)\n }\n for (const item of list ?? []) setPlace(item.id, main.value.name, item)\n for (const [name, others] of Object.entries(saved ?? {}))\n for (const item of others ?? []) setPlace(item.id, name, item)\n const steps: DashboardStep[] = []\n for (const [id, place] of places) {\n if (widgetMap.value.has(id)) {\n steps.push({ type: 'update', id, patch: { place } })\n } else if (drop?.id === id) {\n const type = typeMap.value.get(drop.type)\n if (type)\n steps.push({ type: 'add', widget: newWidget(id, type, place), index: doc.widgets.length })\n }\n }\n const tr = run(steps, { origin: drop ? 'drop' : 'layout' })\n if (drop && tr.changed) {\n const widget = widgetMap.value.get(drop.id)\n if (widget) say(format(messages.value.added, { item: titleOf(widget) }))\n }\n}\n\nfunction onDrop(event: GridDropEvent) {\n const data = event.data as { type?: string } | undefined\n if (data?.type) gather({ drop: { id: event.item.id, type: data.type } })\n}\n\nconst dragging = ref(false)\n\n// ---------------------------------------------------------------------------\n// Adding, copying and removing\n\nfunction newId(type: string) {\n const used = widgetMap.value\n for (;;) {\n const id = props.createId?.(type) ?? `${type}-${Math.random().toString(36).slice(2, 8)}`\n if (!used.has(id)) return id\n if (props.createId)\n throw new Error(`[@dou-ui/dashboard] createId gave \"${id}\", which is already in use`)\n }\n}\n\nfunction newWidget(\n id: string,\n type: DashboardWidgetType<any>,\n place: DashboardWidget['place'],\n options: Pick<DashboardAddOptions, 'title' | 'props'> = {},\n): DashboardWidget {\n const widget: DashboardWidget = { id, type: type.type, place }\n if (options.title) widget.title = options.title\n const initial = defaultPropsOf(type)\n if (initial || options.props) widget.props = { ...initial, ...options.props }\n return widget\n}\n\n/**\n * Puts a new item on the widest breakpoint, at `at` (pushing the others\n * away) or in the first spot it fits; the steps also move any widget it\n * pushed.\n */\nfunction placeNew(item: GridItem, at: Pick<DashboardPlace, 'x' | 'y'> | undefined) {\n const options = mainOptions.value\n const fitted = fitItem(item, options.columns)\n const spot = at ?? findPlace(items.value, fitted, options)\n const layout = moveItem([...items.value, { ...fitted, ...spot }], item.id, spot, options)\n const steps: DashboardStep[] = []\n let place: DashboardPlace = { ...fitted, ...spot }\n for (const next of layout) {\n if (next.id === item.id) {\n place = placeOf(next)\n continue\n }\n const widget = widgetMap.value.get(next.id)\n const old = widget && items.value.find((other) => other.id === next.id)\n if (widget && old && (old.x !== next.x || old.y !== next.y))\n steps.push({\n type: 'update',\n id: next.id,\n patch: { place: { ...widget.place, [main.value.name]: placeOf(next) } },\n })\n }\n return { place, steps }\n}\n\nfunction addWidget(typeName: string, options: DashboardAddOptions = {}) {\n const type = typeMap.value.get(typeName)\n if (!type) return undefined\n const id = newId(typeName)\n const { place, steps } = placeNew({ id, x: 0, y: 0, ...limitsOf(type) }, options.at)\n const widget = newWidget(id, type, { [main.value.name]: place }, options)\n run([{ type: 'add', widget, index: model.value.widgets.length }, ...steps], {\n origin: 'api',\n })\n say(format(messages.value.added, { item: titleOf(widget) }))\n if (options.focus !== false) void nextTick(() => focusWidget(id))\n return id\n}\n\n/** Adds a copy of a widget (from this dashboard or the clipboard) beside `near`, or in the first free spot. */\nfunction addCopy(source: DashboardWidget, near?: GridItem) {\n const type = typeOf(source)\n const id = newId(source.type)\n const columns = mainOptions.value.columns\n const size = near ?? { ...limitsOf(type), ...source.place[main.value.name] }\n const at = near\n ? near.x + near.w * 2 <= columns\n ? { x: near.x + near.w, y: near.y }\n : { x: near.x, y: near.y + near.h }\n : undefined\n const { place, steps } = placeNew({ ...limitsOf(type), id, x: 0, y: 0, w: size.w, h: size.h }, at)\n const widget: DashboardWidget = {\n ...cloneJson(source),\n id,\n place: { [main.value.name]: place },\n }\n const index = near\n ? model.value.widgets.findIndex((other) => other.id === near.id) + 1\n : model.value.widgets.length\n run([{ type: 'add', widget, index }, ...steps], { origin: 'copy' })\n say(format(messages.value.added, { item: titleOf(widget) }))\n void nextTick(() => focusWidget(id))\n return id\n}\n\nfunction duplicateWidget(id: string) {\n const widget = widgetMap.value.get(id)\n const item = items.value.find((other) => other.id === id)\n return widget && item ? addCopy(widget, item) : undefined\n}\n\nfunction removeWidget(id: string) {\n const list = model.value.widgets\n const index = list.findIndex((widget) => widget.id === id)\n if (index < 0) return\n const widget = list[index]!\n const hadFocus = root.value?.contains(document.activeElement)\n const neighbour = list[index + 1] ?? list[index - 1]\n if (settingsId.value === id) settingsId.value = undefined\n if (fullscreenId.value === id) fullscreenId.value = undefined\n run([{ type: 'remove', id }], { origin: 'api' })\n say(format(messages.value.removed, { item: titleOf(widget) }))\n if (hadFocus) void nextTick(() => (neighbour ? focusWidget(neighbour.id) : root.value?.focus()))\n}\n\nfunction focusWidget(id: string) {\n grid.value?.focusItem(id)\n}\n\n/** The widget being dragged in, which has no widget yet: its name is its type's. */\nlet adding: { id: string; label: string } | undefined\n\nfunction startAdd(event: PointerEvent, typeName: string) {\n const type = typeMap.value.get(typeName)\n if (!type || !props.editable) return\n adding = { id: newId(typeName), label: type.label }\n grid.value?.startDrop(event, { id: adding.id, ...limitsOf(type) }, { type: typeName })\n}\n\nfunction labelOf(item: GridItem) {\n const widget = widgetMap.value.get(item.id)\n return widget ? titleOf(widget) : item.id === adding?.id ? adding.label : item.id\n}\n\nfunction setProps(id: string, patch: Record<string, unknown>, options: { group?: string } = {}) {\n const widget = widgetMap.value.get(id)\n if (!widget) return\n run([{ type: 'update', id, patch: { props: { ...widget.props, ...patch } } }], {\n origin: 'widget',\n group: options.group,\n })\n}\n\nfunction rename(id: string, title: string) {\n run([{ type: 'update', id, patch: { title: title.trim() || undefined } }], {\n origin: 'title',\n })\n}\n\n// ---------------------------------------------------------------------------\n// Settings and full screen\n\nconst settingsId = ref<string>()\nconst settingsWidget = computed(() =>\n settingsId.value ? widgetMap.value.get(settingsId.value) : undefined,\n)\nconst fullscreenId = ref<string>()\nconst fullscreenWidget = computed(() =>\n fullscreenId.value ? widgetMap.value.get(fullscreenId.value) : undefined,\n)\n/** The widget to give focus back to when a drawer or dialog closes. */\nlet returnTo: string | undefined\n\nfunction openSettings(id: string) {\n if (!props.editable || !widgetMap.value.has(id)) return\n returnTo = id\n settingsId.value = id\n}\n\nfunction openFullscreen(id: string) {\n const widget = widgetMap.value.get(id)\n if (!widget || typeOf(widget)?.fullscreen === false) return\n returnTo = id\n fullscreenId.value = id\n}\n\nfunction restoreFocus() {\n const id = returnTo\n returnTo = undefined\n if (id && widgetMap.value.has(id)) focusWidget(id)\n}\n\nwatch(\n () => props.editable,\n (editable) => {\n if (!editable) settingsId.value = undefined\n },\n)\n\nfunction onAction(id: string, action: WidgetAction) {\n if (action === 'settings') openSettings(id)\n else if (action === 'fullscreen') openFullscreen(id)\n else if (action === 'duplicate') duplicateWidget(id)\n else removeWidget(id)\n}\n\n// ---------------------------------------------------------------------------\n// Editing together\n\n/** Changes from others wait while a widget is dragged here; then the shared document is read again. */\nlet held = false\n\nfunction applyShared(doc: DashboardDocument, origin: 'remote' | 'undo' | 'redo') {\n if (dragging.value) {\n held = true\n return\n }\n const tr = replaceWidgets(new DashboardTransaction(model.value), doc)\n if (!tr.changed) return\n tr.setMeta('history', false).setMeta('origin', origin)\n const ids = new Set(tr.doc.widgets.map((widget) => widget.id))\n if (settingsId.value && !ids.has(settingsId.value)) settingsId.value = undefined\n if (fullscreenId.value && !ids.has(fullscreenId.value)) fullscreenId.value = undefined\n commit(tr)\n}\n\n// one written in the template is new on every render: each one starts over and clears undo\nlet setupTimes: number[] = []\nlet setupWarned = false\nfunction warnRepeatedSetup() {\n if (process.env.NODE_ENV === 'production' || setupWarned) return\n const now = Date.now()\n setupTimes = [...setupTimes.filter((time) => now - time < 1000), now]\n if (setupTimes.length < 4) return\n setupWarned = true\n console.warn(\n '[@dou-ui/dashboard] collaboration keeps being replaced, which starts it over and clears undo each time. Create it once in <script setup> instead of in the template.',\n )\n}\n\nwatch(\n () => props.collaboration,\n (shared, _, onCleanup) => {\n if (shared) warnRepeatedSetup()\n history.clear()\n historyVersion.value++\n if (!shared) return\n const stop = shared.setup({ document: () => model.value, apply: applyShared })\n onCleanup(() => {\n shared.presence?.(null)\n stop()\n })\n },\n { immediate: true },\n)\n\n/** The widget being moved or resized here, and how. */\nconst drag = shallowRef<GridDragEvent>()\nfunction onDragStart(event: GridDragEvent) {\n dragging.value = true\n drag.value = event\n}\nfunction onDragEnd() {\n dragging.value = false\n drag.value = undefined\n if (!held) return\n held = false\n // after the drop's own change, which the grid hands over in a microtask\n queueMicrotask(() => queueMicrotask(() => props.collaboration?.sync()))\n}\n\n/** The widget whose item, or a control in it, has focus. */\nconst focused = ref<string>()\nfunction onFocusIn(event: FocusEvent) {\n const item = (event.target as HTMLElement).closest?.<HTMLElement>('[data-grid-id]')\n focused.value = item?.closest('.dou-dashboard') === root.value ? item?.dataset.gridId : undefined\n}\nfunction onFocusOut(event: FocusEvent) {\n if (!root.value?.contains(event.relatedTarget as Node | null)) focused.value = undefined\n}\n\n/** What this person is doing, shown to the others. */\nconst presence = computed<DashboardPresence | null>(() => {\n const moving = drag.value\n if (moving && moving.kind !== 'drop' && widgetMap.value.has(moving.id))\n return { widget: moving.id, action: moving.kind as DashboardPresenceAction }\n if (settingsId.value) return { widget: settingsId.value, action: 'settings' }\n if (focused.value && widgetMap.value.has(focused.value))\n return { widget: focused.value, action: 'focus' }\n return null\n})\nwatch(\n [presence, () => props.collaboration] as const,\n ([current, shared], previous) => {\n if (!shared?.presence) return\n const [before, sharedBefore] = previous ?? []\n if (\n sharedBefore === shared &&\n before?.widget === current?.widget &&\n before?.action === current?.action\n )\n return\n shared.presence(current)\n },\n { immediate: true },\n)\nconst NO_PEERS: readonly DashboardPeer[] = []\n\nprovide(frameContextKey, {\n widget: (id) => widgetMap.value.get(id),\n type: typeOf,\n title: titleOf,\n editing: () => props.editable,\n messages: () => messages.value,\n eager: isEager,\n all: () => showAll.value,\n setProps,\n action: onAction,\n rename,\n linked: (id) => linksByWidget.value.get(id) ?? NO_LINKS,\n clearFilter: (key) => setFilter(undefined, key, undefined),\n peers: (id) => props.collaboration?.peers?.(id) ?? NO_PEERS,\n setWorkbook,\n exporters: (id) => (workbooks.has(id) ? exporterList.value : NO_EXPORTERS),\n exportTo: exportFromMenu,\n exporting: (id) => exporting.has(id),\n setDrawn: (id, drawn) => (drawn ? drawnIds.add(id) : drawnIds.delete(id)),\n exportError: (id) => exportErrors.get(id),\n clearExportError: (id) => exportErrors.delete(id),\n})\n\n// ---------------------------------------------------------------------------\n// Exporting a widget's content as a file\n\n/** How each mounted widget gives its content as a workbook; see `setWorkbook`. */\nconst workbooks = shallowReactive(new Map<string, () => SheetWorkbook | undefined>())\nfunction setWorkbook(id: string, get: () => SheetWorkbook | undefined) {\n workbooks.set(id, get)\n return () => {\n if (workbooks.get(id) === get) workbooks.delete(id)\n }\n}\n\n// the file code comes with the first exporter, in a chunk of its own\ntype SheetCore = typeof import('@dou-ui/sheet/core')\nconst sheetCore = shallowRef<SheetCore>()\nlet loadingCore: Promise<SheetCore> | undefined\nfunction loadSheetCore() {\n loadingCore ??= import('@dou-ui/sheet/core').then((core) => (sheetCore.value = core))\n return loadingCore\n}\nwatch(\n () => props.exporters?.length,\n (count) => count && void loadSheetCore(),\n { immediate: true },\n)\n\nconst NO_EXPORTERS: FrameExporter[] = []\nconst { locale } = useLocale()\n/** The exporters of the menu, without those that draw a sheet's grid. */\nconst exporterList = computed<FrameExporter[]>(() => {\n const core = sheetCore.value\n if (!core) return NO_EXPORTERS\n const sheetMessages = core.sheetMessagesFor(locale.value.name)\n return (props.exporters ?? [])\n .filter((exporter) => exporter.needs !== 'view')\n .map((exporter) => ({\n name: exporter.name,\n label: typeof exporter.label === 'function' ? exporter.label(sheetMessages) : exporter.label,\n icon: exporter.icon ?? fileIconOf(exporter.extension),\n }))\n})\n\nfunction exporterOf(exporter: string | SheetExporter) {\n if (typeof exporter !== 'string') return exporter\n const found = props.exporters?.find((item) => item.name === exporter)\n if (!found) throw new RangeError(`[@dou-ui/dashboard] there is no exporter \"${exporter}\"`)\n return found\n}\n\n/** A widget's file and sheet name: its title, or its type's name. */\nconst exportNameOf = (widget: DashboardWidget) => titleOf(widget).trim() || widget.type\n\n/** Why something failed, as people read it: a file error's `reason`, or its message. */\nfunction reasonOf(error: unknown) {\n if (error instanceof Error)\n return 'reason' in error && typeof error.reason === 'string' ? error.reason : error.message\n return String(error)\n}\n\n/**\n * A widget's content as a file: its workbook written by the exporter, a\n * name from `exporters` or an exporter itself. Waits for the data shown,\n * and draws the widget first when it is far from the view.\n */\nasync function exportWidget(\n id: string,\n exporter: string | SheetExporter,\n options: DashboardExportOptions = {},\n): Promise<Blob> {\n const chosen = exporterOf(exporter)\n const widget = widgetMap.value.get(id)\n if (!widget) throw new RangeError(`[@dou-ui/dashboard] there is no widget \"${id}\"`)\n const core = await loadSheetCore()\n // a widget drawn already that gives no workbook never will: no need to draw them all\n await settle(5000, !drawnIds.has(id))\n options.signal?.throwIfAborted()\n const get = workbooks.get(id)\n const book = get?.()\n if (!book)\n throw new core.SheetFileError(\n get\n ? `[@dou-ui/dashboard] the widget \"${id}\" has no data to export now`\n : `[@dou-ui/dashboard] the widget \"${id}\" doesn't give its content as a workbook (setWorkbook)`,\n 'empty',\n messages.value.nothingToExport,\n )\n // sheets without a name take the widget's, as the file does\n const name = exportNameOf(widget)\n const named = book.sheets.some((sheet) => !sheet.name)\n ? { ...book, sheets: book.sheets.map((sheet) => (sheet.name ? sheet : { ...sheet, name })) }\n : book\n return core.exportWorkbook(named, chosen, {\n options: options.options,\n signal: options.signal,\n locale: locale.value.name,\n })\n}\n\n/** The widgets drawn in full; the others show only their title until they come near. */\nconst drawnIds = new Set<string>()\n\n/** Why the last export from each widget's menu failed, shown over its content until closed. */\nconst exportErrors = shallowReactive(new Map<string, string>())\n/** The widgets exporting now, whose export button waits. */\nconst exporting = shallowReactive(new Set<string>())\n\n/**\n * Exports a widget's content and saves the file in the downloads, as its\n * menu does. A failure shows over the widget and comes as `exportError`,\n * and the promise rejects.\n */\nasync function saveWidget(\n id: string,\n exporter: string | SheetExporter,\n options: DashboardSaveOptions = {},\n): Promise<void> {\n const chosen = exporterOf(exporter)\n const widget = widgetMap.value.get(id)\n if (!widget) throw new RangeError(`[@dou-ui/dashboard] there is no widget \"${id}\"`)\n const name = options.fileName ?? `${exportNameOf(widget)}.${chosen.extension}`\n exportErrors.delete(id)\n exporting.add(id)\n let blob: Blob\n try {\n blob = await exportWidget(id, chosen, options)\n } catch (error) {\n const event: DashboardExportErrorEvent = {\n widget: id,\n exporter: chosen.name,\n error,\n message: format(messages.value.exportFailed, { name, reason: reasonOf(error) }),\n defaultPrevented: false,\n preventDefault() {\n this.defaultPrevented = true\n },\n }\n emit('exportError', event)\n // the notice is read out itself; told by the app, it is only read out\n if (event.defaultPrevented) say(event.message)\n else exportErrors.set(id, event.message)\n throw error\n } finally {\n exporting.delete(id)\n }\n downloadBlob(blob, name)\n say(format(messages.value.exported, { name }))\n}\n\nfunction exportFromMenu(id: string, name: string) {\n if (exporting.has(id)) return\n // the failure is shown over the widget\n saveWidget(id, name).catch(() => {})\n}\n\n// ---------------------------------------------------------------------------\n// Data and filters\n\nconst store = createSourceStore(() => props.sources)\nonBeforeUnmount(() => store.dispose())\n// a source replaced by one that loads differently loads again\nwatch(\n () => props.sources,\n (next, previous) => {\n const names = new Set([...Object.keys(previous ?? {}), ...Object.keys(next ?? {})])\n for (const name of names) if (!sameSource(previous?.[name], next?.[name])) store.reload(name)\n },\n)\n\n/** The filters set by widgets, by key: they apply to every widget but the one that set them. */\nconst links = shallowRef(new Map<string, DashboardLinkedFilter>())\nconst EMPTY_SET: ReadonlySet<string> = new Set()\nconst NO_LINKS: DashboardLinkedFilter[] = []\nconst linksByWidget = computed(() => {\n const result = new Map<string, DashboardLinkedFilter[]>()\n for (const link of links.value.values()) {\n const list = result.get(link.widget)\n if (list) list.push(link)\n else result.set(link.widget, [link])\n }\n return result\n})\nconst emittedByWidget = computed(() => {\n const result = new Map<string, Set<string>>()\n for (const [id, list] of linksByWidget.value)\n result.set(id, new Set(list.map((link) => link.key)))\n return result\n})\n\n// The values as set last: with `v-model:filters` the model only updates once\n// the parent draws again, so several filters set at once build on this.\nconst values = shallowRef<DashboardFilters>(filters.value)\nwatch(filters, (next) => (values.value = next))\n\nfunction setFilter(\n widget: string | undefined,\n key: string,\n value: unknown,\n options: DashboardSetFilterOptions = {},\n) {\n const next = { ...values.value }\n const nextLinks = new Map(links.value)\n if (isEmptyFilter(value)) delete next[key]\n else next[key] = value\n if (widget && !isEmptyFilter(value) && widgetMap.value.has(widget))\n nextLinks.set(key, { key, value, label: options.label ?? filterText(value), widget })\n else nextLinks.delete(key)\n links.value = nextLinks\n values.value = next\n filters.value = next\n}\n\nfunction clearFilters() {\n links.value = new Map()\n values.value = {}\n filters.value = {}\n}\n\n// a filter changed from outside (the filter bar, the app) is no longer the widget's\nwatch(filters, (current) => {\n let next: Map<string, DashboardLinkedFilter> | undefined\n for (const [key, link] of links.value) {\n if (sameValue(current[key], link.value)) continue\n next ??= new Map(links.value)\n next.delete(key)\n }\n if (next) links.value = next\n})\n\n// the filters of a widget that is removed go with it\nwatch(widgetMap, (map) => {\n for (const link of links.value.values())\n if (!map.has(link.widget)) setFilter(undefined, link.key, undefined)\n})\n\nconst sourceNames = computed(() => Object.keys(props.sources ?? {}))\nprovide(dataContextKey, {\n store,\n sourceNames: () => sourceNames.value,\n filters: () => values.value,\n emitted: (id) => emittedByWidget.value.get(id) ?? EMPTY_SET,\n linked: (id) => linksByWidget.value.get(id) ?? NO_LINKS,\n setFilter,\n})\n\n// ---------------------------------------------------------------------------\n// Keyboard and clipboard\n\nconst CLIPBOARD_KIND = 'dou-dashboard/widget'\n\nfunction isTyping(target: EventTarget | null) {\n const el = target as HTMLElement | null\n if (!el?.tagName) return false\n return (\n el.isContentEditable ||\n el.tagName === 'TEXTAREA' ||\n el.tagName === 'SELECT' ||\n (el.tagName === 'INPUT' &&\n !['checkbox', 'radio', 'button', 'range', 'color'].includes((el as HTMLInputElement).type))\n )\n}\n\n/** The widget whose item itself has focus (not a control inside it). */\nfunction focusedId(target: EventTarget | null) {\n const el = target as HTMLElement | null\n return el?.dataset?.gridId && el.closest('.dou-dashboard') === root.value\n ? el.dataset.gridId\n : undefined\n}\n\nfunction onKeydown(event: KeyboardEvent) {\n if (!props.editable || event.defaultPrevented || isTyping(event.target)) return\n if (matchesHotkey(event, 'mod+z')) {\n event.preventDefault()\n undo()\n return\n }\n if (matchesHotkey(event, 'mod+shift+z') || matchesHotkey(event, 'mod+y')) {\n event.preventDefault()\n redo()\n return\n }\n const id = focusedId(event.target)\n if (!id || dragging.value) return\n if (event.key === 'Delete' || event.key === 'Backspace') {\n event.preventDefault()\n removeWidget(id)\n } else if (matchesHotkey(event, 'mod+d')) {\n event.preventDefault()\n duplicateWidget(id)\n }\n}\n\nfunction onCopy(event: ClipboardEvent, cut = false) {\n if (isTyping(event.target)) return\n const id = focusedId(event.target)\n const widget = id && widgetMap.value.get(id)\n if (!widget || !event.clipboardData) return\n event.clipboardData.setData('text/plain', JSON.stringify({ kind: CLIPBOARD_KIND, widget }))\n event.preventDefault()\n if (cut && props.editable) removeWidget(widget.id)\n}\n\nfunction onPaste(event: ClipboardEvent) {\n if (!props.editable || isTyping(event.target)) return\n let data: { kind?: string; widget?: DashboardWidget } | undefined\n try {\n data = JSON.parse(event.clipboardData?.getData('text/plain') ?? '')\n } catch {\n return\n }\n const widget = data?.kind === CLIPBOARD_KIND ? data.widget : undefined\n if (!widget?.type || !typeMap.value.has(widget.type)) return\n event.preventDefault()\n const id = focusedId(event.target)\n addCopy(widget, id ? items.value.find((item) => item.id === id) : undefined)\n}\n\n// ---------------------------------------------------------------------------\n// Export and print\n\n/** Every widget drawn in full, not only those near the view. */\nconst showAll = ref(false)\nconst nextFrame = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))\n\n/** Draws every widget (unless `all` is false) and waits for their sources (at most `timeout` ms) and one more frame. */\nasync function settle(timeout = 5000, all = true) {\n if (all) showAll.value = true\n await nextTick()\n const start = performance.now()\n await nextFrame()\n while (store.busy() && performance.now() - start < timeout) await nextFrame()\n await nextFrame()\n await nextFrame()\n}\n\n/** The dashboard as it is shown, every widget drawn, as a PNG image. */\nasync function exportPNG(options?: DashboardImageOptions) {\n await settle()\n const el = root.value!\n // the controls are left out; the copy is made before the class goes\n el.classList.add('is-exporting')\n try {\n return elementToPNG(el, options)\n } finally {\n el.classList.remove('is-exporting')\n }\n}\n\n/** Draws every widget, waits for their data, and opens the browser's print dialog. */\nasync function print() {\n await settle()\n window.print()\n}\n\n// printing from the browser's menu: draw what is far from the view, as far as there is time\nfunction onBeforePrint() {\n showAll.value = true\n}\nonMounted(() => window.addEventListener('beforeprint', onBeforePrint))\nonBeforeUnmount(() => window.removeEventListener('beforeprint', onBeforePrint))\n\n// ---------------------------------------------------------------------------\n// Announcements\n\nconst live = ref('')\nfunction say(text: string) {\n live.value = ''\n requestAnimationFrame(() => (live.value = text))\n}\n\nconst breakpointName = shallowRef(main.value.name)\nfunction onBreakpoint(name: string) {\n breakpointName.value = name\n emit('breakpointChange', name)\n}\n\nconst minHeight = computed(() => {\n const gap = Array.isArray(props.gap) ? props.gap[1] : props.gap\n return `${props.rowHeight * 3 + gap * 2}px`\n})\n\ndefineExpose<DashboardExpose>({\n transaction,\n dispatch,\n undo,\n redo,\n get canUndo() {\n return canUndo.value\n },\n get canRedo() {\n return canRedo.value\n },\n addWidget,\n removeWidget,\n duplicateWidget,\n focusWidget,\n openSettings,\n openFullscreen,\n startAdd,\n get types() {\n return props.widgets\n },\n get breakpoint() {\n return grid.value?.breakpoint ?? breakpointName.value\n },\n get editable() {\n return props.editable\n },\n get document() {\n return model.value\n },\n get filters() {\n return values.value\n },\n get linkedFilters() {\n return [...links.value.values()]\n },\n setFilter: (key, value, options) => setFilter(undefined, key, value, options),\n clearFilters,\n refresh: (source) => store.reload(source),\n exportPNG,\n exportWidget: exportWidget as DashboardExpose['exportWidget'],\n saveWidget: saveWidget as DashboardExpose['saveWidget'],\n print,\n})\n</script>\n\n<template>\n <div\n ref=\"root\"\n :class=\"['dou-dashboard', editable && 'is-editable']\"\n role=\"region\"\n :aria-label=\"label || messages.dashboard\"\n tabindex=\"-1\"\n @keydown=\"onKeydown\"\n @copy=\"onCopy($event)\"\n @cut=\"onCopy($event, true)\"\n @paste=\"onPaste\"\n @focusin=\"onFocusIn\"\n @focusout=\"onFocusOut\"\n >\n <GridLayout\n ref=\"grid\"\n :model-value=\"items\"\n :layouts=\"layouts\"\n :columns=\"columns\"\n :breakpoints=\"breakpoints\"\n :row-height=\"rowHeight\"\n :gap=\"gap\"\n :compact=\"compact\"\n :editable=\"editable\"\n :handle=\"true\"\n :item-label=\"labelOf\"\n :messages=\"messages\"\n :style=\"editable && !model.widgets.length ? { minHeight } : undefined\"\n @update:model-value=\"(list) => gather({ items: list })\"\n @update:layouts=\"(next) => gather({ layouts: next })\"\n @drop=\"onDrop\"\n @drag-start=\"onDragStart\"\n @drag-end=\"onDragEnd\"\n @breakpoint-change=\"onBreakpoint\"\n >\n <template #item=\"{ item }\">\n <WidgetFrame :id=\"item.id\" />\n </template>\n </GridLayout>\n <div v-if=\"editable && !model.widgets.length\" class=\"dou-dashboard__empty\">\n <DouEmpty size=\"sm\" :title=\"messages.emptyTitle\" :description=\"messages.emptyHint\">\n <template #icon><LayoutGridPlusIcon /></template>\n </DouEmpty>\n </div>\n <div class=\"dou-dashboard__live\" aria-live=\"polite\" aria-atomic=\"true\">{{ live }}</div>\n <DouDrawer\n :open=\"!!settingsWidget\"\n :title=\"\n settingsWidget ? format(messages.settingsTitle, { title: titleOf(settingsWidget) }) : ''\n \"\n :size=\"360\"\n @update:open=\"(open) => !open && (settingsId = undefined)\"\n @closed=\"restoreFocus\"\n >\n <div v-if=\"settingsWidget\" class=\"dou-dashboard__settings\">\n <label class=\"dou-dashboard__field\">\n <span class=\"dou-dashboard__field-label\">{{ messages.titleField }}</span>\n <DouInput\n :model-value=\"settingsWidget.title ?? ''\"\n :placeholder=\"typeOf(settingsWidget)?.label ?? settingsWidget.type\"\n @update:model-value=\"\n (value) =>\n run(\n [\n {\n type: 'update',\n id: settingsWidget!.id,\n patch: { title: value || undefined },\n },\n ],\n { origin: 'settings', group: `title:${settingsWidget!.id}` },\n )\n \"\n />\n </label>\n <WidgetSettings\n v-if=\"typeOf(settingsWidget)?.settings\"\n :widget=\"settingsWidget\"\n :type=\"typeOf(settingsWidget)!\"\n :set-props=\"setProps\"\n @update:props=\"\n (value) =>\n run([{ type: 'update', id: settingsWidget!.id, patch: { props: value } }], {\n origin: 'settings',\n group: `props:${settingsWidget!.id}`,\n })\n \"\n />\n </div>\n </DouDrawer>\n <DouDialog\n :open=\"!!fullscreenWidget\"\n :title=\"fullscreenWidget ? titleOf(fullscreenWidget) : ''\"\n fullscreen\n @update:open=\"(open) => !open && (fullscreenId = undefined)\"\n @closed=\"restoreFocus\"\n >\n <WidgetBody\n v-if=\"fullscreenWidget\"\n class=\"is-fullscreen\"\n :widget=\"fullscreenWidget\"\n :type=\"typeOf(fullscreenWidget)\"\n :editing=\"false\"\n fullscreen\n :messages=\"messages\"\n :set-props=\"setProps\"\n />\n </DouDialog>\n </div>\n</template>\n"],"mappings":""}
|
|
1
|
+
{"version":3,"file":"dashboard.js","names":["_sfc_main"],"sources":["../../src/dashboard/dashboard.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { LayoutGridPlusIcon, fileIconOf } from '@dou-ui/icons'\nimport type { SheetExporter, SheetWorkbook } from '@dou-ui/sheet/core'\nimport {\n DouDialog,\n DouDrawer,\n DouEmpty,\n DouInput,\n downloadBlob,\n matchesHotkey,\n useLocale,\n} from 'dou-ui'\nimport {\n computed,\n nextTick,\n onBeforeUnmount,\n onMounted,\n provide,\n ref,\n shallowReactive,\n shallowRef,\n toRaw,\n watch,\n} from 'vue'\nimport { emptyDocument } from '../core/document'\nimport { DashboardHistory } from '../core/history'\nimport {\n applySteps,\n cloneJson,\n DashboardTransaction,\n replaceWidgets,\n sameValue,\n} from '../core/transaction'\nimport type { DashboardStep, DashboardTransactionMeta } from '../core/transaction'\nimport type { DashboardDocument, DashboardPlace, DashboardWidget } from '../core/types'\nimport type {\n DashboardPeer,\n DashboardPresence,\n DashboardPresenceAction,\n} from '../core/collaboration'\nimport { dataContextKey } from '../data/context'\nimport { filterText, isEmptyFilter } from '../data/filters'\nimport { createSourceStore, sameSource } from '../data/store'\nimport type {\n DashboardFilters,\n DashboardLinkedFilter,\n DashboardSetFilterOptions,\n} from '../data/types'\nimport GridLayout from '../grid/grid-layout.vue'\nimport { DEFAULT_BREAKPOINTS, DEFAULT_COLUMNS, gridTiers } from '../grid/tiers'\nimport type { GridDragEvent, GridDropEvent, GridLayoutExpose, GridLayouts } from '../grid/types'\nimport { findPlace, fitItem, moveItem } from '../layout/engine'\nimport type { GridItem, GridLayoutOptions } from '../layout/types'\nimport { format, useDashboardMessages } from '../locale'\nimport { DEFAULT_WIDGET_SIZE, defaultPropsOf } from '../widget/define'\nimport type { DashboardWidgetType } from '../widget/define'\nimport type {\n DashboardAddOptions,\n DashboardEmits,\n DashboardExportErrorEvent,\n DashboardExportOptions,\n DashboardExpose,\n DashboardSaveOptions,\n DashboardProps,\n} from './types'\nimport { createObservers, observersKey } from './observers'\nimport { elementToPNG } from './export'\nimport type { DashboardImageOptions } from './export'\nimport WidgetBody from './widget-body.vue'\nimport WidgetFrame from './widget-frame.vue'\nimport WidgetSettings from './widget-settings.vue'\nimport { frameContextKey } from './frame-context'\nimport CommentButton from './comment-button.vue'\nimport CommentCard from './comment-card.vue'\nimport { useDashboardComments } from './comments'\nimport type { FrameExporter, WidgetAction } from './frame-context'\n\ndefineOptions({ name: 'DouDashboard' })\n\nconst props = withDefaults(defineProps<Omit<DashboardProps, 'modelValue' | 'filters'>>(), {\n widgets: () => [],\n columns: () => DEFAULT_COLUMNS,\n breakpoints: () => DEFAULT_BREAKPOINTS,\n rowHeight: 80,\n gap: 16,\n compact: 'vertical',\n comments: undefined,\n author: undefined,\n activeComment: undefined,\n commentable: true,\n})\nconst emit = defineEmits<Omit<DashboardEmits, 'update:modelValue' | 'update:filters'>>()\nconst model = defineModel<DashboardDocument>({ default: emptyDocument })\nconst filters = defineModel<DashboardFilters>('filters', { default: () => ({}) })\nconst messages = useDashboardMessages(() => props.messages)\n\nconst grid = ref<GridLayoutExpose>()\nconst observers = createObservers()\nprovide(observersKey, observers)\nonBeforeUnmount(() => observers.dispose())\n/** This many widgets are drawn in full at once, the rest when they come near the view. */\nconst EAGER = 16\nconst root = ref<HTMLElement>()\n\nconst typeMap = computed(() => new Map(props.widgets.map((type) => [type.type, type])))\nconst typeOf = (widget: DashboardWidget) => typeMap.value.get(widget.type)\nconst widgetMap = computed(() => new Map(model.value.widgets.map((widget) => [widget.id, widget])))\n/** The first widgets of the document, drawn in full without waiting to be seen. */\nconst eagerIds = computed(() => new Set(model.value.widgets.slice(0, EAGER).map((w) => w.id)))\n/** Widgets added once the dashboard is up are drawn in full straight away (not reactive on purpose). */\nlet mounted = false\nonMounted(() => (mounted = true))\nconst isEager = (id: string) => mounted || eagerIds.value.has(id)\nconst titleOf = (widget: DashboardWidget) => widget.title || typeOf(widget)?.label || widget.type\n\nconst tiers = computed(() => gridTiers(props.columns, props.breakpoints))\n/** The widest breakpoint: its places are the main ones. */\nconst main = computed(() => tiers.value[0]!)\nconst mainOptions = computed<GridLayoutOptions>(() => ({\n columns: Math.max(1, Math.round(main.value.columns)),\n compact: props.compact,\n}))\n\n// ---------------------------------------------------------------------------\n// Changes and history\n\nconst history = new DashboardHistory()\n/** Bumped whenever the history changes, so `canUndo` and `canRedo` update. */\nconst historyVersion = ref(0)\n/** The document last emitted, to tell our own changes from the app's. */\nlet emitted: DashboardDocument | undefined\nwatch(model, (doc) => {\n // the app may hand back a reactive copy of what was emitted\n // a document the app put in itself: the old steps no longer fit it\n if (toRaw(doc) === emitted) return\n history.clear()\n historyVersion.value++\n})\n\nfunction commit(tr: DashboardTransaction) {\n emitted = toRaw(tr.doc)\n model.value = tr.doc\n emit('change', tr)\n}\n\n/** A transaction on the document as it is now, to `dispatch`. */\nfunction transaction() {\n return new DashboardTransaction(model.value)\n}\n\n/** Makes the changes of a transaction, as one undo step (or part of one, by `meta.group`). */\nfunction dispatch(tr: DashboardTransaction) {\n if (toRaw(tr.before) !== toRaw(model.value))\n throw new Error(\n '[@dou-ui/dashboard] the transaction was made on another version of the dashboard',\n )\n if (!tr.changed) return\n const shared = props.collaboration\n if (!shared) {\n history.record(tr)\n historyVersion.value++\n }\n commit(tr)\n shared?.transaction(tr)\n}\n\n/** Steps made into a transaction and dispatched, for the dashboard's own changes. */\nfunction run(steps: DashboardStep[], meta: DashboardTransactionMeta) {\n const tr = applySteps(model.value, steps, meta)\n dispatch(tr)\n return tr\n}\n\n// ---------------------------------------------------------------------------\n// Comments on widgets\n\nconst comments = useDashboardComments({\n props,\n emit,\n model,\n run,\n carry: (id, patch) => history.carry(id, patch),\n})\n/** The full screen view, which has a comment button of its own. */\nconst fullscreenBody = shallowRef<HTMLElement | null>(null)\n/** A widget's comment button: in the full screen view while that widget is there. */\nfunction buttonOf(id: string) {\n const selector = `[data-dou-comment=\"${CSS.escape(id)}\"]`\n const inFullscreen = fullscreenId.value === id ? fullscreenBody.value : null\n return (\n inFullscreen?.querySelector<HTMLElement>(selector) ??\n root.value?.querySelector<HTMLElement>(selector) ??\n null\n )\n}\n/** Where a widget's card goes: by its comment button, or by the widget until the button is drawn. */\nconst cardSpotOf = (id: string) =>\n buttonOf(id) ??\n root.value?.querySelector<HTMLElement>(`[data-grid-id=\"${CSS.escape(id)}\"]`) ??\n null\n/** The comment shortcut in the full screen view, which is outside the dashboard. */\nfunction onFullscreenKeydown(event: KeyboardEvent) {\n const id = fullscreenId.value\n if (!id || event.defaultPrevented || !comments.enabled.value || isTyping(event.target)) return\n if (matchesHotkey(event, 'mod+alt+m') && comments.start(id)) event.preventDefault()\n}\n// A thread opened from outside, e.g. a list beside the dashboard: bring its widget into view.\nwatch(\n () => props.activeComment,\n (id) => {\n const widget = id && !comments.openedHere(id) ? comments.widgetOf(id) : null\n if (widget) grid.value?.scrollToItem(widget)\n },\n)\n\nfunction undo() {\n if (props.collaboration) return props.collaboration.undo()\n const tr = history.undo(model.value)\n historyVersion.value++\n if (!tr) return false\n if (tr.changed) commit(tr)\n return true\n}\n\nfunction redo() {\n if (props.collaboration) return props.collaboration.redo()\n const tr = history.redo(model.value)\n historyVersion.value++\n if (!tr) return false\n if (tr.changed) commit(tr)\n return true\n}\n\nconst canUndo = computed(() =>\n props.collaboration ? props.collaboration.canUndo() : (historyVersion.value, history.canUndo),\n)\nconst canRedo = computed(() =>\n props.collaboration ? props.collaboration.canRedo() : (historyVersion.value, history.canRedo),\n)\n\n// ---------------------------------------------------------------------------\n// The grid: widgets as items, places by breakpoint\n\nfunction limitsOf(type: DashboardWidgetType<any> | undefined) {\n const size = type?.size ?? DEFAULT_WIDGET_SIZE\n return {\n w: size.w,\n h: size.h,\n minW: size.minW,\n maxW: size.maxW,\n minH: size.minH,\n maxH: size.maxH,\n }\n}\n\n/** The widgets on the widest breakpoint; those without a place there get the first spot they fit. */\nconst placeOf = (item: DashboardPlace): DashboardPlace => ({\n x: item.x,\n y: item.y,\n w: item.w,\n h: item.h,\n})\n\n// The item of a widget that didn't change is the same object as before, so\n// the grid and its other items have nothing to draw again.\nlet itemCache = new WeakMap<DashboardWidget, GridItem>()\nwatch([typeMap, main], () => (itemCache = new WeakMap()), { flush: 'sync' })\nconst items = computed<GridItem[]>(() => {\n const name = main.value.name\n const options = mainOptions.value\n const placed: GridItem[] = []\n const result = new Map<string, GridItem>()\n const missing: DashboardWidget[] = []\n for (const widget of model.value.widgets) {\n const raw = toRaw(widget)\n const place = raw.place[name]\n if (!place) {\n missing.push(raw)\n continue\n }\n let item = itemCache.get(raw)\n if (!item) {\n const { minW, maxW, minH, maxH } = limitsOf(typeOf(raw))\n item = { id: raw.id, ...placeOf(place), minW, maxW, minH, maxH }\n itemCache.set(raw, item)\n }\n placed.push(item)\n result.set(raw.id, item)\n }\n for (const widget of missing) {\n const fitted = fitItem(\n { id: widget.id, x: 0, y: 0, ...limitsOf(typeOf(widget)) },\n options.columns,\n )\n const item = { ...fitted, ...findPlace(placed, fitted, options) }\n placed.push(item)\n result.set(widget.id, item)\n }\n return model.value.widgets.map((widget) => result.get(widget.id)!)\n})\n\n/** The places on the narrower breakpoints. */\nconst layouts = computed<GridLayouts>(() => {\n const result: GridLayouts = {}\n for (const tier of tiers.value.slice(1)) {\n const list = []\n for (const widget of model.value.widgets) {\n const place = widget.place[tier.name]\n if (place) list.push({ id: widget.id, ...place })\n }\n if (list.length) result[tier.name] = list\n }\n return result\n})\n\n// The grid tells a move with `update:modelValue`, `update:layouts` and\n// `drop` one after another; they are gathered into one change.\nlet pending: { items?: GridItem[]; layouts?: GridLayouts; drop?: { id: string; type: string } } = {}\nlet flushing = false\nfunction gather(part: typeof pending) {\n Object.assign(pending, part)\n if (flushing) return\n flushing = true\n queueMicrotask(flush)\n}\n\nfunction flush() {\n flushing = false\n const { items: list, layouts: saved, drop } = pending\n pending = {}\n const doc = model.value\n const places = new Map<string, DashboardWidget['place']>()\n const setPlace = (id: string, name: string, item: DashboardPlace) => {\n let place = places.get(id)\n if (!place) places.set(id, (place = { ...widgetMap.value.get(id)?.place }))\n place[name] = placeOf(item)\n }\n for (const item of list ?? []) setPlace(item.id, main.value.name, item)\n for (const [name, others] of Object.entries(saved ?? {}))\n for (const item of others ?? []) setPlace(item.id, name, item)\n const steps: DashboardStep[] = []\n for (const [id, place] of places) {\n if (widgetMap.value.has(id)) {\n steps.push({ type: 'update', id, patch: { place } })\n } else if (drop?.id === id) {\n const type = typeMap.value.get(drop.type)\n if (type)\n steps.push({ type: 'add', widget: newWidget(id, type, place), index: doc.widgets.length })\n }\n }\n const tr = run(steps, { origin: drop ? 'drop' : 'layout' })\n if (drop && tr.changed) {\n const widget = widgetMap.value.get(drop.id)\n if (widget) say(format(messages.value.added, { item: titleOf(widget) }))\n }\n}\n\nfunction onDrop(event: GridDropEvent) {\n const data = event.data as { type?: string } | undefined\n if (data?.type) gather({ drop: { id: event.item.id, type: data.type } })\n}\n\nconst dragging = ref(false)\n\n// ---------------------------------------------------------------------------\n// Adding, copying and removing\n\nfunction newId(type: string) {\n const used = widgetMap.value\n for (;;) {\n const id = props.createId?.(type) ?? `${type}-${Math.random().toString(36).slice(2, 8)}`\n if (!used.has(id)) return id\n if (props.createId)\n throw new Error(`[@dou-ui/dashboard] createId gave \"${id}\", which is already in use`)\n }\n}\n\nfunction newWidget(\n id: string,\n type: DashboardWidgetType<any>,\n place: DashboardWidget['place'],\n options: Pick<DashboardAddOptions, 'title' | 'props'> = {},\n): DashboardWidget {\n const widget: DashboardWidget = { id, type: type.type, place }\n if (options.title) widget.title = options.title\n const initial = defaultPropsOf(type)\n if (initial || options.props) widget.props = { ...initial, ...options.props }\n return widget\n}\n\n/**\n * Puts a new item on the widest breakpoint, at `at` (pushing the others\n * away) or in the first spot it fits; the steps also move any widget it\n * pushed.\n */\nfunction placeNew(item: GridItem, at: Pick<DashboardPlace, 'x' | 'y'> | undefined) {\n const options = mainOptions.value\n const fitted = fitItem(item, options.columns)\n const spot = at ?? findPlace(items.value, fitted, options)\n const layout = moveItem([...items.value, { ...fitted, ...spot }], item.id, spot, options)\n const steps: DashboardStep[] = []\n let place: DashboardPlace = { ...fitted, ...spot }\n for (const next of layout) {\n if (next.id === item.id) {\n place = placeOf(next)\n continue\n }\n const widget = widgetMap.value.get(next.id)\n const old = widget && items.value.find((other) => other.id === next.id)\n if (widget && old && (old.x !== next.x || old.y !== next.y))\n steps.push({\n type: 'update',\n id: next.id,\n patch: { place: { ...widget.place, [main.value.name]: placeOf(next) } },\n })\n }\n return { place, steps }\n}\n\nfunction addWidget(typeName: string, options: DashboardAddOptions = {}) {\n const type = typeMap.value.get(typeName)\n if (!type) return undefined\n const id = newId(typeName)\n const { place, steps } = placeNew({ id, x: 0, y: 0, ...limitsOf(type) }, options.at)\n const widget = newWidget(id, type, { [main.value.name]: place }, options)\n run([{ type: 'add', widget, index: model.value.widgets.length }, ...steps], {\n origin: 'api',\n })\n say(format(messages.value.added, { item: titleOf(widget) }))\n if (options.focus !== false) void nextTick(() => focusWidget(id))\n return id\n}\n\n/** Adds a copy of a widget (from this dashboard or the clipboard) beside `near`, or in the first free spot. */\nfunction addCopy(source: DashboardWidget, near?: GridItem) {\n const type = typeOf(source)\n const id = newId(source.type)\n const columns = mainOptions.value.columns\n const size = near ?? { ...limitsOf(type), ...source.place[main.value.name] }\n const at = near\n ? near.x + near.w * 2 <= columns\n ? { x: near.x + near.w, y: near.y }\n : { x: near.x, y: near.y + near.h }\n : undefined\n const { place, steps } = placeNew({ ...limitsOf(type), id, x: 0, y: 0, w: size.w, h: size.h }, at)\n const widget: DashboardWidget = {\n ...cloneJson(source),\n id,\n place: { [main.value.name]: place },\n }\n // a copy starts without the original's discussions\n delete widget.comments\n const index = near\n ? model.value.widgets.findIndex((other) => other.id === near.id) + 1\n : model.value.widgets.length\n run([{ type: 'add', widget, index }, ...steps], { origin: 'copy' })\n say(format(messages.value.added, { item: titleOf(widget) }))\n void nextTick(() => focusWidget(id))\n return id\n}\n\nfunction duplicateWidget(id: string) {\n const widget = widgetMap.value.get(id)\n const item = items.value.find((other) => other.id === id)\n return widget && item ? addCopy(widget, item) : undefined\n}\n\nfunction removeWidget(id: string) {\n const list = model.value.widgets\n const index = list.findIndex((widget) => widget.id === id)\n if (index < 0) return\n const widget = list[index]!\n const hadFocus = root.value?.contains(document.activeElement)\n const neighbour = list[index + 1] ?? list[index - 1]\n if (settingsId.value === id) settingsId.value = undefined\n if (fullscreenId.value === id) fullscreenId.value = undefined\n run([{ type: 'remove', id }], { origin: 'api' })\n say(format(messages.value.removed, { item: titleOf(widget) }))\n if (hadFocus) void nextTick(() => (neighbour ? focusWidget(neighbour.id) : root.value?.focus()))\n}\n\nfunction focusWidget(id: string) {\n grid.value?.focusItem(id)\n}\n\n/** The widget being dragged in, which has no widget yet: its name is its type's. */\nlet adding: { id: string; label: string } | undefined\n\nfunction startAdd(event: PointerEvent, typeName: string) {\n const type = typeMap.value.get(typeName)\n if (!type || !props.editable) return\n adding = { id: newId(typeName), label: type.label }\n grid.value?.startDrop(event, { id: adding.id, ...limitsOf(type) }, { type: typeName })\n}\n\nfunction labelOf(item: GridItem) {\n const widget = widgetMap.value.get(item.id)\n return widget ? titleOf(widget) : item.id === adding?.id ? adding.label : item.id\n}\n\nfunction setProps(id: string, patch: Record<string, unknown>, options: { group?: string } = {}) {\n const widget = widgetMap.value.get(id)\n if (!widget) return\n run([{ type: 'update', id, patch: { props: { ...widget.props, ...patch } } }], {\n origin: 'widget',\n group: options.group,\n })\n}\n\nfunction rename(id: string, title: string) {\n run([{ type: 'update', id, patch: { title: title.trim() || undefined } }], {\n origin: 'title',\n })\n}\n\n// ---------------------------------------------------------------------------\n// Settings and full screen\n\nconst settingsId = ref<string>()\nconst settingsWidget = computed(() =>\n settingsId.value ? widgetMap.value.get(settingsId.value) : undefined,\n)\nconst fullscreenId = ref<string>()\nconst fullscreenWidget = computed(() =>\n fullscreenId.value ? widgetMap.value.get(fullscreenId.value) : undefined,\n)\n/** The widget to give focus back to when a drawer or dialog closes. */\nlet returnTo: string | undefined\n\nfunction openSettings(id: string) {\n if (!props.editable || !widgetMap.value.has(id)) return\n returnTo = id\n settingsId.value = id\n}\n\nfunction openFullscreen(id: string) {\n const widget = widgetMap.value.get(id)\n if (!widget || typeOf(widget)?.fullscreen === false) return\n returnTo = id\n fullscreenId.value = id\n}\n\nfunction restoreFocus() {\n const id = returnTo\n returnTo = undefined\n if (id && widgetMap.value.has(id)) focusWidget(id)\n}\n\nwatch(\n () => props.editable,\n (editable) => {\n if (!editable) settingsId.value = undefined\n },\n)\n\nfunction onAction(id: string, action: WidgetAction) {\n if (action === 'settings') openSettings(id)\n else if (action === 'fullscreen') openFullscreen(id)\n else if (action === 'duplicate') duplicateWidget(id)\n else removeWidget(id)\n}\n\n// ---------------------------------------------------------------------------\n// Editing together\n\n/** Changes from others wait while a widget is dragged here; then the shared document is read again. */\nlet held = false\n\nfunction applyShared(doc: DashboardDocument, origin: 'remote' | 'undo' | 'redo') {\n if (dragging.value) {\n held = true\n return\n }\n const tr = replaceWidgets(new DashboardTransaction(model.value), doc, { keepComments: false })\n if (!tr.changed) return\n tr.setMeta('history', false).setMeta('origin', origin)\n const ids = new Set(tr.doc.widgets.map((widget) => widget.id))\n if (settingsId.value && !ids.has(settingsId.value)) settingsId.value = undefined\n if (fullscreenId.value && !ids.has(fullscreenId.value)) fullscreenId.value = undefined\n commit(tr)\n}\n\n// one written in the template is new on every render: each one starts over and clears undo\nlet setupTimes: number[] = []\nlet setupWarned = false\nfunction warnRepeatedSetup() {\n if (process.env.NODE_ENV === 'production' || setupWarned) return\n const now = Date.now()\n setupTimes = [...setupTimes.filter((time) => now - time < 1000), now]\n if (setupTimes.length < 4) return\n setupWarned = true\n console.warn(\n '[@dou-ui/dashboard] collaboration keeps being replaced, which starts it over and clears undo each time. Create it once in <script setup> instead of in the template.',\n )\n}\n\nwatch(\n () => props.collaboration,\n (shared, _, onCleanup) => {\n if (shared) warnRepeatedSetup()\n history.clear()\n historyVersion.value++\n if (!shared) return\n const stop = shared.setup({ document: () => model.value, apply: applyShared })\n onCleanup(() => {\n shared.presence?.(null)\n stop()\n })\n },\n { immediate: true },\n)\n\n/** The widget being moved or resized here, and how. */\nconst drag = shallowRef<GridDragEvent>()\nfunction onDragStart(event: GridDragEvent) {\n dragging.value = true\n drag.value = event\n}\nfunction onDragEnd() {\n dragging.value = false\n drag.value = undefined\n if (!held) return\n held = false\n // after the drop's own change, which the grid hands over in a microtask\n queueMicrotask(() => queueMicrotask(() => props.collaboration?.sync()))\n}\n\n/** The widget whose item, or a control in it, has focus. */\nconst focused = ref<string>()\nfunction onFocusIn(event: FocusEvent) {\n const item = (event.target as HTMLElement).closest?.<HTMLElement>('[data-grid-id]')\n focused.value = item?.closest('.dou-dashboard') === root.value ? item?.dataset.gridId : undefined\n}\nfunction onFocusOut(event: FocusEvent) {\n if (!root.value?.contains(event.relatedTarget as Node | null)) focused.value = undefined\n}\n\n/** What this person is doing, shown to the others. */\nconst presence = computed<DashboardPresence | null>(() => {\n const moving = drag.value\n if (moving && moving.kind !== 'drop' && widgetMap.value.has(moving.id))\n return { widget: moving.id, action: moving.kind as DashboardPresenceAction }\n if (settingsId.value) return { widget: settingsId.value, action: 'settings' }\n if (focused.value && widgetMap.value.has(focused.value))\n return { widget: focused.value, action: 'focus' }\n return null\n})\nwatch(\n [presence, () => props.collaboration] as const,\n ([current, shared], previous) => {\n if (!shared?.presence) return\n const [before, sharedBefore] = previous ?? []\n if (\n sharedBefore === shared &&\n before?.widget === current?.widget &&\n before?.action === current?.action\n )\n return\n shared.presence(current)\n },\n { immediate: true },\n)\nconst NO_PEERS: readonly DashboardPeer[] = []\n\nprovide(frameContextKey, {\n comments,\n widget: (id) => widgetMap.value.get(id),\n type: typeOf,\n title: titleOf,\n editing: () => props.editable,\n messages: () => messages.value,\n eager: isEager,\n all: () => showAll.value,\n setProps,\n action: onAction,\n rename,\n linked: (id) => linksByWidget.value.get(id) ?? NO_LINKS,\n clearFilter: (key) => setFilter(undefined, key, undefined),\n peers: (id) => props.collaboration?.peers?.(id) ?? NO_PEERS,\n setWorkbook,\n exporters: (id) => (workbooks.has(id) ? exporterList.value : NO_EXPORTERS),\n exportTo: exportFromMenu,\n exporting: (id) => exporting.has(id),\n setDrawn: (id, drawn) => (drawn ? drawnIds.add(id) : drawnIds.delete(id)),\n exportError: (id) => exportErrors.get(id),\n clearExportError: (id) => exportErrors.delete(id),\n})\n\n// ---------------------------------------------------------------------------\n// Exporting a widget's content as a file\n\n/** How each mounted widget gives its content as a workbook; see `setWorkbook`. */\nconst workbooks = shallowReactive(new Map<string, () => SheetWorkbook | undefined>())\nfunction setWorkbook(id: string, get: () => SheetWorkbook | undefined) {\n workbooks.set(id, get)\n return () => {\n if (workbooks.get(id) === get) workbooks.delete(id)\n }\n}\n\n// the file code comes with the first exporter, in a chunk of its own\ntype SheetCore = typeof import('@dou-ui/sheet/core')\nconst sheetCore = shallowRef<SheetCore>()\nlet loadingCore: Promise<SheetCore> | undefined\nfunction loadSheetCore() {\n loadingCore ??= import('@dou-ui/sheet/core').then((core) => (sheetCore.value = core))\n return loadingCore\n}\nwatch(\n () => props.exporters?.length,\n (count) => count && void loadSheetCore(),\n { immediate: true },\n)\n\nconst NO_EXPORTERS: FrameExporter[] = []\nconst { locale } = useLocale()\n/** The exporters of the menu, without those that draw a sheet's grid. */\nconst exporterList = computed<FrameExporter[]>(() => {\n const core = sheetCore.value\n if (!core) return NO_EXPORTERS\n const sheetMessages = core.sheetMessagesFor(locale.value.name)\n return (props.exporters ?? [])\n .filter((exporter) => exporter.needs !== 'view')\n .map((exporter) => ({\n name: exporter.name,\n label: typeof exporter.label === 'function' ? exporter.label(sheetMessages) : exporter.label,\n icon: exporter.icon ?? fileIconOf(exporter.extension),\n }))\n})\n\nfunction exporterOf(exporter: string | SheetExporter) {\n if (typeof exporter !== 'string') return exporter\n const found = props.exporters?.find((item) => item.name === exporter)\n if (!found) throw new RangeError(`[@dou-ui/dashboard] there is no exporter \"${exporter}\"`)\n return found\n}\n\n/** A widget's file and sheet name: its title, or its type's name. */\nconst exportNameOf = (widget: DashboardWidget) => titleOf(widget).trim() || widget.type\n\n/** Why something failed, as people read it: a file error's `reason`, or its message. */\nfunction reasonOf(error: unknown) {\n if (error instanceof Error)\n return 'reason' in error && typeof error.reason === 'string' ? error.reason : error.message\n return String(error)\n}\n\n/**\n * A widget's content as a file: its workbook written by the exporter, a\n * name from `exporters` or an exporter itself. Waits for the data shown,\n * and draws the widget first when it is far from the view.\n */\nasync function exportWidget(\n id: string,\n exporter: string | SheetExporter,\n options: DashboardExportOptions = {},\n): Promise<Blob> {\n const chosen = exporterOf(exporter)\n const widget = widgetMap.value.get(id)\n if (!widget) throw new RangeError(`[@dou-ui/dashboard] there is no widget \"${id}\"`)\n const core = await loadSheetCore()\n // a widget drawn already that gives no workbook never will: no need to draw them all\n await settle(5000, !drawnIds.has(id))\n options.signal?.throwIfAborted()\n const get = workbooks.get(id)\n const book = get?.()\n if (!book)\n throw new core.SheetFileError(\n get\n ? `[@dou-ui/dashboard] the widget \"${id}\" has no data to export now`\n : `[@dou-ui/dashboard] the widget \"${id}\" doesn't give its content as a workbook (setWorkbook)`,\n 'empty',\n messages.value.nothingToExport,\n )\n // sheets without a name take the widget's, as the file does\n const name = exportNameOf(widget)\n const named = book.sheets.some((sheet) => !sheet.name)\n ? { ...book, sheets: book.sheets.map((sheet) => (sheet.name ? sheet : { ...sheet, name })) }\n : book\n return core.exportWorkbook(named, chosen, {\n options: options.options,\n signal: options.signal,\n locale: locale.value.name,\n })\n}\n\n/** The widgets drawn in full; the others show only their title until they come near. */\nconst drawnIds = new Set<string>()\n\n/** Why the last export from each widget's menu failed, shown over its content until closed. */\nconst exportErrors = shallowReactive(new Map<string, string>())\n/** The widgets exporting now, whose export button waits. */\nconst exporting = shallowReactive(new Set<string>())\n\n/**\n * Exports a widget's content and saves the file in the downloads, as its\n * menu does. A failure shows over the widget and comes as `exportError`,\n * and the promise rejects.\n */\nasync function saveWidget(\n id: string,\n exporter: string | SheetExporter,\n options: DashboardSaveOptions = {},\n): Promise<void> {\n const chosen = exporterOf(exporter)\n const widget = widgetMap.value.get(id)\n if (!widget) throw new RangeError(`[@dou-ui/dashboard] there is no widget \"${id}\"`)\n const name = options.fileName ?? `${exportNameOf(widget)}.${chosen.extension}`\n exportErrors.delete(id)\n exporting.add(id)\n let blob: Blob\n try {\n blob = await exportWidget(id, chosen, options)\n } catch (error) {\n const event: DashboardExportErrorEvent = {\n widget: id,\n exporter: chosen.name,\n error,\n message: format(messages.value.exportFailed, { name, reason: reasonOf(error) }),\n defaultPrevented: false,\n preventDefault() {\n this.defaultPrevented = true\n },\n }\n emit('exportError', event)\n // the notice is read out itself; told by the app, it is only read out\n if (event.defaultPrevented) say(event.message)\n else exportErrors.set(id, event.message)\n throw error\n } finally {\n exporting.delete(id)\n }\n downloadBlob(blob, name)\n say(format(messages.value.exported, { name }))\n}\n\nfunction exportFromMenu(id: string, name: string) {\n if (exporting.has(id)) return\n // the failure is shown over the widget\n saveWidget(id, name).catch(() => {})\n}\n\n// ---------------------------------------------------------------------------\n// Data and filters\n\nconst store = createSourceStore(() => props.sources)\nonBeforeUnmount(() => store.dispose())\n// a source replaced by one that loads differently loads again\nwatch(\n () => props.sources,\n (next, previous) => {\n const names = new Set([...Object.keys(previous ?? {}), ...Object.keys(next ?? {})])\n for (const name of names) if (!sameSource(previous?.[name], next?.[name])) store.reload(name)\n },\n)\n\n/** The filters set by widgets, by key: they apply to every widget but the one that set them. */\nconst links = shallowRef(new Map<string, DashboardLinkedFilter>())\nconst EMPTY_SET: ReadonlySet<string> = new Set()\nconst NO_LINKS: DashboardLinkedFilter[] = []\nconst linksByWidget = computed(() => {\n const result = new Map<string, DashboardLinkedFilter[]>()\n for (const link of links.value.values()) {\n const list = result.get(link.widget)\n if (list) list.push(link)\n else result.set(link.widget, [link])\n }\n return result\n})\nconst emittedByWidget = computed(() => {\n const result = new Map<string, Set<string>>()\n for (const [id, list] of linksByWidget.value)\n result.set(id, new Set(list.map((link) => link.key)))\n return result\n})\n\n// The values as set last: with `v-model:filters` the model only updates once\n// the parent draws again, so several filters set at once build on this.\nconst values = shallowRef<DashboardFilters>(filters.value)\nwatch(filters, (next) => (values.value = next))\n\nfunction setFilter(\n widget: string | undefined,\n key: string,\n value: unknown,\n options: DashboardSetFilterOptions = {},\n) {\n const next = { ...values.value }\n const nextLinks = new Map(links.value)\n if (isEmptyFilter(value)) delete next[key]\n else next[key] = value\n if (widget && !isEmptyFilter(value) && widgetMap.value.has(widget))\n nextLinks.set(key, { key, value, label: options.label ?? filterText(value), widget })\n else nextLinks.delete(key)\n links.value = nextLinks\n values.value = next\n filters.value = next\n}\n\nfunction clearFilters() {\n links.value = new Map()\n values.value = {}\n filters.value = {}\n}\n\n// a filter changed from outside (the filter bar, the app) is no longer the widget's\nwatch(filters, (current) => {\n let next: Map<string, DashboardLinkedFilter> | undefined\n for (const [key, link] of links.value) {\n if (sameValue(current[key], link.value)) continue\n next ??= new Map(links.value)\n next.delete(key)\n }\n if (next) links.value = next\n})\n\n// the filters of a widget that is removed go with it\nwatch(widgetMap, (map) => {\n for (const link of links.value.values())\n if (!map.has(link.widget)) setFilter(undefined, link.key, undefined)\n})\n\nconst sourceNames = computed(() => Object.keys(props.sources ?? {}))\nprovide(dataContextKey, {\n store,\n sourceNames: () => sourceNames.value,\n filters: () => values.value,\n emitted: (id) => emittedByWidget.value.get(id) ?? EMPTY_SET,\n linked: (id) => linksByWidget.value.get(id) ?? NO_LINKS,\n setFilter,\n})\n\n// ---------------------------------------------------------------------------\n// Keyboard and clipboard\n\nconst CLIPBOARD_KIND = 'dou-dashboard/widget'\n\nfunction isTyping(target: EventTarget | null) {\n const el = target as HTMLElement | null\n if (!el?.tagName) return false\n return (\n el.isContentEditable ||\n el.tagName === 'TEXTAREA' ||\n el.tagName === 'SELECT' ||\n (el.tagName === 'INPUT' &&\n !['checkbox', 'radio', 'button', 'range', 'color'].includes((el as HTMLInputElement).type))\n )\n}\n\n/** The widget an element is in, such as the one holding the focus. */\nfunction widgetHolding(target: EventTarget | null) {\n const el = (target as HTMLElement | null)?.closest?.<HTMLElement>('[data-grid-id]')\n return el && el.closest('.dou-dashboard') === root.value ? el.dataset.gridId : undefined\n}\n\n/** The widget whose item itself has focus (not a control inside it). */\nfunction focusedId(target: EventTarget | null) {\n const el = target as HTMLElement | null\n return el?.dataset?.gridId && el.closest('.dou-dashboard') === root.value\n ? el.dataset.gridId\n : undefined\n}\n\nfunction onKeydown(event: KeyboardEvent) {\n if (event.defaultPrevented || isTyping(event.target)) return\n // anywhere in a widget, also while viewing: its discussions\n if (matchesHotkey(event, 'mod+alt+m') && comments.enabled.value) {\n const id = widgetHolding(event.target)\n if (id && comments.start(id)) event.preventDefault()\n return\n }\n if (!props.editable) return\n if (matchesHotkey(event, 'mod+z')) {\n event.preventDefault()\n undo()\n return\n }\n if (matchesHotkey(event, 'mod+shift+z') || matchesHotkey(event, 'mod+y')) {\n event.preventDefault()\n redo()\n return\n }\n const id = focusedId(event.target)\n if (!id || dragging.value) return\n if (event.key === 'Delete' || event.key === 'Backspace') {\n event.preventDefault()\n removeWidget(id)\n } else if (matchesHotkey(event, 'mod+d')) {\n event.preventDefault()\n duplicateWidget(id)\n }\n}\n\nfunction onCopy(event: ClipboardEvent, cut = false) {\n if (isTyping(event.target)) return\n const id = focusedId(event.target)\n const widget = id && widgetMap.value.get(id)\n if (!widget || !event.clipboardData) return\n event.clipboardData.setData('text/plain', JSON.stringify({ kind: CLIPBOARD_KIND, widget }))\n event.preventDefault()\n if (cut && props.editable) removeWidget(widget.id)\n}\n\nfunction onPaste(event: ClipboardEvent) {\n if (!props.editable || isTyping(event.target)) return\n let data: { kind?: string; widget?: DashboardWidget } | undefined\n try {\n data = JSON.parse(event.clipboardData?.getData('text/plain') ?? '')\n } catch {\n return\n }\n const widget = data?.kind === CLIPBOARD_KIND ? data.widget : undefined\n if (!widget?.type || !typeMap.value.has(widget.type)) return\n event.preventDefault()\n const id = focusedId(event.target)\n addCopy(widget, id ? items.value.find((item) => item.id === id) : undefined)\n}\n\n// ---------------------------------------------------------------------------\n// Export and print\n\n/** Every widget drawn in full, not only those near the view. */\nconst showAll = ref(false)\nconst nextFrame = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))\n\n/** Draws every widget (unless `all` is false) and waits for their sources (at most `timeout` ms) and one more frame. */\nasync function settle(timeout = 5000, all = true) {\n if (all) showAll.value = true\n await nextTick()\n const start = performance.now()\n await nextFrame()\n while (store.busy() && performance.now() - start < timeout) await nextFrame()\n await nextFrame()\n await nextFrame()\n}\n\n/** The dashboard as it is shown, every widget drawn, as a PNG image. */\nasync function exportPNG(options?: DashboardImageOptions) {\n await settle()\n const el = root.value!\n // the controls are left out; the copy is made before the class goes\n el.classList.add('is-exporting')\n try {\n return elementToPNG(el, options)\n } finally {\n el.classList.remove('is-exporting')\n }\n}\n\n/** Draws every widget, waits for their data, and opens the browser's print dialog. */\nasync function print() {\n await settle()\n window.print()\n}\n\n// printing from the browser's menu: draw what is far from the view, as far as there is time\nfunction onBeforePrint() {\n showAll.value = true\n}\nonMounted(() => window.addEventListener('beforeprint', onBeforePrint))\nonBeforeUnmount(() => window.removeEventListener('beforeprint', onBeforePrint))\n\n// ---------------------------------------------------------------------------\n// Announcements\n\nconst live = ref('')\nfunction say(text: string) {\n live.value = ''\n requestAnimationFrame(() => (live.value = text))\n}\n\nconst breakpointName = shallowRef(main.value.name)\nfunction onBreakpoint(name: string) {\n breakpointName.value = name\n emit('breakpointChange', name)\n}\n\nconst minHeight = computed(() => {\n const gap = Array.isArray(props.gap) ? props.gap[1] : props.gap\n return `${props.rowHeight * 3 + gap * 2}px`\n})\n\ndefineExpose<DashboardExpose>({\n transaction,\n dispatch,\n undo,\n redo,\n get canUndo() {\n return canUndo.value\n },\n get canRedo() {\n return canRedo.value\n },\n addWidget,\n removeWidget,\n comment: (id = widgetHolding(document.activeElement)) => !!id && comments.start(id),\n getCommentAnchors: comments.anchors,\n duplicateWidget,\n focusWidget,\n openSettings,\n openFullscreen,\n startAdd,\n get types() {\n return props.widgets\n },\n get breakpoint() {\n return grid.value?.breakpoint ?? breakpointName.value\n },\n get editable() {\n return props.editable\n },\n get document() {\n return model.value\n },\n get filters() {\n return values.value\n },\n get linkedFilters() {\n return [...links.value.values()]\n },\n setFilter: (key, value, options) => setFilter(undefined, key, value, options),\n clearFilters,\n refresh: (source) => store.reload(source),\n exportPNG,\n exportWidget: exportWidget as DashboardExpose['exportWidget'],\n saveWidget: saveWidget as DashboardExpose['saveWidget'],\n print,\n})\n</script>\n\n<template>\n <div\n ref=\"root\"\n :class=\"['dou-dashboard', editable && 'is-editable']\"\n role=\"region\"\n :aria-label=\"label || messages.dashboard\"\n tabindex=\"-1\"\n @keydown=\"onKeydown\"\n @copy=\"onCopy($event)\"\n @cut=\"onCopy($event, true)\"\n @paste=\"onPaste\"\n @focusin=\"onFocusIn\"\n @focusout=\"onFocusOut\"\n >\n <GridLayout\n ref=\"grid\"\n :model-value=\"items\"\n :layouts=\"layouts\"\n :columns=\"columns\"\n :breakpoints=\"breakpoints\"\n :row-height=\"rowHeight\"\n :gap=\"gap\"\n :compact=\"compact\"\n :editable=\"editable\"\n :handle=\"true\"\n :item-label=\"labelOf\"\n :messages=\"messages\"\n :style=\"editable && !model.widgets.length ? { minHeight } : undefined\"\n @update:model-value=\"(list) => gather({ items: list })\"\n @update:layouts=\"(next) => gather({ layouts: next })\"\n @drop=\"onDrop\"\n @drag-start=\"onDragStart\"\n @drag-end=\"onDragEnd\"\n @breakpoint-change=\"onBreakpoint\"\n >\n <template #item=\"{ item }\">\n <WidgetFrame :id=\"item.id\" />\n </template>\n </GridLayout>\n <div v-if=\"editable && !model.widgets.length\" class=\"dou-dashboard__empty\">\n <DouEmpty size=\"sm\" :title=\"messages.emptyTitle\" :description=\"messages.emptyHint\">\n <template #icon><LayoutGridPlusIcon /></template>\n </DouEmpty>\n </div>\n <div class=\"dou-dashboard__live\" aria-live=\"polite\" aria-atomic=\"true\">{{ live }}</div>\n <!-- in fullscreen the card goes in the dialog, which keeps the focus to itself -->\n <CommentCard\n v-if=\"comments.enabled.value && !fullscreenWidget\"\n :comments=\"comments\"\n :messages=\"messages\"\n :button-of=\"buttonOf\"\n :place-of=\"cardSpotOf\"\n />\n <DouDrawer\n :open=\"!!settingsWidget\"\n :title=\"\n settingsWidget ? format(messages.settingsTitle, { title: titleOf(settingsWidget) }) : ''\n \"\n :size=\"360\"\n @update:open=\"(open) => !open && (settingsId = undefined)\"\n @closed=\"restoreFocus\"\n >\n <div v-if=\"settingsWidget\" class=\"dou-dashboard__settings\">\n <label class=\"dou-dashboard__field\">\n <span class=\"dou-dashboard__field-label\">{{ messages.titleField }}</span>\n <DouInput\n :model-value=\"settingsWidget.title ?? ''\"\n :placeholder=\"typeOf(settingsWidget)?.label ?? settingsWidget.type\"\n @update:model-value=\"\n (value) =>\n run(\n [\n {\n type: 'update',\n id: settingsWidget!.id,\n patch: { title: value || undefined },\n },\n ],\n { origin: 'settings', group: `title:${settingsWidget!.id}` },\n )\n \"\n />\n </label>\n <WidgetSettings\n v-if=\"typeOf(settingsWidget)?.settings\"\n :widget=\"settingsWidget\"\n :type=\"typeOf(settingsWidget)!\"\n :set-props=\"setProps\"\n @update:props=\"\n (value) =>\n run([{ type: 'update', id: settingsWidget!.id, patch: { props: value } }], {\n origin: 'settings',\n group: `props:${settingsWidget!.id}`,\n })\n \"\n />\n </div>\n </DouDrawer>\n <DouDialog\n :open=\"!!fullscreenWidget\"\n :title=\"fullscreenWidget ? titleOf(fullscreenWidget) : ''\"\n fullscreen\n @update:open=\"(open) => !open && (fullscreenId = undefined)\"\n @closed=\"restoreFocus\"\n @keydown=\"onFullscreenKeydown\"\n >\n <div v-if=\"fullscreenWidget\" ref=\"fullscreenBody\" class=\"dou-dashboard__fullscreen\">\n <div v-if=\"comments.enabled.value\" class=\"dou-dashboard__fullscreen-bar\">\n <CommentButton\n :id=\"fullscreenWidget.id\"\n :comments=\"comments\"\n :messages=\"messages\"\n tooltip-placement=\"left\"\n />\n </div>\n <WidgetBody\n class=\"is-fullscreen\"\n :widget=\"fullscreenWidget\"\n :type=\"typeOf(fullscreenWidget)\"\n :editing=\"false\"\n fullscreen\n :messages=\"messages\"\n :set-props=\"setProps\"\n />\n <CommentCard\n v-if=\"comments.enabled.value\"\n :comments=\"comments\"\n :messages=\"messages\"\n :button-of=\"buttonOf\"\n :place-of=\"cardSpotOf\"\n />\n </div>\n </DouDialog>\n </div>\n</template>\n"],"mappings":""}
|
|
@@ -13,8 +13,11 @@ import { createObservers, observersKey } from "./observers.js";
|
|
|
13
13
|
import { elementToPNG } from "./export.js";
|
|
14
14
|
import widget_body_default from "./widget-body.js";
|
|
15
15
|
import { frameContextKey } from "./frame-context.js";
|
|
16
|
+
import comment_button_default from "./comment-button.js";
|
|
16
17
|
import widget_frame_default from "./widget-frame.js";
|
|
17
18
|
import widget_settings_default from "./widget-settings.js";
|
|
19
|
+
import comment_card_default from "./comment-card.js";
|
|
20
|
+
import { useDashboardComments } from "./comments.js";
|
|
18
21
|
import { DouDialog, DouDrawer, DouEmpty, DouInput, downloadBlob, matchesHotkey, useLocale } from "dou-ui";
|
|
19
22
|
import { computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createVNode, defineComponent, mergeModels, nextTick, normalizeClass, normalizeStyle, onBeforeUnmount, onMounted, openBlock, provide, ref, shallowReactive, shallowRef, toDisplayString, toRaw, unref, useModel, watch, withCtx } from "vue";
|
|
20
23
|
import { LayoutGridPlusIcon, fileIconOf } from "@dou-ui/icons";
|
|
@@ -35,6 +38,10 @@ var _hoisted_4 = {
|
|
|
35
38
|
};
|
|
36
39
|
var _hoisted_5 = { class: "dou-dashboard__field" };
|
|
37
40
|
var _hoisted_6 = { class: "dou-dashboard__field-label" };
|
|
41
|
+
var _hoisted_7 = {
|
|
42
|
+
key: 0,
|
|
43
|
+
class: "dou-dashboard__fullscreen-bar"
|
|
44
|
+
};
|
|
38
45
|
var EAGER = 16;
|
|
39
46
|
var CLIPBOARD_KIND = "dou-dashboard/widget";
|
|
40
47
|
var dashboard_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ defineComponent({
|
|
@@ -56,7 +63,14 @@ var dashboard_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ define
|
|
|
56
63
|
messages: {},
|
|
57
64
|
createId: {},
|
|
58
65
|
collaboration: {},
|
|
59
|
-
exporters: {}
|
|
66
|
+
exporters: {},
|
|
67
|
+
comments: { default: void 0 },
|
|
68
|
+
author: { default: void 0 },
|
|
69
|
+
activeComment: { default: void 0 },
|
|
70
|
+
commentable: {
|
|
71
|
+
type: Boolean,
|
|
72
|
+
default: true
|
|
73
|
+
}
|
|
60
74
|
}, {
|
|
61
75
|
"modelValue": { default: emptyDocument },
|
|
62
76
|
"modelModifiers": {},
|
|
@@ -66,7 +80,10 @@ var dashboard_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ define
|
|
|
66
80
|
emits: /*@__PURE__*/ mergeModels([
|
|
67
81
|
"change",
|
|
68
82
|
"breakpointChange",
|
|
69
|
-
"exportError"
|
|
83
|
+
"exportError",
|
|
84
|
+
"update:comments",
|
|
85
|
+
"update:activeComment",
|
|
86
|
+
"commentAction"
|
|
70
87
|
], ["update:modelValue", "update:filters"]),
|
|
71
88
|
setup(__props, { expose: __expose, emit: __emit }) {
|
|
72
89
|
const props = __props;
|
|
@@ -134,6 +151,32 @@ var dashboard_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ define
|
|
|
134
151
|
dispatch(tr);
|
|
135
152
|
return tr;
|
|
136
153
|
}
|
|
154
|
+
const comments = useDashboardComments({
|
|
155
|
+
props,
|
|
156
|
+
emit,
|
|
157
|
+
model,
|
|
158
|
+
run,
|
|
159
|
+
carry: (id, patch) => history.carry(id, patch)
|
|
160
|
+
});
|
|
161
|
+
/** The full screen view, which has a comment button of its own. */
|
|
162
|
+
const fullscreenBody = shallowRef(null);
|
|
163
|
+
/** A widget's comment button: in the full screen view while that widget is there. */
|
|
164
|
+
function buttonOf(id) {
|
|
165
|
+
const selector = `[data-dou-comment="${CSS.escape(id)}"]`;
|
|
166
|
+
return (fullscreenId.value === id ? fullscreenBody.value : null)?.querySelector(selector) ?? root.value?.querySelector(selector) ?? null;
|
|
167
|
+
}
|
|
168
|
+
/** Where a widget's card goes: by its comment button, or by the widget until the button is drawn. */
|
|
169
|
+
const cardSpotOf = (id) => buttonOf(id) ?? root.value?.querySelector(`[data-grid-id="${CSS.escape(id)}"]`) ?? null;
|
|
170
|
+
/** The comment shortcut in the full screen view, which is outside the dashboard. */
|
|
171
|
+
function onFullscreenKeydown(event) {
|
|
172
|
+
const id = fullscreenId.value;
|
|
173
|
+
if (!id || event.defaultPrevented || !comments.enabled.value || isTyping(event.target)) return;
|
|
174
|
+
if (matchesHotkey(event, "mod+alt+m") && comments.start(id)) event.preventDefault();
|
|
175
|
+
}
|
|
176
|
+
watch(() => props.activeComment, (id) => {
|
|
177
|
+
const widget = id && !comments.openedHere(id) ? comments.widgetOf(id) : null;
|
|
178
|
+
if (widget) grid.value?.scrollToItem(widget);
|
|
179
|
+
});
|
|
137
180
|
function undo() {
|
|
138
181
|
if (props.collaboration) return props.collaboration.undo();
|
|
139
182
|
const tr = history.undo(model.value);
|
|
@@ -392,6 +435,7 @@ var dashboard_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ define
|
|
|
392
435
|
id,
|
|
393
436
|
place: { [main.value.name]: place }
|
|
394
437
|
};
|
|
438
|
+
delete widget.comments;
|
|
395
439
|
run([{
|
|
396
440
|
type: "add",
|
|
397
441
|
widget,
|
|
@@ -503,7 +547,7 @@ var dashboard_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ define
|
|
|
503
547
|
held = true;
|
|
504
548
|
return;
|
|
505
549
|
}
|
|
506
|
-
const tr = replaceWidgets(new DashboardTransaction(model.value), doc);
|
|
550
|
+
const tr = replaceWidgets(new DashboardTransaction(model.value), doc, { keepComments: false });
|
|
507
551
|
if (!tr.changed) return;
|
|
508
552
|
tr.setMeta("history", false).setMeta("origin", origin);
|
|
509
553
|
const ids = new Set(tr.doc.widgets.map((widget) => widget.id));
|
|
@@ -582,6 +626,7 @@ var dashboard_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ define
|
|
|
582
626
|
}, { immediate: true });
|
|
583
627
|
const NO_PEERS = [];
|
|
584
628
|
provide(frameContextKey, {
|
|
629
|
+
comments,
|
|
585
630
|
widget: (id) => widgetMap.value.get(id),
|
|
586
631
|
type: typeOf,
|
|
587
632
|
title: titleOf,
|
|
@@ -801,13 +846,24 @@ var dashboard_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ define
|
|
|
801
846
|
"color"
|
|
802
847
|
].includes(el.type);
|
|
803
848
|
}
|
|
849
|
+
/** The widget an element is in, such as the one holding the focus. */
|
|
850
|
+
function widgetHolding(target) {
|
|
851
|
+
const el = target?.closest?.("[data-grid-id]");
|
|
852
|
+
return el && el.closest(".dou-dashboard") === root.value ? el.dataset.gridId : void 0;
|
|
853
|
+
}
|
|
804
854
|
/** The widget whose item itself has focus (not a control inside it). */
|
|
805
855
|
function focusedId(target) {
|
|
806
856
|
const el = target;
|
|
807
857
|
return el?.dataset?.gridId && el.closest(".dou-dashboard") === root.value ? el.dataset.gridId : void 0;
|
|
808
858
|
}
|
|
809
859
|
function onKeydown(event) {
|
|
810
|
-
if (
|
|
860
|
+
if (event.defaultPrevented || isTyping(event.target)) return;
|
|
861
|
+
if (matchesHotkey(event, "mod+alt+m") && comments.enabled.value) {
|
|
862
|
+
const id = widgetHolding(event.target);
|
|
863
|
+
if (id && comments.start(id)) event.preventDefault();
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
if (!props.editable) return;
|
|
811
867
|
if (matchesHotkey(event, "mod+z")) {
|
|
812
868
|
event.preventDefault();
|
|
813
869
|
undo();
|
|
@@ -915,6 +971,8 @@ var dashboard_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ define
|
|
|
915
971
|
},
|
|
916
972
|
addWidget,
|
|
917
973
|
removeWidget,
|
|
974
|
+
comment: (id = widgetHolding(document.activeElement)) => !!id && comments.start(id),
|
|
975
|
+
getCommentAnchors: comments.anchors,
|
|
918
976
|
duplicateWidget,
|
|
919
977
|
focusWidget,
|
|
920
978
|
openSettings,
|
|
@@ -1006,6 +1064,13 @@ var dashboard_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ define
|
|
|
1006
1064
|
_: 1
|
|
1007
1065
|
}, 8, ["title", "description"])])) : createCommentVNode("", true),
|
|
1008
1066
|
createElementVNode("div", _hoisted_3, toDisplayString(live.value), 1),
|
|
1067
|
+
unref(comments).enabled.value && !fullscreenWidget.value ? (openBlock(), createBlock(comment_card_default, {
|
|
1068
|
+
key: 1,
|
|
1069
|
+
comments: unref(comments),
|
|
1070
|
+
messages: unref(messages),
|
|
1071
|
+
"button-of": buttonOf,
|
|
1072
|
+
"place-of": cardSpotOf
|
|
1073
|
+
}, null, 8, ["comments", "messages"])) : createCommentVNode("", true),
|
|
1009
1074
|
createVNode(unref(DouDrawer), {
|
|
1010
1075
|
open: !!settingsWidget.value,
|
|
1011
1076
|
title: settingsWidget.value ? unref(format)(unref(messages).settingsTitle, { title: titleOf(settingsWidget.value) }) : "",
|
|
@@ -1045,22 +1110,46 @@ var dashboard_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ define
|
|
|
1045
1110
|
title: fullscreenWidget.value ? titleOf(fullscreenWidget.value) : "",
|
|
1046
1111
|
fullscreen: "",
|
|
1047
1112
|
"onUpdate:open": _cache[5] || (_cache[5] = (open) => !open && (fullscreenId.value = void 0)),
|
|
1048
|
-
onClosed: restoreFocus
|
|
1113
|
+
onClosed: restoreFocus,
|
|
1114
|
+
onKeydown: onFullscreenKeydown
|
|
1049
1115
|
}, {
|
|
1050
|
-
default: withCtx(() => [fullscreenWidget.value ? (openBlock(),
|
|
1116
|
+
default: withCtx(() => [fullscreenWidget.value ? (openBlock(), createElementBlock("div", {
|
|
1051
1117
|
key: 0,
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1118
|
+
ref_key: "fullscreenBody",
|
|
1119
|
+
ref: fullscreenBody,
|
|
1120
|
+
class: "dou-dashboard__fullscreen"
|
|
1121
|
+
}, [
|
|
1122
|
+
unref(comments).enabled.value ? (openBlock(), createElementBlock("div", _hoisted_7, [createVNode(comment_button_default, {
|
|
1123
|
+
id: fullscreenWidget.value.id,
|
|
1124
|
+
comments: unref(comments),
|
|
1125
|
+
messages: unref(messages),
|
|
1126
|
+
"tooltip-placement": "left"
|
|
1127
|
+
}, null, 8, [
|
|
1128
|
+
"id",
|
|
1129
|
+
"comments",
|
|
1130
|
+
"messages"
|
|
1131
|
+
])])) : createCommentVNode("", true),
|
|
1132
|
+
createVNode(widget_body_default, {
|
|
1133
|
+
class: "is-fullscreen",
|
|
1134
|
+
widget: fullscreenWidget.value,
|
|
1135
|
+
type: typeOf(fullscreenWidget.value),
|
|
1136
|
+
editing: false,
|
|
1137
|
+
fullscreen: "",
|
|
1138
|
+
messages: unref(messages),
|
|
1139
|
+
"set-props": setProps
|
|
1140
|
+
}, null, 8, [
|
|
1141
|
+
"widget",
|
|
1142
|
+
"type",
|
|
1143
|
+
"messages"
|
|
1144
|
+
]),
|
|
1145
|
+
unref(comments).enabled.value ? (openBlock(), createBlock(comment_card_default, {
|
|
1146
|
+
key: 1,
|
|
1147
|
+
comments: unref(comments),
|
|
1148
|
+
messages: unref(messages),
|
|
1149
|
+
"button-of": buttonOf,
|
|
1150
|
+
"place-of": cardSpotOf
|
|
1151
|
+
}, null, 8, ["comments", "messages"])) : createCommentVNode("", true)
|
|
1152
|
+
], 512)) : createCommentVNode("", true)]),
|
|
1064
1153
|
_: 1
|
|
1065
1154
|
}, 8, ["open", "title"])
|
|
1066
1155
|
], 42, _hoisted_1);
|