@dou-ui/dashboard 0.1.0-alpha.4 → 0.1.0-alpha.5

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.
Files changed (73) hide show
  1. package/dist/chart/chart-settings.js.map +1 -1
  2. package/dist/chart/chart-settings.vue_vue_type_script_setup_true_lang.js +10 -8
  3. package/dist/chart/chart-settings.vue_vue_type_script_setup_true_lang.js.map +1 -1
  4. package/dist/chart/chart.js.map +1 -1
  5. package/dist/chart/chart.vue_vue_type_script_setup_true_lang.js +19 -15
  6. package/dist/chart/chart.vue_vue_type_script_setup_true_lang.js.map +1 -1
  7. package/dist/chart/read-props.js +26 -0
  8. package/dist/chart/read-props.js.map +1 -0
  9. package/dist/dashboard/dashboard.js.map +1 -1
  10. package/dist/dashboard/dashboard.vue_vue_type_script_setup_true_lang.js +27 -4
  11. package/dist/dashboard/dashboard.vue_vue_type_script_setup_true_lang.js.map +1 -1
  12. package/dist/dashboard/error-boundary.js +64 -0
  13. package/dist/dashboard/error-boundary.js.map +1 -0
  14. package/dist/dashboard/frame-context.js.map +1 -1
  15. package/dist/dashboard/widget-body.js.map +1 -1
  16. package/dist/dashboard/widget-body.vue_vue_type_script_setup_true_lang.js +53 -12
  17. package/dist/dashboard/widget-body.vue_vue_type_script_setup_true_lang.js.map +1 -1
  18. package/dist/dashboard/widget-frame.js.map +1 -1
  19. package/dist/dashboard/widget-frame.vue_vue_type_script_setup_true_lang.js +4 -2
  20. package/dist/dashboard/widget-frame.vue_vue_type_script_setup_true_lang.js.map +1 -1
  21. package/dist/dashboard/widget-settings.js.map +1 -1
  22. package/dist/dashboard/widget-settings.vue_vue_type_script_setup_true_lang.js +31 -5
  23. package/dist/dashboard/widget-settings.vue_vue_type_script_setup_true_lang.js.map +1 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/locale.js +6 -0
  26. package/dist/locale.js.map +1 -1
  27. package/dist/pivot/pivot-settings.js.map +1 -1
  28. package/dist/pivot/pivot-settings.vue_vue_type_script_setup_true_lang.js +6 -4
  29. package/dist/pivot/pivot-settings.vue_vue_type_script_setup_true_lang.js.map +1 -1
  30. package/dist/pivot/pivot.js.map +1 -1
  31. package/dist/pivot/pivot.vue_vue_type_script_setup_true_lang.js +4 -2
  32. package/dist/pivot/pivot.vue_vue_type_script_setup_true_lang.js.map +1 -1
  33. package/dist/pivot/read-props.js +44 -0
  34. package/dist/pivot/read-props.js.map +1 -0
  35. package/dist/style.css +1 -1
  36. package/dist/types/chart/read-props.d.ts +3 -0
  37. package/dist/types/dashboard/dashboard.vue.d.ts +2 -0
  38. package/dist/types/dashboard/error-boundary.d.ts +12 -0
  39. package/dist/types/dashboard/frame-context.d.ts +2 -0
  40. package/dist/types/dashboard/types.d.ts +17 -0
  41. package/dist/types/dashboard/widget-body.vue.d.ts +2 -0
  42. package/dist/types/dashboard/widget-settings.vue.d.ts +4 -0
  43. package/dist/types/index.d.ts +3 -1
  44. package/dist/types/locale.d.ts +4 -0
  45. package/dist/types/pivot/read-props.d.ts +3 -0
  46. package/dist/types/widgets/index.d.ts +1 -1
  47. package/dist/types/widgets/read-props.d.ts +4 -0
  48. package/dist/types/widgets/shared.d.ts +20 -4
  49. package/dist/widgets/index.js +2 -2
  50. package/dist/widgets/index.js.map +1 -1
  51. package/dist/widgets/read-props.js +52 -0
  52. package/dist/widgets/read-props.js.map +1 -0
  53. package/dist/widgets/shared.js +27 -5
  54. package/dist/widgets/shared.js.map +1 -1
  55. package/dist/widgets/stat-settings.js.map +1 -1
  56. package/dist/widgets/stat-settings.vue_vue_type_script_setup_true_lang.js +12 -10
  57. package/dist/widgets/stat-settings.vue_vue_type_script_setup_true_lang.js.map +1 -1
  58. package/dist/widgets/stat.js.map +1 -1
  59. package/dist/widgets/stat.vue_vue_type_script_setup_true_lang.js +10 -13
  60. package/dist/widgets/stat.vue_vue_type_script_setup_true_lang.js.map +1 -1
  61. package/dist/widgets/table-settings.js.map +1 -1
  62. package/dist/widgets/table-settings.vue_vue_type_script_setup_true_lang.js +6 -4
  63. package/dist/widgets/table-settings.vue_vue_type_script_setup_true_lang.js.map +1 -1
  64. package/dist/widgets/table.js.map +1 -1
  65. package/dist/widgets/table.vue_vue_type_script_setup_true_lang.js +3 -1
  66. package/dist/widgets/table.vue_vue_type_script_setup_true_lang.js.map +1 -1
  67. package/dist/widgets/text-settings.js.map +1 -1
  68. package/dist/widgets/text-settings.vue_vue_type_script_setup_true_lang.js +5 -3
  69. package/dist/widgets/text-settings.vue_vue_type_script_setup_true_lang.js.map +1 -1
  70. package/dist/widgets/text.js.map +1 -1
  71. package/dist/widgets/text.vue_vue_type_script_setup_true_lang.js +5 -3
  72. package/dist/widgets/text.vue_vue_type_script_setup_true_lang.js.map +1 -1
  73. package/package.json +8 -8
@@ -1 +1 @@
1
- {"version":3,"file":"chart-settings.js","names":["_sfc_main"],"sources":["../../src/chart/chart-settings.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { DouInput, DouSegmentedControl, DouSwitch } from 'dou-ui'\nimport { computed } from 'vue'\nimport { useDashboardMessages } from '../locale'\nimport { useDashboardWidget } from '../widget/context'\nimport FieldSelect from '../widgets/field-select.vue'\nimport SettingsField from '../widgets/settings-field.vue'\nimport { fieldsOf, numberFieldsOf, rowsOf } from '../widgets/shared'\nimport SourceField from '../widgets/source-field.vue'\nimport type { ChartWidgetProps } from './types'\n\ndefineOptions({ name: 'DouDashboardChartSettings' })\n\nconst model = defineModel<ChartWidgetProps>({ required: true })\nconst messages = useDashboardMessages()\nconst { data } = useDashboardWidget()\nconst rows = computed(() => rowsOf(data.value, model.value.path))\nconst set = (patch: Partial<ChartWidgetProps>) => (model.value = { ...model.value, ...patch })\nconst kind = computed(() => model.value.kind ?? 'line')\n</script>\n\n<template>\n <div class=\"dou-dashboard__settings\">\n <SourceField :model-value=\"model.source\" @update:model-value=\"(source) => set({ source })\" />\n <SettingsField :label=\"messages.chartKind\">\n <DouSegmentedControl\n :model-value=\"kind\"\n :options=\"[\n { value: 'line', label: messages.chartLine },\n { value: 'area', label: messages.chartArea },\n { value: 'bar', label: messages.chartBar },\n { value: 'pie', label: messages.chartPie },\n ]\"\n :aria-label=\"messages.chartKind\"\n @update:model-value=\"(value) => set({ kind: value as ChartWidgetProps['kind'] })\"\n />\n </SettingsField>\n <FieldSelect\n :model-value=\"model.x\"\n :label=\"messages.xField\"\n :fields=\"fieldsOf(rows)\"\n @update:model-value=\"(x) => set({ x: x as string | undefined })\"\n />\n <FieldSelect\n :model-value=\"kind === 'pie' ? (model.y ?? []).slice(0, 1) : model.y\"\n :label=\"messages.yFields\"\n :fields=\"numberFieldsOf(rows)\"\n :multiple=\"kind !== 'pie'\"\n @update:model-value=\"\n (y) => set({ y: y === undefined ? undefined : Array.isArray(y) ? y : [y] })\n \"\n />\n <div v-if=\"kind === 'bar' || kind === 'area'\" class=\"dou-dashboard__field-row\">\n <DouSwitch\n :model-value=\"!!model.stacked\"\n @update:model-value=\"(stacked) => set({ stacked: stacked || undefined })\"\n >\n {{ messages.stacked }}\n </DouSwitch>\n <DouSwitch\n v-if=\"kind === 'bar'\"\n :model-value=\"!!model.horizontal\"\n @update:model-value=\"(horizontal) => set({ horizontal: horizontal || undefined })\"\n >\n {{ messages.horizontal }}\n </DouSwitch>\n </div>\n <SettingsField :label=\"messages.chartFilter\" :hint=\"messages.chartFilterHint\">\n <DouInput\n :model-value=\"model.filter ?? ''\"\n :aria-label=\"messages.chartFilter\"\n @update:model-value=\"(key) => set({ filter: key.trim() || undefined })\"\n />\n </SettingsField>\n </div>\n</template>\n"],"mappings":""}
1
+ {"version":3,"file":"chart-settings.js","names":["_sfc_main"],"sources":["../../src/chart/chart-settings.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { DouInput, DouSegmentedControl, DouSwitch } from 'dou-ui'\nimport { computed } from 'vue'\nimport { useDashboardMessages } from '../locale'\nimport { useDashboardWidget } from '../widget/context'\nimport FieldSelect from '../widgets/field-select.vue'\nimport SettingsField from '../widgets/settings-field.vue'\nimport { fieldsOf, numberFieldsOf, rowsOf } from '../widgets/shared'\nimport SourceField from '../widgets/source-field.vue'\nimport type { ChartWidgetProps } from './types'\nimport { chartPropsOf } from './read-props'\n\ndefineOptions({ name: 'DouDashboardChartSettings' })\n\nconst model = defineModel<ChartWidgetProps>({ required: true })\n// the props as saved may have the wrong types: read them as the widget does\nconst view = computed(() => chartPropsOf(model.value as Record<string, unknown>))\nconst messages = useDashboardMessages()\nconst { data } = useDashboardWidget()\nconst rows = computed(() => rowsOf(data.value, view.value.path))\nconst set = (patch: Partial<ChartWidgetProps>) => (model.value = { ...model.value, ...patch })\nconst kind = computed(() => view.value.kind ?? 'line')\n</script>\n\n<template>\n <div class=\"dou-dashboard__settings\">\n <SourceField :model-value=\"view.source\" @update:model-value=\"(source) => set({ source })\" />\n <SettingsField :label=\"messages.chartKind\">\n <DouSegmentedControl\n :model-value=\"kind\"\n :options=\"[\n { value: 'line', label: messages.chartLine },\n { value: 'area', label: messages.chartArea },\n { value: 'bar', label: messages.chartBar },\n { value: 'pie', label: messages.chartPie },\n ]\"\n :aria-label=\"messages.chartKind\"\n @update:model-value=\"(value) => set({ kind: value as ChartWidgetProps['kind'] })\"\n />\n </SettingsField>\n <FieldSelect\n :model-value=\"view.x\"\n :label=\"messages.xField\"\n :fields=\"fieldsOf(rows)\"\n @update:model-value=\"(x) => set({ x: x as string | undefined })\"\n />\n <FieldSelect\n :model-value=\"kind === 'pie' ? (view.y ?? []).slice(0, 1) : view.y\"\n :label=\"messages.yFields\"\n :fields=\"numberFieldsOf(rows)\"\n :multiple=\"kind !== 'pie'\"\n @update:model-value=\"\n (y) => set({ y: y === undefined ? undefined : Array.isArray(y) ? y : [y] })\n \"\n />\n <div v-if=\"kind === 'bar' || kind === 'area'\" class=\"dou-dashboard__field-row\">\n <DouSwitch\n :model-value=\"!!view.stacked\"\n @update:model-value=\"(stacked) => set({ stacked: stacked || undefined })\"\n >\n {{ messages.stacked }}\n </DouSwitch>\n <DouSwitch\n v-if=\"kind === 'bar'\"\n :model-value=\"!!view.horizontal\"\n @update:model-value=\"(horizontal) => set({ horizontal: horizontal || undefined })\"\n >\n {{ messages.horizontal }}\n </DouSwitch>\n </div>\n <SettingsField :label=\"messages.chartFilter\" :hint=\"messages.chartFilterHint\">\n <DouInput\n :model-value=\"view.filter ?? ''\"\n :aria-label=\"messages.chartFilter\"\n @update:model-value=\"(key) => set({ filter: key.trim() || undefined })\"\n />\n </SettingsField>\n </div>\n</template>\n"],"mappings":""}
@@ -4,6 +4,7 @@ import { fieldsOf, numberFieldsOf, rowsOf } from "../widgets/shared.js";
4
4
  import settings_field_default from "../widgets/settings-field.js";
5
5
  import field_select_default from "../widgets/field-select.js";
6
6
  import source_field_default from "../widgets/source-field.js";
7
+ import { chartPropsOf } from "./read-props.js";
7
8
  import { DouInput, DouSegmentedControl, DouSwitch } from "dou-ui";
8
9
  import { computed, createBlock, createCommentVNode, createElementBlock, createTextVNode, createVNode, defineComponent, openBlock, toDisplayString, unref, useModel, withCtx } from "vue";
9
10
  // src/chart/chart-settings.vue?vue&type=script&setup=true&lang.ts
@@ -22,18 +23,19 @@ var chart_settings_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ d
22
23
  emits: ["update:modelValue"],
23
24
  setup(__props) {
24
25
  const model = useModel(__props, "modelValue");
26
+ const view = computed(() => chartPropsOf(model.value));
25
27
  const messages = useDashboardMessages();
26
28
  const { data } = useDashboardWidget();
27
- const rows = computed(() => rowsOf(data.value, model.value.path));
29
+ const rows = computed(() => rowsOf(data.value, view.value.path));
28
30
  const set = (patch) => model.value = {
29
31
  ...model.value,
30
32
  ...patch
31
33
  };
32
- const kind = computed(() => model.value.kind ?? "line");
34
+ const kind = computed(() => view.value.kind ?? "line");
33
35
  return (_ctx, _cache) => {
34
36
  return openBlock(), createElementBlock("div", _hoisted_1, [
35
37
  createVNode(source_field_default, {
36
- "model-value": model.value.source,
38
+ "model-value": view.value.source,
37
39
  "onUpdate:modelValue": _cache[0] || (_cache[0] = (source) => set({ source }))
38
40
  }, null, 8, ["model-value"]),
39
41
  createVNode(settings_field_default, { label: unref(messages).chartKind }, {
@@ -67,7 +69,7 @@ var chart_settings_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ d
67
69
  _: 1
68
70
  }, 8, ["label"]),
69
71
  createVNode(field_select_default, {
70
- "model-value": model.value.x,
72
+ "model-value": view.value.x,
71
73
  label: unref(messages).xField,
72
74
  fields: unref(fieldsOf)(rows.value),
73
75
  "onUpdate:modelValue": _cache[2] || (_cache[2] = (x) => set({ x }))
@@ -77,7 +79,7 @@ var chart_settings_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ d
77
79
  "fields"
78
80
  ]),
79
81
  createVNode(field_select_default, {
80
- "model-value": kind.value === "pie" ? (model.value.y ?? []).slice(0, 1) : model.value.y,
82
+ "model-value": kind.value === "pie" ? (view.value.y ?? []).slice(0, 1) : view.value.y,
81
83
  label: unref(messages).yFields,
82
84
  fields: unref(numberFieldsOf)(rows.value),
83
85
  multiple: kind.value !== "pie",
@@ -89,14 +91,14 @@ var chart_settings_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ d
89
91
  "multiple"
90
92
  ]),
91
93
  kind.value === "bar" || kind.value === "area" ? (openBlock(), createElementBlock("div", _hoisted_2, [createVNode(unref(DouSwitch), {
92
- "model-value": !!model.value.stacked,
94
+ "model-value": !!view.value.stacked,
93
95
  "onUpdate:modelValue": _cache[4] || (_cache[4] = (stacked) => set({ stacked: stacked || void 0 }))
94
96
  }, {
95
97
  default: withCtx(() => [createTextVNode(toDisplayString(unref(messages).stacked), 1)]),
96
98
  _: 1
97
99
  }, 8, ["model-value"]), kind.value === "bar" ? (openBlock(), createBlock(unref(DouSwitch), {
98
100
  key: 0,
99
- "model-value": !!model.value.horizontal,
101
+ "model-value": !!view.value.horizontal,
100
102
  "onUpdate:modelValue": _cache[5] || (_cache[5] = (horizontal) => set({ horizontal: horizontal || void 0 }))
101
103
  }, {
102
104
  default: withCtx(() => [createTextVNode(toDisplayString(unref(messages).horizontal), 1)]),
@@ -107,7 +109,7 @@ var chart_settings_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ d
107
109
  hint: unref(messages).chartFilterHint
108
110
  }, {
109
111
  default: withCtx(() => [createVNode(unref(DouInput), {
110
- "model-value": model.value.filter ?? "",
112
+ "model-value": view.value.filter ?? "",
111
113
  "aria-label": unref(messages).chartFilter,
112
114
  "onUpdate:modelValue": _cache[6] || (_cache[6] = (key) => set({ filter: key.trim() || void 0 }))
113
115
  }, null, 8, ["model-value", "aria-label"])]),
@@ -1 +1 @@
1
- {"version":3,"file":"chart-settings.vue_vue_type_script_setup_true_lang.js","names":["_useModel","_openBlock","_createElementBlock","_createVNode","SourceField","SettingsField","_unref","_withCtx","FieldSelect","_createTextVNode","_toDisplayString","_createBlock","_createCommentVNode"],"sources":["../../src/chart/chart-settings.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { DouInput, DouSegmentedControl, DouSwitch } from 'dou-ui'\nimport { computed } from 'vue'\nimport { useDashboardMessages } from '../locale'\nimport { useDashboardWidget } from '../widget/context'\nimport FieldSelect from '../widgets/field-select.vue'\nimport SettingsField from '../widgets/settings-field.vue'\nimport { fieldsOf, numberFieldsOf, rowsOf } from '../widgets/shared'\nimport SourceField from '../widgets/source-field.vue'\nimport type { ChartWidgetProps } from './types'\n\ndefineOptions({ name: 'DouDashboardChartSettings' })\n\nconst model = defineModel<ChartWidgetProps>({ required: true })\nconst messages = useDashboardMessages()\nconst { data } = useDashboardWidget()\nconst rows = computed(() => rowsOf(data.value, model.value.path))\nconst set = (patch: Partial<ChartWidgetProps>) => (model.value = { ...model.value, ...patch })\nconst kind = computed(() => model.value.kind ?? 'line')\n</script>\n\n<template>\n <div class=\"dou-dashboard__settings\">\n <SourceField :model-value=\"model.source\" @update:model-value=\"(source) => set({ source })\" />\n <SettingsField :label=\"messages.chartKind\">\n <DouSegmentedControl\n :model-value=\"kind\"\n :options=\"[\n { value: 'line', label: messages.chartLine },\n { value: 'area', label: messages.chartArea },\n { value: 'bar', label: messages.chartBar },\n { value: 'pie', label: messages.chartPie },\n ]\"\n :aria-label=\"messages.chartKind\"\n @update:model-value=\"(value) => set({ kind: value as ChartWidgetProps['kind'] })\"\n />\n </SettingsField>\n <FieldSelect\n :model-value=\"model.x\"\n :label=\"messages.xField\"\n :fields=\"fieldsOf(rows)\"\n @update:model-value=\"(x) => set({ x: x as string | undefined })\"\n />\n <FieldSelect\n :model-value=\"kind === 'pie' ? (model.y ?? []).slice(0, 1) : model.y\"\n :label=\"messages.yFields\"\n :fields=\"numberFieldsOf(rows)\"\n :multiple=\"kind !== 'pie'\"\n @update:model-value=\"\n (y) => set({ y: y === undefined ? undefined : Array.isArray(y) ? y : [y] })\n \"\n />\n <div v-if=\"kind === 'bar' || kind === 'area'\" class=\"dou-dashboard__field-row\">\n <DouSwitch\n :model-value=\"!!model.stacked\"\n @update:model-value=\"(stacked) => set({ stacked: stacked || undefined })\"\n >\n {{ messages.stacked }}\n </DouSwitch>\n <DouSwitch\n v-if=\"kind === 'bar'\"\n :model-value=\"!!model.horizontal\"\n @update:model-value=\"(horizontal) => set({ horizontal: horizontal || undefined })\"\n >\n {{ messages.horizontal }}\n </DouSwitch>\n </div>\n <SettingsField :label=\"messages.chartFilter\" :hint=\"messages.chartFilterHint\">\n <DouInput\n :model-value=\"model.filter ?? ''\"\n :aria-label=\"messages.chartFilter\"\n @update:model-value=\"(key) => set({ filter: key.trim() || undefined })\"\n />\n </SettingsField>\n </div>\n</template>\n"],"mappings":"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"}
1
+ {"version":3,"file":"chart-settings.vue_vue_type_script_setup_true_lang.js","names":["_useModel","_openBlock","_createElementBlock","_createVNode","SourceField","SettingsField","_unref","_withCtx","FieldSelect","_createTextVNode","_toDisplayString","_createBlock","_createCommentVNode"],"sources":["../../src/chart/chart-settings.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { DouInput, DouSegmentedControl, DouSwitch } from 'dou-ui'\nimport { computed } from 'vue'\nimport { useDashboardMessages } from '../locale'\nimport { useDashboardWidget } from '../widget/context'\nimport FieldSelect from '../widgets/field-select.vue'\nimport SettingsField from '../widgets/settings-field.vue'\nimport { fieldsOf, numberFieldsOf, rowsOf } from '../widgets/shared'\nimport SourceField from '../widgets/source-field.vue'\nimport type { ChartWidgetProps } from './types'\nimport { chartPropsOf } from './read-props'\n\ndefineOptions({ name: 'DouDashboardChartSettings' })\n\nconst model = defineModel<ChartWidgetProps>({ required: true })\n// the props as saved may have the wrong types: read them as the widget does\nconst view = computed(() => chartPropsOf(model.value as Record<string, unknown>))\nconst messages = useDashboardMessages()\nconst { data } = useDashboardWidget()\nconst rows = computed(() => rowsOf(data.value, view.value.path))\nconst set = (patch: Partial<ChartWidgetProps>) => (model.value = { ...model.value, ...patch })\nconst kind = computed(() => view.value.kind ?? 'line')\n</script>\n\n<template>\n <div class=\"dou-dashboard__settings\">\n <SourceField :model-value=\"view.source\" @update:model-value=\"(source) => set({ source })\" />\n <SettingsField :label=\"messages.chartKind\">\n <DouSegmentedControl\n :model-value=\"kind\"\n :options=\"[\n { value: 'line', label: messages.chartLine },\n { value: 'area', label: messages.chartArea },\n { value: 'bar', label: messages.chartBar },\n { value: 'pie', label: messages.chartPie },\n ]\"\n :aria-label=\"messages.chartKind\"\n @update:model-value=\"(value) => set({ kind: value as ChartWidgetProps['kind'] })\"\n />\n </SettingsField>\n <FieldSelect\n :model-value=\"view.x\"\n :label=\"messages.xField\"\n :fields=\"fieldsOf(rows)\"\n @update:model-value=\"(x) => set({ x: x as string | undefined })\"\n />\n <FieldSelect\n :model-value=\"kind === 'pie' ? (view.y ?? []).slice(0, 1) : view.y\"\n :label=\"messages.yFields\"\n :fields=\"numberFieldsOf(rows)\"\n :multiple=\"kind !== 'pie'\"\n @update:model-value=\"\n (y) => set({ y: y === undefined ? undefined : Array.isArray(y) ? y : [y] })\n \"\n />\n <div v-if=\"kind === 'bar' || kind === 'area'\" class=\"dou-dashboard__field-row\">\n <DouSwitch\n :model-value=\"!!view.stacked\"\n @update:model-value=\"(stacked) => set({ stacked: stacked || undefined })\"\n >\n {{ messages.stacked }}\n </DouSwitch>\n <DouSwitch\n v-if=\"kind === 'bar'\"\n :model-value=\"!!view.horizontal\"\n @update:model-value=\"(horizontal) => set({ horizontal: horizontal || undefined })\"\n >\n {{ messages.horizontal }}\n </DouSwitch>\n </div>\n <SettingsField :label=\"messages.chartFilter\" :hint=\"messages.chartFilterHint\">\n <DouInput\n :model-value=\"view.filter ?? ''\"\n :aria-label=\"messages.chartFilter\"\n @update:model-value=\"(key) => set({ filter: key.trim() || undefined })\"\n />\n </SettingsField>\n </div>\n</template>\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"chart.js","names":["_sfc_main"],"sources":["../../src/chart/chart.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport {\n DouChart,\n DouChartArea,\n DouChartBar,\n DouChartLine,\n DouChartPie,\n DouChartTooltip,\n} from '@dou-ui/chart'\nimport type { ChartPointEvent } from '@dou-ui/chart'\nimport { computed, watch } from 'vue'\nimport { useDashboardWidget } from '../widget/context'\nimport { filterValueOf, rowsOf, rowsToWorkbook } from '../widgets/shared'\nimport type { ChartWidgetProps } from './types'\n\ndefineOptions({ name: 'DouDashboardChartWidget' })\n\nconst { props, data, size, setFilter, ownFilters, widget, setWorkbook } =\n useDashboardWidget<ChartWidgetProps>()\nconst rows = computed(() => rowsOf(data.value, props.value.path))\nconst kind = computed(() => props.value.kind ?? 'line')\nconst ys = computed(() => props.value.y ?? [])\nconst nameOf = (key: string) => props.value.labels?.[key] ?? key\n/** The x this widget filters by, if any: the other bars fade. */\nconst picked = computed(() =>\n props.value.filter ? ownFilters.value[props.value.filter] : undefined,\n)\n/** Bars other than the picked one turn gray; a new function when the pick changes, so they draw again. */\nconst barColor = computed(() => {\n const value = picked.value\n const x = props.value.x ?? ''\n if (!props.value.filter || value === undefined) return undefined\n return (row: Record<string, unknown>) =>\n filterValueOf(row[x]) === value ? undefined : 'var(--dou-color-border-strong)'\n})\n\n// the export menu writes the chart's data: a column for x, one for each series;\n// a chart without its fields has no menu\nconst seriesKeys = computed(() => (kind.value === 'pie' ? ys.value.slice(0, 1) : ys.value))\nconst exportable = computed(() => !!props.value.x && seriesKeys.value.length > 0)\nwatch(\n exportable,\n (ok) =>\n setWorkbook(\n ok\n ? () => {\n const x = props.value.x!\n return rowsToWorkbook(\n '',\n [\n { key: x, title: nameOf(x) },\n ...seriesKeys.value.map((key) => ({ key, title: nameOf(key) })),\n ],\n rows.value,\n )\n }\n : null,\n ),\n { immediate: true },\n)\n\nfunction onSelect(event: ChartPointEvent) {\n const key = props.value.filter\n if (!key) return\n const value = filterValueOf(event.x)\n if (value === picked.value) setFilter(key, undefined)\n else setFilter(key, value, { label: event.title ?? String(value) })\n}\n</script>\n\n<template>\n <DouChart\n class=\"dou-dashboard-chart\"\n :data=\"rows\"\n :x=\"props.x\"\n :height=\"size.height.value || 160\"\n :horizontal=\"kind === 'bar' && props.horizontal\"\n :legend=\"kind === 'pie' || ys.length > 1\"\n :aria-label=\"widget.title\"\n v-on=\"props.filter ? { select: onSelect } : {}\"\n >\n <DouChartPie\n v-if=\"kind === 'pie'\"\n :value=\"ys[0] ?? ''\"\n :name=\"ys[0] ? nameOf(ys[0]) : undefined\"\n :inner-radius=\"0.6\"\n />\n <template v-else>\n <template v-for=\"y in ys\" :key=\"y\">\n <DouChartBar\n v-if=\"kind === 'bar'\"\n :y=\"y\"\n :name=\"nameOf(y)\"\n :stack=\"props.stacked || undefined\"\n :color=\"barColor\"\n />\n <DouChartArea\n v-else-if=\"kind === 'area'\"\n :y=\"y\"\n :name=\"nameOf(y)\"\n :stack=\"props.stacked || undefined\"\n />\n <DouChartLine v-else :y=\"y\" :name=\"nameOf(y)\" />\n </template>\n </template>\n <DouChartTooltip />\n </DouChart>\n</template>\n"],"mappings":""}
1
+ {"version":3,"file":"chart.js","names":["_sfc_main"],"sources":["../../src/chart/chart.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport {\n DouChart,\n DouChartArea,\n DouChartBar,\n DouChartLine,\n DouChartPie,\n DouChartTooltip,\n} from '@dou-ui/chart'\nimport type { ChartPointEvent } from '@dou-ui/chart'\nimport { computed, watch } from 'vue'\nimport { useDashboardWidget } from '../widget/context'\nimport { filterValueOf, rowsOf, rowsToWorkbook } from '../widgets/shared'\nimport { chartPropsOf } from './read-props'\n\ndefineOptions({ name: 'DouDashboardChartWidget' })\n\nconst { props: raw, data, size, setFilter, ownFilters, widget, setWorkbook } = useDashboardWidget()\nconst props = computed(() => chartPropsOf(raw.value))\nconst rows = computed(() => rowsOf(data.value, props.value.path))\nconst kind = computed(() => props.value.kind ?? 'line')\nconst ys = computed(() => props.value.y ?? [])\nconst x = computed(() => props.value.x)\nconst filter = computed(() => props.value.filter)\nconst nameOf = (key: string) => props.value.labels?.[key] ?? key\n/** The x this widget filters by, if any: the other bars fade. */\nconst picked = computed(() => (filter.value ? ownFilters.value[filter.value] : undefined))\n/** Bars other than the picked one turn gray; a new function when the pick changes, so they draw again. */\nconst barColor = computed(() => {\n const value = picked.value\n const field = x.value ?? ''\n if (!filter.value || value === undefined) return undefined\n return (row: Record<string, unknown>) =>\n filterValueOf(row[field]) === value ? undefined : 'var(--dou-color-border-strong)'\n})\n\n// the export menu writes the chart's data: a column for x, one for each series;\n// a chart without its fields has no menu\nconst seriesKeys = computed(() => (kind.value === 'pie' ? ys.value.slice(0, 1) : ys.value))\nconst exportable = computed(() => !!x.value && seriesKeys.value.length > 0)\nwatch(\n exportable,\n (ok) =>\n setWorkbook(\n ok\n ? () => {\n const field = x.value!\n return rowsToWorkbook(\n '',\n [\n { key: field, title: nameOf(field) },\n ...seriesKeys.value.map((key) => ({ key, title: nameOf(key) })),\n ],\n rows.value,\n )\n }\n : null,\n ),\n { immediate: true },\n)\n\nfunction onSelect(event: ChartPointEvent) {\n const key = filter.value\n if (!key) return\n const value = filterValueOf(event.x)\n if (value === picked.value) setFilter(key, undefined)\n else setFilter(key, value, { label: event.title ?? String(value) })\n}\n</script>\n\n<template>\n <DouChart\n class=\"dou-dashboard-chart\"\n :data=\"rows\"\n :x=\"x\"\n :height=\"size.height.value || 160\"\n :horizontal=\"kind === 'bar' && props.horizontal\"\n :legend=\"kind === 'pie' || ys.length > 1\"\n :aria-label=\"widget.title\"\n v-on=\"filter ? { select: onSelect } : {}\"\n >\n <DouChartPie\n v-if=\"kind === 'pie'\"\n :value=\"ys[0] ?? ''\"\n :name=\"ys[0] ? nameOf(ys[0]) : undefined\"\n :inner-radius=\"0.6\"\n />\n <template v-else>\n <template v-for=\"y in ys\" :key=\"y\">\n <DouChartBar\n v-if=\"kind === 'bar'\"\n :y=\"y\"\n :name=\"nameOf(y)\"\n :stack=\"props.stacked || undefined\"\n :color=\"barColor\"\n />\n <DouChartArea\n v-else-if=\"kind === 'area'\"\n :y=\"y\"\n :name=\"nameOf(y)\"\n :stack=\"props.stacked || undefined\"\n />\n <DouChartLine v-else :y=\"y\" :name=\"nameOf(y)\" />\n </template>\n </template>\n <DouChartTooltip />\n </DouChart>\n</template>\n"],"mappings":""}
@@ -1,5 +1,6 @@
1
1
  import { useDashboardWidget } from "../widget/context.js";
2
2
  import { filterValueOf, rowsOf, rowsToWorkbook } from "../widgets/shared.js";
3
+ import { chartPropsOf } from "./read-props.js";
3
4
  import { Fragment, computed, createBlock, createElementBlock, createVNode, defineComponent, mergeProps, openBlock, renderList, toHandlers, unref, watch, withCtx } from "vue";
4
5
  import { DouChart, DouChartArea, DouChartBar, DouChartLine, DouChartPie, DouChartTooltip } from "@dou-ui/chart";
5
6
  // src/chart/chart.vue?vue&type=script&setup=true&lang.ts
@@ -7,34 +8,37 @@ var chart_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ defineComp
7
8
  name: "DouDashboardChartWidget",
8
9
  __name: "chart",
9
10
  setup(__props) {
10
- const { props, data, size, setFilter, ownFilters, widget, setWorkbook } = useDashboardWidget();
11
+ const { props: raw, data, size, setFilter, ownFilters, widget, setWorkbook } = useDashboardWidget();
12
+ const props = computed(() => chartPropsOf(raw.value));
11
13
  const rows = computed(() => rowsOf(data.value, props.value.path));
12
14
  const kind = computed(() => props.value.kind ?? "line");
13
15
  const ys = computed(() => props.value.y ?? []);
16
+ const x = computed(() => props.value.x);
17
+ const filter = computed(() => props.value.filter);
14
18
  const nameOf = (key) => props.value.labels?.[key] ?? key;
15
19
  /** The x this widget filters by, if any: the other bars fade. */
16
- const picked = computed(() => props.value.filter ? ownFilters.value[props.value.filter] : void 0);
20
+ const picked = computed(() => filter.value ? ownFilters.value[filter.value] : void 0);
17
21
  /** Bars other than the picked one turn gray; a new function when the pick changes, so they draw again. */
18
22
  const barColor = computed(() => {
19
23
  const value = picked.value;
20
- const x = props.value.x ?? "";
21
- if (!props.value.filter || value === void 0) return void 0;
22
- return (row) => filterValueOf(row[x]) === value ? void 0 : "var(--dou-color-border-strong)";
24
+ const field = x.value ?? "";
25
+ if (!filter.value || value === void 0) return void 0;
26
+ return (row) => filterValueOf(row[field]) === value ? void 0 : "var(--dou-color-border-strong)";
23
27
  });
24
28
  const seriesKeys = computed(() => kind.value === "pie" ? ys.value.slice(0, 1) : ys.value);
25
- const exportable = computed(() => !!props.value.x && seriesKeys.value.length > 0);
29
+ const exportable = computed(() => !!x.value && seriesKeys.value.length > 0);
26
30
  watch(exportable, (ok) => setWorkbook(ok ? () => {
27
- const x = props.value.x;
31
+ const field = x.value;
28
32
  return rowsToWorkbook("", [{
29
- key: x,
30
- title: nameOf(x)
33
+ key: field,
34
+ title: nameOf(field)
31
35
  }, ...seriesKeys.value.map((key) => ({
32
36
  key,
33
37
  title: nameOf(key)
34
38
  }))], rows.value);
35
39
  } : null), { immediate: true });
36
40
  function onSelect(event) {
37
- const key = props.value.filter;
41
+ const key = filter.value;
38
42
  if (!key) return;
39
43
  const value = filterValueOf(event.x);
40
44
  if (value === picked.value) setFilter(key, void 0);
@@ -44,12 +48,12 @@ var chart_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ defineComp
44
48
  return openBlock(), createBlock(unref(DouChart), mergeProps({
45
49
  class: "dou-dashboard-chart",
46
50
  data: rows.value,
47
- x: unref(props).x,
51
+ x: x.value,
48
52
  height: unref(size).height.value || 160,
49
- horizontal: kind.value === "bar" && unref(props).horizontal,
53
+ horizontal: kind.value === "bar" && props.value.horizontal,
50
54
  legend: kind.value === "pie" || ys.value.length > 1,
51
55
  "aria-label": unref(widget).title
52
- }, toHandlers(unref(props).filter ? { select: onSelect } : {})), {
56
+ }, toHandlers(filter.value ? { select: onSelect } : {})), {
53
57
  default: withCtx(() => [kind.value === "pie" ? (openBlock(), createBlock(unref(DouChartPie), {
54
58
  key: 0,
55
59
  value: ys.value[0] ?? "",
@@ -60,7 +64,7 @@ var chart_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ defineComp
60
64
  key: 0,
61
65
  y,
62
66
  name: nameOf(y),
63
- stack: unref(props).stacked || void 0,
67
+ stack: props.value.stacked || void 0,
64
68
  color: barColor.value
65
69
  }, null, 8, [
66
70
  "y",
@@ -71,7 +75,7 @@ var chart_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ defineComp
71
75
  key: 1,
72
76
  y,
73
77
  name: nameOf(y),
74
- stack: unref(props).stacked || void 0
78
+ stack: props.value.stacked || void 0
75
79
  }, null, 8, [
76
80
  "y",
77
81
  "name",
@@ -1 +1 @@
1
- {"version":3,"file":"chart.vue_vue_type_script_setup_true_lang.js","names":["_openBlock","_createBlock","_unref","_mergeProps","_toHandlers","_withCtx","_createElementBlock","_Fragment","_renderList","_createVNode"],"sources":["../../src/chart/chart.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport {\n DouChart,\n DouChartArea,\n DouChartBar,\n DouChartLine,\n DouChartPie,\n DouChartTooltip,\n} from '@dou-ui/chart'\nimport type { ChartPointEvent } from '@dou-ui/chart'\nimport { computed, watch } from 'vue'\nimport { useDashboardWidget } from '../widget/context'\nimport { filterValueOf, rowsOf, rowsToWorkbook } from '../widgets/shared'\nimport type { ChartWidgetProps } from './types'\n\ndefineOptions({ name: 'DouDashboardChartWidget' })\n\nconst { props, data, size, setFilter, ownFilters, widget, setWorkbook } =\n useDashboardWidget<ChartWidgetProps>()\nconst rows = computed(() => rowsOf(data.value, props.value.path))\nconst kind = computed(() => props.value.kind ?? 'line')\nconst ys = computed(() => props.value.y ?? [])\nconst nameOf = (key: string) => props.value.labels?.[key] ?? key\n/** The x this widget filters by, if any: the other bars fade. */\nconst picked = computed(() =>\n props.value.filter ? ownFilters.value[props.value.filter] : undefined,\n)\n/** Bars other than the picked one turn gray; a new function when the pick changes, so they draw again. */\nconst barColor = computed(() => {\n const value = picked.value\n const x = props.value.x ?? ''\n if (!props.value.filter || value === undefined) return undefined\n return (row: Record<string, unknown>) =>\n filterValueOf(row[x]) === value ? undefined : 'var(--dou-color-border-strong)'\n})\n\n// the export menu writes the chart's data: a column for x, one for each series;\n// a chart without its fields has no menu\nconst seriesKeys = computed(() => (kind.value === 'pie' ? ys.value.slice(0, 1) : ys.value))\nconst exportable = computed(() => !!props.value.x && seriesKeys.value.length > 0)\nwatch(\n exportable,\n (ok) =>\n setWorkbook(\n ok\n ? () => {\n const x = props.value.x!\n return rowsToWorkbook(\n '',\n [\n { key: x, title: nameOf(x) },\n ...seriesKeys.value.map((key) => ({ key, title: nameOf(key) })),\n ],\n rows.value,\n )\n }\n : null,\n ),\n { immediate: true },\n)\n\nfunction onSelect(event: ChartPointEvent) {\n const key = props.value.filter\n if (!key) return\n const value = filterValueOf(event.x)\n if (value === picked.value) setFilter(key, undefined)\n else setFilter(key, value, { label: event.title ?? String(value) })\n}\n</script>\n\n<template>\n <DouChart\n class=\"dou-dashboard-chart\"\n :data=\"rows\"\n :x=\"props.x\"\n :height=\"size.height.value || 160\"\n :horizontal=\"kind === 'bar' && props.horizontal\"\n :legend=\"kind === 'pie' || ys.length > 1\"\n :aria-label=\"widget.title\"\n v-on=\"props.filter ? { select: onSelect } : {}\"\n >\n <DouChartPie\n v-if=\"kind === 'pie'\"\n :value=\"ys[0] ?? ''\"\n :name=\"ys[0] ? nameOf(ys[0]) : undefined\"\n :inner-radius=\"0.6\"\n />\n <template v-else>\n <template v-for=\"y in ys\" :key=\"y\">\n <DouChartBar\n v-if=\"kind === 'bar'\"\n :y=\"y\"\n :name=\"nameOf(y)\"\n :stack=\"props.stacked || undefined\"\n :color=\"barColor\"\n />\n <DouChartArea\n v-else-if=\"kind === 'area'\"\n :y=\"y\"\n :name=\"nameOf(y)\"\n :stack=\"props.stacked || undefined\"\n />\n <DouChartLine v-else :y=\"y\" :name=\"nameOf(y)\" />\n </template>\n </template>\n <DouChartTooltip />\n </DouChart>\n</template>\n"],"mappings":"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"}
1
+ {"version":3,"file":"chart.vue_vue_type_script_setup_true_lang.js","names":["_openBlock","_createBlock","_unref","_mergeProps","_toHandlers","_withCtx","_createElementBlock","_Fragment","_renderList","_createVNode"],"sources":["../../src/chart/chart.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport {\n DouChart,\n DouChartArea,\n DouChartBar,\n DouChartLine,\n DouChartPie,\n DouChartTooltip,\n} from '@dou-ui/chart'\nimport type { ChartPointEvent } from '@dou-ui/chart'\nimport { computed, watch } from 'vue'\nimport { useDashboardWidget } from '../widget/context'\nimport { filterValueOf, rowsOf, rowsToWorkbook } from '../widgets/shared'\nimport { chartPropsOf } from './read-props'\n\ndefineOptions({ name: 'DouDashboardChartWidget' })\n\nconst { props: raw, data, size, setFilter, ownFilters, widget, setWorkbook } = useDashboardWidget()\nconst props = computed(() => chartPropsOf(raw.value))\nconst rows = computed(() => rowsOf(data.value, props.value.path))\nconst kind = computed(() => props.value.kind ?? 'line')\nconst ys = computed(() => props.value.y ?? [])\nconst x = computed(() => props.value.x)\nconst filter = computed(() => props.value.filter)\nconst nameOf = (key: string) => props.value.labels?.[key] ?? key\n/** The x this widget filters by, if any: the other bars fade. */\nconst picked = computed(() => (filter.value ? ownFilters.value[filter.value] : undefined))\n/** Bars other than the picked one turn gray; a new function when the pick changes, so they draw again. */\nconst barColor = computed(() => {\n const value = picked.value\n const field = x.value ?? ''\n if (!filter.value || value === undefined) return undefined\n return (row: Record<string, unknown>) =>\n filterValueOf(row[field]) === value ? undefined : 'var(--dou-color-border-strong)'\n})\n\n// the export menu writes the chart's data: a column for x, one for each series;\n// a chart without its fields has no menu\nconst seriesKeys = computed(() => (kind.value === 'pie' ? ys.value.slice(0, 1) : ys.value))\nconst exportable = computed(() => !!x.value && seriesKeys.value.length > 0)\nwatch(\n exportable,\n (ok) =>\n setWorkbook(\n ok\n ? () => {\n const field = x.value!\n return rowsToWorkbook(\n '',\n [\n { key: field, title: nameOf(field) },\n ...seriesKeys.value.map((key) => ({ key, title: nameOf(key) })),\n ],\n rows.value,\n )\n }\n : null,\n ),\n { immediate: true },\n)\n\nfunction onSelect(event: ChartPointEvent) {\n const key = filter.value\n if (!key) return\n const value = filterValueOf(event.x)\n if (value === picked.value) setFilter(key, undefined)\n else setFilter(key, value, { label: event.title ?? String(value) })\n}\n</script>\n\n<template>\n <DouChart\n class=\"dou-dashboard-chart\"\n :data=\"rows\"\n :x=\"x\"\n :height=\"size.height.value || 160\"\n :horizontal=\"kind === 'bar' && props.horizontal\"\n :legend=\"kind === 'pie' || ys.length > 1\"\n :aria-label=\"widget.title\"\n v-on=\"filter ? { select: onSelect } : {}\"\n >\n <DouChartPie\n v-if=\"kind === 'pie'\"\n :value=\"ys[0] ?? ''\"\n :name=\"ys[0] ? nameOf(ys[0]) : undefined\"\n :inner-radius=\"0.6\"\n />\n <template v-else>\n <template v-for=\"y in ys\" :key=\"y\">\n <DouChartBar\n v-if=\"kind === 'bar'\"\n :y=\"y\"\n :name=\"nameOf(y)\"\n :stack=\"props.stacked || undefined\"\n :color=\"barColor\"\n />\n <DouChartArea\n v-else-if=\"kind === 'area'\"\n :y=\"y\"\n :name=\"nameOf(y)\"\n :stack=\"props.stacked || undefined\"\n />\n <DouChartLine v-else :y=\"y\" :name=\"nameOf(y)\" />\n </template>\n </template>\n <DouChartTooltip />\n </DouChart>\n</template>\n"],"mappings":"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"}
@@ -0,0 +1,26 @@
1
+ import { flagOf, oneOf, recordOf, textOf, textsOf } from "../widgets/shared.js";
2
+ // src/chart/read-props.ts
3
+ /** The chart widget's props as its type says; what isn't the right type counts as not set. */
4
+ function chartPropsOf(props) {
5
+ const labels = {};
6
+ for (const [key, label] of Object.entries(recordOf(props.labels) ?? {})) if (typeof label === "string") labels[key] = label;
7
+ return {
8
+ source: textOf(props.source),
9
+ path: textOf(props.path),
10
+ kind: oneOf(props.kind, [
11
+ "line",
12
+ "area",
13
+ "bar",
14
+ "pie"
15
+ ]),
16
+ x: textOf(props.x),
17
+ y: textsOf(props.y),
18
+ labels,
19
+ stacked: flagOf(props.stacked),
20
+ horizontal: flagOf(props.horizontal),
21
+ filter: textOf(props.filter)
22
+ };
23
+ }
24
+ export { chartPropsOf };
25
+
26
+ //# sourceMappingURL=read-props.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"read-props.js","names":[],"sources":["../../src/chart/read-props.ts"],"sourcesContent":["import { flagOf, oneOf, recordOf, textOf, textsOf } from '../widgets/shared'\nimport type { ChartWidgetProps } from './types'\n\n/** The chart widget's props as its type says; what isn't the right type counts as not set. */\nexport function chartPropsOf(props: Record<string, unknown>): ChartWidgetProps {\n const labels: Record<string, string> = {}\n for (const [key, label] of Object.entries(recordOf(props.labels) ?? {}))\n if (typeof label === 'string') labels[key] = label\n return {\n source: textOf(props.source),\n path: textOf(props.path),\n kind: oneOf(props.kind, ['line', 'area', 'bar', 'pie'] as const),\n x: textOf(props.x),\n y: textsOf(props.y),\n labels,\n stacked: flagOf(props.stacked),\n horizontal: flagOf(props.horizontal),\n filter: textOf(props.filter),\n }\n}\n"],"mappings":";;;AAIA,SAAgB,aAAa,OAAkD;CAC7E,MAAM,SAAiC,CAAC;CACxC,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,SAAS,MAAM,MAAM,KAAK,CAAC,CAAC,GACpE,IAAI,OAAO,UAAU,UAAU,OAAO,OAAO;CAC/C,OAAO;EACL,QAAQ,OAAO,MAAM,MAAM;EAC3B,MAAM,OAAO,MAAM,IAAI;EACvB,MAAM,MAAM,MAAM,MAAM;GAAC;GAAQ;GAAQ;GAAO;EAAK,CAAU;EAC/D,GAAG,OAAO,MAAM,CAAC;EACjB,GAAG,QAAQ,MAAM,CAAC;EAClB;EACA,SAAS,OAAO,MAAM,OAAO;EAC7B,YAAY,OAAO,MAAM,UAAU;EACnC,QAAQ,OAAO,MAAM,MAAM;CAC7B;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 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":""}
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 reportError: (id, error) => emit('widgetError', { widget: id, part: 'content', error }),\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 :messages=\"messages\"\n :set-props=\"setProps\"\n :report-error=\"\n (id, error) => emit('widgetError', { widget: id, part: 'settings', error })\n \"\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 :report-error=\"(id, error) => emit('widgetError', { widget: id, part: 'content', error })\"\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":""}