Fixes for GUI2 (#8310)

Addresses several issues found during book club.
- Add placeholder icons for visualizations (see screenshot)
- Also add the corresponding export to visualizations, and relevant support in the visualization store and visualization metadata DB.
- Show icon both on the visualization selector button, and in each visualization selector entry
- Adjust gaps between visualization selector entries
- Port table viz fixes (#8102) to Vue
- Fix height of table rows to avoid cutting off descenders on letters like `y` and `g`
- Make the space and enter keys work on visualization toolbar buttons, if they are selected
- `.blur()` code editor when clicking outside of it
- And similarly `.blur()` visualization selector
- Move selection brush on scroll by scaling the delta of `scrollTop` and `scrollLeft`
- Note that mouse position is technically still incorrect when scrolling past the end. I think this is fine - the new behavior is less broken, plus I am not aware of any way to fix this.

# Important Notes
None
This commit is contained in:
somebody1234 2023-11-20 23:23:50 +10:00 committed by GitHub
parent 1c936cc69f
commit 062992bb8b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 304 additions and 138 deletions

View File

@ -33,6 +33,7 @@
"@ag-grid-community/core": "^30.2.0",
"@ag-grid-community/styles": "^30.2.0",
"@ag-grid-enterprise/core": "^30.2.0",
"@ag-grid-enterprise/range-selection": "^30.2.1",
"@babel/parser": "^7.22.16",
"@fast-check/vitest": "^0.0.8",
"@lezer/common": "^1.1.0",

View File

@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<!-- Please run `npm run generate` whenever this file is edited. -->
<!-- Please run `npm run generate-metadata` whenever this file is edited. -->
<g id="add_column" fill="none">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="icon/add_column">
@ -904,6 +904,22 @@
</g>
</svg>
</g>
<g id="table" fill="none">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="4.5" width="4" height="3" fill="black" fill-opacity="0.6" />
<rect x="4" y="8.5" width="4" height="3" fill="black" fill-opacity="0.6" />
<rect x="4" y="12.5" width="4" height="3" fill="black" fill-opacity="0.6" />
<rect x="9" y="4.5" width="3" height="3" fill="black" fill-opacity="0.6" />
<rect x="9" y="8.5" width="3" height="3" fill="black" fill-opacity="0.6" />
<rect x="9" y="12.5" width="3" height="3" fill="black" fill-opacity="0.6" />
<path d="M0 4.5H3V15.5H2C0.895431 15.5 0 14.6046 0 13.5V4.5Z" fill="black" fill-opacity="0.6" />
<path d="M0 2.5C0 1.39543 0.895431 0.5 2 0.5H3V3.5H0V2.5Z" fill="black" fill-opacity="0.6" />
<path d="M4 0.5H14C15.1046 0.5 16 1.39543 16 2.5V3.5H4V0.5Z" fill="black" fill-opacity="0.6" />
<rect x="13" y="4.5" width="3" height="3" fill="black" fill-opacity="0.6" />
<rect x="13" y="8.5" width="3" height="3" fill="black" fill-opacity="0.6" />
<path d="M13 12.5H16V13.5C16 14.6046 15.1046 15.5 14 15.5H13V12.5Z" fill="black" fill-opacity="0.6" />
</svg>
</g>
<g id="table_edit" fill="none">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="icon/table_edit">
@ -1271,5 +1287,50 @@
fill="black" fill-opacity="0.6" />
</svg>
</g>
<!-- Please run `npm run generate` whenever this file is edited. -->
<g id="braces" fill="none">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 1C4.5 1 3.5 2 3.5 3V6C3.5 6.5 3.49999 8 1 8C3.5 8 3.5 9.5 3.5 10V13C3.5 14 4.5 15 6 15" stroke="black" stroke-width="2" stroke-opacity="0.6" />
<path d="M10 1C11.5 1 12.5 2 12.5 3V6C12.5 6.5 12.5 8 15 8C12.5 8 12.5 9.5 12.5 10V13C12.5 14 11.5 15 10 15" stroke="black" stroke-width="2" stroke-opacity="0.6" />
</svg>
</g>
<g id="columns_increasing" fill="none">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="10" width="4" height="6" rx="1" fill="black" fill-opacity="0.6" />
<rect x="6" y="5" width="4" height="11" rx="1" fill="black" fill-opacity="0.6" />
<rect x="11" width="4" height="16" rx="1" fill="black" fill-opacity="0.6" />
</svg>
</g>
<g id="exclamation" fill="none">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="6.5" y="1" width="3" height="10" rx="1" fill="black" fill-opacity="0.6" />
<rect x="6.5" y="12" width="3" height="3" rx="1" fill="black" fill-opacity="0.6" />
</svg>
</g>
<g id="points" fill="none">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="5" y="2" width="3" height="3" rx="1.5" fill="black" fill-opacity="0.6" />
<rect x="1" y="11" width="3" height="3" rx="1.5" fill="black" fill-opacity="0.6" />
<rect x="12" y="4" width="3" height="3" rx="1.5" fill="black" fill-opacity="0.6" />
<rect x="10" y="10" width="3" height="3" rx="1.5" fill="black" fill-opacity="0.6" />
</svg>
</g>
<g id="heatmap" fill="none">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="5.75" y="0.75" width="4.5" height="4.5" rx="1" fill="black" fill-opacity="0.3" />
<rect x="0.75" y="10.75" width="4.5" height="4.5" rx="1" fill="black" fill-opacity="0.45" />
<rect x="10.75" y="5.75" width="4.5" height="4.5" rx="1" fill="black" fill-opacity="0.45" />
<rect x="5.75" y="10.75" width="4.5" height="4.5" rx="1" fill="black" fill-opacity="0.6" />
</svg>
</g>
<g id="image" fill="none">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 3C1 2.44772 1.44772 2 2 2H14C14.5523 2 15 2.44772 15 3V4H1V3Z" fill="black" fill-opacity="0.6" />
<path d="M1 12H15V13C15 13.5523 14.5523 14 14 14H2C1.44772 14 1 13.5523 1 13V12Z" fill="black" fill-opacity="0.6" />
<rect x="1" y="4" width="2" height="8" fill="black" fill-opacity="0.6" />
<rect x="13" y="4" width="2" height="8" fill="black" fill-opacity="0.6" />
<circle cx="10.5" cy="6.5" r="1.5" fill="black" fill-opacity="0.6" />
<path d="M4 11.0237V8.02371C6 5.5237 7 6 8.5 7.50003L12 11.0237L4 11.0237Z" fill="black" fill-opacity="0.6" />
</svg>
</g>
<!-- Please run `npm run generate-metadata` whenever this file is edited. -->
</svg>

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

@ -2,6 +2,7 @@
import { useGraphStore } from '@/stores/graph'
import { useProjectStore } from '@/stores/project'
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
import { useAutoBlur } from '@/util/autoBlur'
import type { Highlighter } from '@/util/codemirror'
import { usePointer } from '@/util/events'
import { useLocalStorage } from '@vueuse/core'
@ -30,6 +31,7 @@ const projectStore = useProjectStore()
const graphStore = useGraphStore()
const suggestionDbStore = useSuggestionDbStore()
const rootElement = ref<HTMLElement>()
useAutoBlur(rootElement)
// == CodeMirror editor setup ==

View File

@ -9,7 +9,9 @@ import {
useVisualizationStore,
type Visualization,
} from '@/stores/visualization'
import type { URLString } from '@/stores/visualization/compilerMessaging'
import { toError } from '@/util/error'
import type { Icon } from '@/util/iconName'
import type { Opt } from '@/util/opt'
import type { Vec2 } from '@/util/vec2'
import type { ExprId, VisualizationIdentifier } from 'shared/yjsModel'
@ -35,6 +37,7 @@ const emit = defineEmits<{
}>()
const visualization = shallowRef<Visualization>()
const icon = ref<Icon | URLString>()
onErrorCaptured((vueError) => {
error.value = vueError
@ -76,6 +79,7 @@ watch(
watchEffect(async () => {
if (props.currentType == null) return
visualization.value = undefined
icon.value = undefined
try {
const module = await visualizationStore.get(props.currentType).value
if (module) {
@ -85,6 +89,7 @@ watchEffect(async () => {
switchToDefaultPreprocessor()
}
visualization.value = module.default
icon.value = module.icon
} else {
switch (props.currentType.module.kind) {
case 'Builtin': {
@ -128,6 +133,9 @@ provideVisualizationConfig({
get currentType() {
return props.currentType ?? DEFAULT_VISUALIZATION_IDENTIFIER
},
get icon() {
return icon.value
},
hide: () => emit('setVisualizationVisible', false),
updateType: (id) => emit('setVisualizationId', id),
})

View File

@ -5,14 +5,15 @@
* It displays one group defined in `@/assets/icons.svg` file, specified by `variant` property.
*/
import icons from '@/assets/icons.svg'
import type { URLString } from '@/stores/visualization/compilerMessaging'
import type { Icon } from '@/util/iconName'
const props = defineProps<{ name: Icon; width?: number; height?: number }>()
const props = defineProps<{ name: Icon | URLString; width?: number; height?: number }>()
</script>
<template>
<svg :style="{ '--width': `${width ?? 16}px`, '--height': `${height ?? 16}px` }">
<use :href="`${icons}#${props.name}`"></use>
<use :href="props.name.includes(':') ? props.name : `${icons}#${props.name}`"></use>
</svg>
</template>

View File

@ -2,7 +2,7 @@
import SvgIcon from '@/components/SvgIcon.vue'
import VisualizationSelector from '@/components/VisualizationSelector.vue'
import { useVisualizationConfig } from '@/providers/visualizationConfig'
import { PointerButtonMask, usePointer } from '@/util/events'
import { PointerButtonMask, isClick, usePointer } from '@/util/events'
import { ref } from 'vue'
const props = defineProps<{
@ -28,6 +28,17 @@ function onWheel(event: WheelEvent) {
}
}
function blur(event: Event) {
const target = event.target
if (
!(target instanceof HTMLElement) &&
!(target instanceof SVGElement) &&
!(target instanceof MathMLElement)
)
return
setTimeout(() => target.blur(), 0)
}
const rootNode = ref<HTMLElement>()
const contentNode = ref<HTMLElement>()
@ -102,14 +113,19 @@ const resizeBottomRight = usePointer((pos, _, type) => {
hidden: config.fullscreen,
}"
>
<button class="image-button active" @pointerdown.stop="config.hide()">
<button
class="image-button active"
@pointerdown.stop="config.hide()"
@click="config.hide()"
>
<SvgIcon class="icon" name="eye" />
</button>
</div>
<div class="toolbar">
<button
class="image-button active"
@pointerdown.stop="config.fullscreen = !config.fullscreen"
@pointerdown.stop="(config.fullscreen = !config.fullscreen), blur($event)"
@click.prevent="!isClick($event) && (config.fullscreen = !config.fullscreen)"
>
<SvgIcon class="icon" :name="config.fullscreen ? 'exit_fullscreen' : 'fullscreen'" />
</button>
@ -117,8 +133,9 @@ const resizeBottomRight = usePointer((pos, _, type) => {
<button
class="image-button active"
@pointerdown.stop="isSelectorVisible = !isSelectorVisible"
@click.prevent="!isClick($event) && (isSelectorVisible = !isSelectorVisible)"
>
<SvgIcon class="icon" name="compass" />
<SvgIcon class="icon" :name="config.icon ?? 'columns_increasing'" />
</button>
<VisualizationSelector
v-if="isSelectorVisible"

View File

@ -1,4 +1,7 @@
<script setup lang="ts">
import SvgIcon from '@/components/SvgIcon.vue'
import { useVisualizationStore } from '@/stores/visualization'
import { useAutoBlur } from '@/util/autoBlur'
import { visIdentifierEquals, type VisualizationIdentifier } from 'shared/yjsModel'
import { onMounted, ref } from 'vue'
@ -8,7 +11,10 @@ const props = defineProps<{
}>()
const emit = defineEmits<{ hide: []; 'update:modelValue': [type: VisualizationIdentifier] }>()
const visualizationStore = useVisualizationStore()
const rootNode = ref<HTMLElement>()
useAutoBlur(rootNode)
function visIdLabel(id: VisualizationIdentifier) {
switch (id.module.kind) {
@ -26,9 +32,7 @@ function visIdKey(id: VisualizationIdentifier) {
return `${kindKey}::${id.name}`
}
onMounted(() => {
setTimeout(() => rootNode.value?.focus(), 0)
})
onMounted(() => setTimeout(() => rootNode.value?.focus(), 0))
</script>
<template>
@ -40,8 +44,10 @@ onMounted(() => {
:key="visIdKey(type_)"
:class="{ selected: visIdentifierEquals(props.modelValue, type_) }"
@pointerdown.stop="emit('update:modelValue', type_)"
v-text="visIdLabel(type_)"
></li>
>
<SvgIcon class="icon" :name="visualizationStore.icon(type_) ?? 'columns_increasing'" />
<span v-text="visIdLabel(type_)"></span>
</li>
</ul>
</div>
</template>
@ -72,14 +78,18 @@ onMounted(() => {
position: relative;
}
.VisualizationSelector > ul {
ul {
display: flex;
flex-flow: column;
gap: 2px;
list-style-type: none;
padding: 4px;
}
li {
display: flex;
gap: 4px;
align-items: center;
cursor: pointer;
padding: 0 8px;
border-radius: 12px;

View File

@ -1,5 +1,6 @@
<script lang="ts">
export const name = 'Geo Map'
export const icon = 'compass'
export const inputType = 'Standard.Table.Data.Table.Table'
export const scripts = [
// mapbox-gl does not have an ESM release.

View File

@ -1,5 +1,6 @@
<script lang="ts">
export const name = 'Heatmap'
export const icon = 'heatmap'
export const inputType = 'Standard.Table.Data.Table.Table | Standard.Base.Data.Vector.Vector'
export const defaultPreprocessor = [
'Standard.Visualization.Table.Visualization',

View File

@ -1,5 +1,6 @@
<script lang="ts">
export const name = 'Image'
export const icon = 'image'
export const inputType = 'Standard.Image.Data.Image.Image'
interface Data {

View File

@ -1,5 +1,6 @@
<script lang="ts">
export const name = 'JSON'
export const icon = 'braces'
export const inputType = 'Any'
</script>

View File

@ -1,5 +1,6 @@
<script lang="ts">
export const name = 'SQL Query'
export const icon = 'braces'
export const inputType = 'Standard.Database.Data.Table.Table | Standard.Database.Data.Column.Column'
export const defaultPreprocessor = [
'Standard.Visualization.SQL.Visualization',

View File

@ -7,6 +7,7 @@ import { VisualizationContainer, defineKeybinds } from '@/util/visualizationBuil
import { computed, ref, watch, watchEffect, watchPostEffect } from 'vue'
export const name = 'Scatter Plot'
export const icon = 'points'
export const inputType = 'Standard.Table.Data.Table.Table | Standard.Base.Data.Vector.Vector'
const DEFAULT_LIMIT = 1024
export const defaultPreprocessor = [

View File

@ -1,5 +1,6 @@
<script lang="ts">
export const name = 'Table'
export const icon = 'table'
export const inputType =
'Standard.Table.Data.Table.Table | Standard.Table.Data.Column.Column | Standard.Table.Data.Row.Row |Standard.Base.Data.Vector.Vector | Standard.Base.Data.Array.Array | Standard.Base.Data.Map.Map | Any'
export const defaultPreprocessor = [
@ -60,7 +61,6 @@ interface UnknownTable {
<script setup lang="ts">
import VisualizationContainer from '@/components/VisualizationContainer.vue'
import { useVisualizationConfig } from '@/providers/visualizationConfig'
import type {
ColDef,
ColumnResizedEvent,
@ -69,26 +69,27 @@ import type {
} from '@ag-grid-community/core'
import '@ag-grid-community/styles/ag-grid.css'
import '@ag-grid-community/styles/ag-theme-alpine.css'
import { useDebounceFn } from '@vueuse/core'
import { computed, onMounted, ref, watch, watchEffect, type Ref } from 'vue'
const [{ ClientSideRowModelModule }, { Grid, ModuleRegistry }, { LicenseManager }] =
await Promise.all([
import { computed, onMounted, reactive, ref, watchEffect, type Ref } from 'vue'
const [
{ ClientSideRowModelModule },
{ RangeSelectionModule },
{ Grid, ModuleRegistry },
{ LicenseManager },
] = await Promise.all([
import('@ag-grid-community/client-side-row-model'),
import('@ag-grid-enterprise/range-selection'),
import('@ag-grid-community/core'),
import('@ag-grid-enterprise/core'),
])
])
ModuleRegistry.registerModules([ClientSideRowModelModule])
ModuleRegistry.registerModules([ClientSideRowModelModule, RangeSelectionModule])
const props = defineProps<{ data: Data }>()
const emit = defineEmits<{
'update:preprocessor': [module: string, method: string, ...args: string[]]
}>()
const config = useVisualizationConfig()
const INDEX_FIELD_NAME = '#'
const SIDE_MARGIN = 20
const rowLimit = ref(0)
const page = ref(0)
@ -96,6 +97,7 @@ const pageLimit = ref(0)
const rowCount = ref(0)
const isTruncated = ref(false)
const tableNode = ref<HTMLElement>()
const widths = reactive(new Map<string, number>())
const defaultColDef = {
editable: false,
sortable: true as boolean,
@ -106,27 +108,27 @@ const defaultColDef = {
cellRenderer: cellRenderer,
}
const agGridOptions: Ref<GridOptions & Required<Pick<GridOptions, 'defaultColDef'>>> = ref({
headerHeight: 20,
rowHeight: 20,
headerHeight: 26,
rowHeight: 22,
rowData: [],
columnDefs: [],
defaultColDef: defaultColDef as typeof defaultColDef & { manuallySized: boolean },
onFirstDataRendered: updateColumnWidths,
onRowDataUpdated: updateColumnWidths,
onColumnResized: lockColumnSize,
suppressFieldDotNotation: true,
enableRangeSelection: true,
})
const isFirstPage = computed(() => page.value === 0)
const isLastPage = computed(() => page.value === pageLimit.value - 1)
const isRowCountSelectorVisible = computed(() => rowCount.value >= 1000)
const selectableRowLimits = computed(() =>
[1000, 2500, 5000, 10000, 25000, 50000, 100000].filter((r) => r <= rowCount.value),
)
const wasAutomaticallyAutosized = ref(false)
function setRowLimitAndPage(newRowLimit: number, newPage: number) {
if (newRowLimit !== rowLimit.value || newPage !== page.value) {
function setRowLimit(newRowLimit: number) {
if (newRowLimit !== rowLimit.value) {
rowLimit.value = newRowLimit
page.value = newPage
emit(
'update:preprocessor',
'Standard.Visualization.Table.Visualization',
@ -148,14 +150,10 @@ function escapeHTML(str: string) {
}
function cellRenderer(params: { value: string | null }) {
if (params.value === null) {
return '<span style="color:grey; font-style: italic;">Nothing</span>'
} else if (params.value === undefined) {
return ''
} else if (params.value === '') {
return '<span style="color:grey; font-style: italic;">Empty</span>'
}
return escapeHTML(params.value.toString())
if (params.value === null) return '<span style="color:grey; font-style: italic;">Nothing</span>'
else if (params.value === undefined) return ''
else if (params.value === '') return '<span style="color:grey; font-style: italic;">Empty</span>'
else return escapeHTML(params.value.toString())
}
function addRowIndex(data: object[]): object[] {
@ -200,7 +198,7 @@ function isMatrix(data: object): data is LegacyMatrix {
}
function toField(name: string): ColDef {
return { field: name, manuallySized: false }
return { field: name }
}
function indexField(): ColDef {
@ -228,7 +226,7 @@ function toRender(content: unknown) {
}
}
return String(content)
return content
}
watchEffect(() => {
@ -242,36 +240,33 @@ watchEffect(() => {
let rowData: object[] = []
if ('error' in data_) {
options.api.setColumnDefs([
columnDefs = [
{
field: 'Error',
cellStyle: { 'white-space': 'normal' },
manuallySized: false,
},
])
options.api.setRowData([{ Error: data_.error }])
]
rowData = [{ Error: data_.error }]
} else if (data_.type === 'Matrix') {
let defs: ColDef[] = [indexField()]
columnDefs.push(indexField())
for (let i = 0; i < data_.column_count; i++) {
defs.push(toField(i.toString()))
columnDefs.push(toField(i.toString()))
}
columnDefs = defs
rowData = addRowIndex(data_.json)
isTruncated.value = data_.all_rows_count !== data_.json.length
} else if (data_.type === 'Object_Matrix') {
let defs: ColDef[] = [indexField()]
columnDefs.push(indexField())
let keys = new Set<string>()
for (const val of data_.json) {
if (val != null) {
Object.keys(val).forEach((k) => {
if (!keys.has(k)) {
keys.add(k)
defs.push(toField(k))
columnDefs.push(toField(k))
}
})
}
}
columnDefs = defs
rowData = addRowIndex(data_.json)
isTruncated.value = data_.all_rows_count !== data_.json.length
} else if (isMatrix(data_)) {
@ -295,7 +290,6 @@ watchEffect(() => {
const indicesHeader = ('indices_header' in data_ ? data_.indices_header : []).map(toField)
const dataHeader = ('header' in data_ ? data_.header : [])?.map(toField) ?? []
columnDefs = [...indicesHeader, ...dataHeader]
const rows =
data_.data && data_.data.length > 0
? data_.data[0]?.length ?? 0
@ -323,104 +317,73 @@ watchEffect(() => {
page.value = newPageLimit
}
// If an existing grid, merge width from manually sized columns.
const newWidths = new Map<string, number>()
const mergedColumnDefs = columnDefs.map((columnDef) => {
if (!columnDef.field) return columnDef
const width = widths.get(columnDef.field)
if (width != null) newWidths.set(columnDef.field, (columnDef.width = width))
return columnDef
})
widths.clear()
for (const [key, value] of newWidths) widths.set(key, value)
// If data is truncated, we cannot rely on sorting/filtering so will disable.
options.defaultColDef.filter = !isTruncated.value
options.defaultColDef.sortable = !isTruncated.value
options.api.setColumnDefs(columnDefs)
options.api.setColumnDefs(mergedColumnDefs)
options.api.setRowData(rowData)
})
function updateTableSize(clientWidth: number | undefined) {
clientWidth ??= tableNode.value?.getBoundingClientRect().width ?? 0
function updateColumnWidths() {
const columnApi = agGridOptions.value.columnApi
if (columnApi == null) {
console.warn('AG Grid column API does not exist.')
return
}
// Resize columns to fit the table width unless the user manually resized them.
const cols = columnApi.getAllGridColumns().filter((c) => !c.getColDef().manuallySized)
// Compute the maximum width of a column: the client width minus a small margin.
const maxWidth = clientWidth - SIDE_MARGIN
// Resize based on the data and then shrink any columns that are too wide.
wasAutomaticallyAutosized.value = true
columnApi.autoSizeColumns(cols, true)
const bigCols = cols
.filter((c) => c.getActualWidth() > maxWidth)
.map((c) => ({ key: c, newWidth: maxWidth, manuallySized: false }))
columnApi.setColumnWidths(bigCols)
const cols = columnApi.getAllGridColumns().filter((c) => {
const field = c.getColDef().field
return field && !widths.has(field)
})
columnApi.autoSizeColumns(cols)
}
function lockColumnSize(e: ColumnResizedEvent) {
// Check if the resize is finished, and it's not from the API (which is triggered by us).
if (!e.finished || e.source === 'api') {
return
}
if (!e.finished || e.source === 'api') return
// If the user manually resized (or manually autosized) a column, we don't want to auto-size it
// on a resize.
const manuallySized = e.source !== 'autosizeColumns' || !wasAutomaticallyAutosized.value
wasAutomaticallyAutosized.value = false
for (const column of e.columns ?? []) {
column.getColDef().manuallySized = manuallySized
const field = column.getColDef().field
if (field && manuallySized) widths.set(field, column.getActualWidth())
}
}
function goToFirstPage() {
setRowLimitAndPage(rowLimit.value, 0)
}
function goToPreviousPage() {
setRowLimitAndPage(rowLimit.value, page.value - 1)
}
function goToNextPage() {
setRowLimitAndPage(rowLimit.value, page.value + 1)
}
function goToLastPage() {
setRowLimitAndPage(rowLimit.value, pageLimit.value - 1)
}
// ===============
// === Updates ===
// ===============
onMounted(() => {
setRowLimitAndPage(1000, 0)
setRowLimit(1000)
if ('AG_GRID_LICENSE_KEY' in window && typeof window.AG_GRID_LICENSE_KEY === 'string') {
LicenseManager.setLicenseKey(window.AG_GRID_LICENSE_KEY)
} else {
console.warn('The AG_GRID_LICENSE_KEY is not defined.')
}
new Grid(tableNode.value!, agGridOptions.value)
setTimeout(() => updateTableSize(undefined), 0)
updateColumnWidths()
})
watch(
() => config.fullscreen,
() => queueMicrotask(() => updateTableSize(undefined)),
)
const debouncedUpdateTableSize = useDebounceFn((...args: Parameters<typeof updateTableSize>) => {
queueMicrotask(() => {
updateTableSize(...args)
})
}, 500)
watch(
() => [props.data, config.width],
() => debouncedUpdateTableSize(undefined),
)
</script>
<template>
<VisualizationContainer :belowToolbar="true" :overflow="true">
<div ref="rootNode" class="TableVisualization" @wheel.stop @pointerdown.stop>
<div class="table-visualization-status-bar">
<button :disabled="isFirstPage" @click="goToFirstPage">«</button>
<button :disabled="isFirstPage" @click="goToPreviousPage">&lsaquo;</button>
<select
v-if="isRowCountSelectorVisible"
@change="setRowLimitAndPage(Number(($event.target as HTMLOptionElement).value), page)"
@change="setRowLimit(Number(($event.target as HTMLOptionElement).value))"
>
<option
v-for="limit in selectableRowLimits"
@ -436,8 +399,6 @@ watch(
<span v-else-if="isRowCountSelectorVisible" v-text="' rows.'"></span>
<span v-else-if="rowCount === 1" v-text="'1 row.'"></span>
<span v-else v-text="`${rowCount} rows.`"></span>
<button :disabled="isLastPage" @click="goToNextPage">&rsaquo;</button>
<button :disabled="isLastPage" @click="goToLastPage">»</button>
</div>
<div ref="tableNode" class="scrollable ag-theme-alpine"></div>
</div>
@ -466,12 +427,6 @@ watch(
padding: 0 5px;
overflow: hidden;
}
.table-visualization-status-bar > button {
width: 12px;
margin: 0 2px;
display: none;
}
</style>
<style>

View File

@ -1,5 +1,6 @@
<script lang="ts">
export const name = 'Warnings'
export const icon = 'exclamation'
export const inputType = 'Any'
export const defaultPreprocessor = [
'Standard.Visualization.Warnings',

View File

@ -14,14 +14,9 @@ declare module 'd3' {
}
}
declare module '@ag-grid-community/core' {
// These type parameters are defined on the original interface.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColDef<TData, TValue> {
/** Custom user-defined value. */
manuallySized: boolean
}
import '@ag-grid-community/core'
declare module '@ag-grid-community/core' {
// These type parameters are defined on the original interface.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface AbstractColDef<TData, TValue> {

View File

@ -1,3 +1,5 @@
import type { URLString } from '@/stores/visualization/compilerMessaging'
import type { Icon } from '@/util/iconName'
import { Vec2 } from '@/util/vec2'
import type { VisualizationIdentifier } from 'shared/yjsModel'
import { reactive } from 'vue'
@ -8,6 +10,7 @@ export interface VisualizationConfig {
background?: string
readonly types: Iterable<VisualizationIdentifier>
readonly currentType: VisualizationIdentifier
readonly icon: Icon | URLString | undefined
readonly isCircularMenuVisible: boolean
readonly nodeSize: Vec2
width: number | null

View File

@ -20,6 +20,8 @@ import type {
import Compiler from '@/stores/visualization/compiler?worker'
import { assertNever } from '@/util/assert'
import { toError } from '@/util/error'
import iconNames from '@/util/iconList.json'
import type { Icon } from '@/util/iconName'
import type { Opt } from '@/util/opt'
import { defineKeybinds } from '@/util/shortcuts'
import { Error as DataError } from 'shared/binaryProtocol'
@ -32,12 +34,24 @@ import { z } from 'zod'
export const currentProjectProtocol = 'enso-current-project:'
export const stylePathAttribute = 'data-style-path'
export type URLString = `${string}:${string}`
const VisualizationModule = z.object({
// This is UNSAFE, but unavoiable as the type of `Visualization` is too difficult to statically
// check. Insteead it will be caught by Vue when trying to mount the visualization, and replaced
// check. Instead it will be caught by Vue when trying to mount the visualization, and replaced
// with a 'Loading Error' visualization.
default: z.custom<Visualization>(() => true),
name: z.string(),
// The name of an icon, or a URL or data URL. If it contains `:`, it is assumed to be a URL.
icon: z
.string()
.transform((s) => {
if (iconNames.includes(s)) return s as Icon
else if (s.includes(':')) return s as URLString
console.warn(`Invalid icon name '${s}'`)
return undefined
})
.optional(),
inputType: z.string().optional(),
defaultPreprocessor: (
z.string().array().min(2) as unknown as z.ZodType<

View File

@ -95,10 +95,11 @@ export const useVisualizationStore = defineStore('visualization', () => {
(roots) => roots.find((root) => root.type === 'Project')?.id,
)
for (const { name, inputType } of builtinVisualizations) {
for (const { name, inputType, icon } of builtinVisualizations) {
metadata.set(toVisualizationId({ module: { kind: 'Builtin' }, name }), {
name,
inputType,
icon,
})
}
@ -182,7 +183,11 @@ export const useVisualizationStore = defineStore('visualization', () => {
id = { module: { kind: 'CurrentProject' }, name: viz.name }
cache.set(toVisualizationId(id), vizPromise)
}
metadata.set(toVisualizationId(id), { name: viz.name, inputType: viz.inputType })
metadata.set(toVisualizationId(id), {
name: viz.name,
inputType: viz.inputType,
icon: viz.icon,
})
} catch (error) {
if (key) cache.delete(key)
if (error instanceof InvalidVisualizationModuleError) {
@ -225,6 +230,10 @@ export const useVisualizationStore = defineStore('visualization', () => {
for (const type of types) yield fromVisualizationId(type)
}
function icon(type: VisualizationIdentifier) {
return metadata.get(toVisualizationId(type))?.icon
}
function get(meta: VisualizationIdentifier, ignoreCache = false) {
const key = toVisualizationId(meta)
if (!cache.get(key) || ignoreCache) {
@ -253,5 +262,5 @@ export const useVisualizationStore = defineStore('visualization', () => {
return module
}
return { types, get }
return { types, get, icon }
})

View File

@ -3,7 +3,8 @@ import { ReactiveDb, ReactiveIndex } from '@/util/database/reactiveDb'
import type { Opt } from '@/util/opt'
import type { VisualizationIdentifier } from 'shared/yjsModel'
export interface VisualizationMetadata extends Pick<VisualizationModule, 'name' | 'inputType'> {}
export interface VisualizationMetadata
extends Pick<VisualizationModule, 'name' | 'inputType' | 'icon'> {}
function getTypesFromUnion(inputType: Opt<string>) {
return inputType?.split('|').map((type) => type.trim()) ?? ['Any']

View File

@ -0,0 +1,25 @@
import { useEvent } from '@/util/events'
import type { Ref } from 'vue'
export function useAutoBlur(root: Ref<HTMLElement | SVGElement | MathMLElement | undefined>) {
useEvent(
window,
'pointerdown',
(event) => {
if (
!root.value?.contains(document.activeElement) ||
!(event.target instanceof Element) ||
root.value.contains(event.target)
)
return
if (
!(document.activeElement instanceof HTMLElement) &&
!(document.activeElement instanceof SVGElement) &&
!(document.activeElement instanceof MathMLElement)
)
return
document.activeElement.blur()
},
{ capture: true },
)
}

View File

@ -12,6 +12,11 @@ import {
type WatchSource,
} from 'vue'
export function isClick(e: MouseEvent | PointerEvent) {
if (e instanceof PointerEvent) return e.pointerId !== -1
else return e.buttons !== 0
}
/**
* Add an event listener for the duration of the component's lifetime.
* @param target element on which to register the event

View File

@ -20,7 +20,7 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
center.value = center.value.addScaled(pos.delta, -1 / scale.value)
}, PointerButtonMask.Auxiliary)
function eventScreenPos(e: PointerEvent): Vec2 {
function eventScreenPos(e: { clientX: number; clientY: number }): Vec2 {
return new Vec2(e.clientX, e.clientY)
}
@ -103,11 +103,51 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
{ capture: true },
)
let isPointerDown = false
let scrolledThisFrame = false
const eventMousePos = ref<Vec2 | null>(null)
let eventTargetScrollPos: Vec2 | null = null
const sceneMousePos = computed(() =>
eventMousePos.value ? clientToScenePos(eventMousePos.value) : null,
)
useEvent(
window,
'scroll',
(e) => {
if (
!isPointerDown ||
scrolledThisFrame ||
!eventMousePos.value ||
!(e.target instanceof Element)
)
return
scrolledThisFrame = true
requestAnimationFrame(() => (scrolledThisFrame = false))
if (!(e.target instanceof Element)) return
const newScrollPos = new Vec2(e.target.scrollLeft, e.target.scrollTop)
if (eventTargetScrollPos !== null) {
const delta = newScrollPos.sub(eventTargetScrollPos)
const mouseDelta = new Vec2(
(delta.x * e.target.clientWidth) / e.target.scrollWidth,
(delta.y * e.target.clientHeight) / e.target.scrollHeight,
)
eventMousePos.value = eventMousePos.value?.add(mouseDelta) ?? null
}
eventTargetScrollPos = newScrollPos
},
{ capture: true },
)
useEvent(
window,
'scrollend',
() => {
eventTargetScrollPos = null
},
{ capture: true },
)
return proxyRefs({
events: {
pointermove(e: PointerEvent) {
@ -119,10 +159,12 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
eventMousePos.value = null
},
pointerup(e: PointerEvent) {
isPointerDown = false
panPointer.events.pointerup(e)
zoomPointer.events.pointerup(e)
},
pointerdown(e: PointerEvent) {
isPointerDown = true
panPointer.events.pointerdown(e)
zoomPointer.events.pointerdown(e)
},

24
package-lock.json generated
View File

@ -30,6 +30,7 @@
"@ag-grid-community/core": "^30.2.0",
"@ag-grid-community/styles": "^30.2.0",
"@ag-grid-enterprise/core": "^30.2.0",
"@ag-grid-enterprise/range-selection": "^30.2.1",
"@babel/parser": "^7.22.16",
"@fast-check/vitest": "^0.0.8",
"@lezer/common": "^1.1.0",
@ -444,9 +445,9 @@
}
},
"node_modules/@ag-grid-community/core": {
"version": "30.2.0",
"resolved": "https://registry.npmjs.org/@ag-grid-community/core/-/core-30.2.0.tgz",
"integrity": "sha512-BdRavyYxyl0rx3w4VKQlV9KHRR9TIJ9RFt4FIsWUxC+VDC+VEL64J1PzgJqXqgkM7Zmb+rYbluA2UPmRrQQJ9A=="
"version": "30.2.1",
"resolved": "https://registry.npmjs.org/@ag-grid-community/core/-/core-30.2.1.tgz",
"integrity": "sha512-jGRBfRFsLwxch8GJGjbVI2FVbB+/fy1s4mm8//+kOkcPFlq4BbLFELvU4Kupwk1YkhduxUC50GTYyFzmF0rSIQ=="
},
"node_modules/@ag-grid-community/styles": {
"version": "30.2.0",
@ -454,11 +455,20 @@
"integrity": "sha512-ipI/weI0jgvhZI/PooNkKUJlJQ21uiyLKmzeQkSOTBPxSqpszvBm5BGkeaw+WmTc6dF/dF2yRVcFz8uTcwveLA=="
},
"node_modules/@ag-grid-enterprise/core": {
"version": "30.2.0",
"resolved": "https://registry.npmjs.org/@ag-grid-enterprise/core/-/core-30.2.0.tgz",
"integrity": "sha512-0XZCBVoOCUmtOkaDr2g+r8ORbT+dV1UqA5dZMwPSC6P537RKPFFCe9YPjwGxPS06YB7VpQcnUF3Pw3TY6ibnyw==",
"version": "30.2.1",
"resolved": "https://registry.npmjs.org/@ag-grid-enterprise/core/-/core-30.2.1.tgz",
"integrity": "sha512-POVdYwMho+WPQ1wrNwQwX4ihGvdq+UbLIHQfWnnZn5c2FLFiUmlxAhCZAXXiOeXq5MDHBKqKrTRemb9TUbBRcg==",
"dependencies": {
"@ag-grid-community/core": "~30.2.0"
"@ag-grid-community/core": "~30.2.1"
}
},
"node_modules/@ag-grid-enterprise/range-selection": {
"version": "30.2.1",
"resolved": "https://registry.npmjs.org/@ag-grid-enterprise/range-selection/-/range-selection-30.2.1.tgz",
"integrity": "sha512-zY/yHnrWd5RLXP2QfCYyUV6VZK76NrnHwKZrsnN4SBpHSI3dV2yO4GVJGu6SVEJM+6H1yNqTriJjA/hTPfmuLQ==",
"dependencies": {
"@ag-grid-community/core": "~30.2.1",
"@ag-grid-enterprise/core": "~30.2.1"
}
},
"node_modules/@akryum/tinypool": {