This commit is contained in:
samunohito 2024-01-23 15:34:52 +09:00
parent 07efd85ffd
commit e39ba6286f
23 changed files with 1082 additions and 139 deletions

View File

@ -27,7 +27,7 @@
"@syuilo/aiscript": "0.17.0",
"@tabler/icons-webfont": "2.44.0",
"@twemoji/parser": "15.0.0",
"@vitejs/plugin-vue": "5.0.2",
"@vitejs/plugin-vue": "5.0.3",
"@vue/compiler-sfc": "3.4.3",
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.0.6",
"astring": "1.8.6",

View File

@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<template v-for="media in mediaList.filter(media => previewable(media))">
<XVideo v-if="media.type.startsWith('video')" :key="`video:${media.id}`" :class="$style.media" :video="media"/>
<XImage v-else-if="media.type.startsWith('image')" :key="`image:${media.id}`" :class="$style.media" class="image" :data-id="media.id" :image="media" :raw="raw"/>
<XImage v-else-if="media.type.startsWith('image')" :key="`image:${media.id}`" :class="$style.media" class="viewImage" :data-id="media.id" :image="media" :raw="raw"/>
</template>
</div>
</div>

View File

@ -90,7 +90,7 @@ const exampleCWNote = reactive<Misskey.entities.Note>({
background: var(--divider);
}
.image {
.viewImage {
max-width: 300px;
margin: 0 auto;
}

View File

@ -100,7 +100,7 @@ const exampleNote = reactive<Misskey.entities.Note>({
background: var(--divider);
}
.image {
.viewImage {
max-width: 300px;
margin: 0 auto;
}

View File

@ -22,7 +22,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<a href="https://misskey-hub.net/docs/for-users/features/timeline/" target="_blank" class="_link">{{ i18n.ts.help }}</a>
</template>
</I18n>
</div>
</template>
@ -42,7 +41,7 @@ import { i18n } from '@/i18n.js';
background: var(--divider);
}
.image {
.viewImage {
max-width: 300px;
margin: 0 auto;
}

View File

@ -1,26 +0,0 @@
<template>
<div
class="cell"
:style="{ width: width + 'px' }"
>
<span>{{ cell.value }}</span>
</div>
</template>
<script setup lang="ts">
import { toRefs } from 'vue';
import { GridCell } from '@/components/grid/types.js';
const props = defineProps<{ cell: GridCell }>();
const { cell } = toRefs(props);
const width = cell.value.columnSetting.width;
</script>
<style scoped lang="scss">
.cell {
display: inline-block;
padding: 4px 8px;
overflow: hidden;
white-space: nowrap;
}
</style>

View File

@ -0,0 +1,304 @@
<template>
<td
ref="rootEl"
:class="$style.cell"
:style="{ maxWidth: cellWidth, minWidth: cellWidth }"
:tabindex="-1"
@dblclick="onCellDoubleClick"
@keydown="onCellKeyDown"
>
<div
:class="[
$style.root,
[cell.selected ? $style.selected : {}],
[cell.ranged ? $style.ranged : {}],
[needsContentCentering ? $style.center : {}]
]"
>
<div v-if="!editing" ref="contentAreaEl">
<div :class="$style.content">
<div v-if="cellType === 'text'">
{{ cell.value }}
</div>
<div v-else-if="cellType === 'boolean'">
<span v-if="cell.value" class="ti ti-check"/>
<span v-else class="ti ti-x"/>
</div>
<div v-else-if="cellType === 'image'">
<img
:src="cell.value as string"
:alt="cell.value as string"
:class="$style.viewImage"
/>
</div>
</div>
</div>
<div v-else ref="inputAreaEl">
<input
v-if="cellType === 'text'"
type="text"
:class="$style.editingInput"
:value="editingValue"
@input="onInputText"
/>
</div>
</div>
</td>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, toRefs, watch } from 'vue';
import {
CellAddress,
CellValue,
equalCellAddress,
getCellAddress,
GridCell,
GridEventEmitter,
} from '@/components/grid/types.js';
const emit = defineEmits<{
(ev: 'edit:begin', sender: GridCell): void;
(ev: 'edit:end', sender: GridCell): void;
(ev: 'selection:move', sender: GridCell, next: CellAddress): void;
}>();
const props = defineProps<{
cell: GridCell,
bus: GridEventEmitter,
}>();
const { cell, bus } = toRefs(props);
const rootEl = ref<InstanceType<typeof HTMLTableCellElement>>();
const contentAreaEl = ref<InstanceType<typeof HTMLDivElement>>();
const inputAreaEl = ref<InstanceType<typeof HTMLDivElement>>();
const editing = ref<boolean>(false);
const editingValue = ref<CellValue>(undefined);
const cellWidth = computed(() => cell.value.column.width);
const cellType = computed(() => cell.value.column.setting.type);
const needsContentCentering = computed(() => {
switch (cellType.value) {
case 'boolean':
return true;
default:
return false;
}
});
watch(cellWidth, updateContentSize);
watch(() => [cell, cell.value.value], () => {
//
nextTick(updateContentSize);
});
watch(() => cell.value.selected, () => {
if (cell.value.selected) {
rootEl.value?.focus();
}
});
function onCellDoubleClick(ev: MouseEvent) {
switch (ev.type) {
case 'dblclick': {
beginEditing();
break;
}
}
}
function onOutsideMouseDown(ev: MouseEvent) {
const isOutside = ev.target instanceof Node && !rootEl.value?.contains(ev.target);
if (isOutside || !equalCellAddress(cell.value.address, getCellAddress(ev.target as HTMLElement))) {
endEditing(true);
}
}
function onCellKeyDown(ev: KeyboardEvent) {
if (!editing.value) {
switch (ev.code) {
case 'Enter':
case 'F2': {
beginEditing();
break;
}
case 'ArrowRight': {
const next = {
col: cell.value.address.col + 1,
row: cell.value.address.row,
};
emit('selection:move', cell.value, next);
break;
}
case 'ArrowLeft': {
const next = {
col: cell.value.address.col - 1,
row: cell.value.address.row,
};
emit('selection:move', cell.value, next);
break;
}
case 'ArrowUp': {
const next = {
col: cell.value.address.col,
row: cell.value.address.row - 1,
};
emit('selection:move', cell.value, next);
break;
}
case 'ArrowDown': {
const next = {
col: cell.value.address.col,
row: cell.value.address.row + 1,
};
emit('selection:move', cell.value, next);
break;
}
}
} else {
switch (ev.code) {
case 'Escape': {
endEditing(false);
break;
}
case 'Enter': {
if (!ev.isComposing) {
endEditing(true);
}
}
}
}
}
function onInputText(ev: Event) {
editingValue.value = (ev.target as HTMLInputElement).value;
}
function registerOutsideMouseDown() {
unregisterOutsideMouseDown();
addEventListener('mousedown', onOutsideMouseDown);
}
function unregisterOutsideMouseDown() {
removeEventListener('mousedown', onOutsideMouseDown);
}
function beginEditing() {
if (editing.value || !cell.value.column.setting.editable) {
return;
}
switch (cellType.value) {
case 'text': {
editingValue.value = cell.value.value;
editing.value = true;
registerOutsideMouseDown();
emit('edit:begin', cell.value);
nextTick(() => {
if (inputAreaEl.value) {
(inputAreaEl.value.querySelector('*') as HTMLElement).focus();
}
});
break;
}
case 'boolean': {
// UI
cell.value.value = !cell.value.value;
break;
}
}
}
function endEditing(applyValue: boolean) {
if (!editing.value) {
return;
}
emit('edit:end', cell.value);
unregisterOutsideMouseDown();
if (applyValue) {
cell.value.value = editingValue.value;
}
editing.value = false;
rootEl.value?.focus();
}
function updateContentSize() {
cell.value.contentSize = {
width: contentAreaEl.value?.clientWidth ?? 0,
height: contentAreaEl.value?.clientHeight ?? 0,
};
}
</script>
<style module lang="scss">
$cellHeight: 28px;
.cell {
overflow: hidden;
white-space: nowrap;
height: $cellHeight;
max-height: $cellHeight;
min-height: $cellHeight;
border-left: solid 0.5px var(--divider);
border-top: solid 0.5px var(--divider);
cursor: cell;
&:focus {
outline: none;
}
}
.root {
display: flex;
flex-direction: row;
align-items: center;
box-sizing: border-box;
height: 100%;
// selected
border: solid 0.5px transparent;
&.selected {
border: solid 0.5px var(--accentLighten);
}
&.ranged {
background-color: var(--accentedBg);
}
&.center {
justify-content: center;
}
}
.content {
display: inline-block;
padding: 0 8px;
}
.viewImage {
width: auto;
max-height: $cellHeight;
height: $cellHeight;
object-fit: cover;
}
.editingInput {
padding: 0 8px;
width: 100%;
max-width: 100%;
box-sizing: border-box;
min-height: $cellHeight;
max-height: $cellHeight;
height: $cellHeight + 4px;
outline: none;
border: none;
font-family: 'Hiragino Maru Gothic Pro', "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif;
}
</style>

View File

@ -0,0 +1,48 @@
<template>
<tr :class="$style.row">
<MkNumberCell
:content="(row.index + 1).toString()"
:selectable="true"
:row="row"
@selection:row="(sender) => emit('selection:row', sender)"
/>
<MkDataCell
v-for="cell in cells"
:key="cell.address.col"
:cell="cell"
:bus="bus"
@edit:begin="(sender) => emit('edit:begin', sender)"
@edit:end="(sender) => emit('edit:end', sender)"
@selection:move="(sender, next) => emit('selection:move', sender, next)"
/>
</tr>
</template>
<script setup lang="ts">
import { computed, toRefs } from 'vue';
import { CellAddress, GridCell, GridEventEmitter, GridRow } from '@/components/grid/types.js';
import MkDataCell from '@/components/grid/MkDataCell.vue';
import MkNumberCell from '@/components/grid/MkNumberCell.vue';
const emit = defineEmits<{
(ev: 'edit:begin', sender: GridCell): void;
(ev: 'edit:end', sender: GridCell): void;
(ev: 'selection:move', sender: GridCell, next: CellAddress): void;
(ev: 'selection:row', sender: GridRow): void;
}>();
const props = defineProps<{
row: GridRow,
cells: GridCell[],
bus: GridEventEmitter,
}>();
const { cells } = toRefs(props);
const last = computed(() => cells.value[cells.value.length - 1]);
</script>
<style module lang="scss">
.row {
}
</style>

View File

@ -1,23 +1,56 @@
<template>
<div :style="$style.grid">
<div :class="$style.header">
<div
v-for="column in columns"
:key="column.index"
:class="$style.cell"
:style="{ width: column.setting.width + 'px'}"
<table
:class="$style.grid"
@mousedown="onMouseDown"
@mouseup="onMouseUp"
@mousemove="onMouseMove"
>
{{ column.setting.title ?? column.setting.bindTo }}
</div>
</div>
<MkRow v-for="row in rows" :key="row.index" :row="row"/>
</div>
<thead>
<MkHeaderRow
:columns="columns"
:bus="bus"
@width:beginChange="onHeaderCellWidthBeginChange"
@width:endChange="onHeaderCellWidthEndChange"
@width:changing="onHeaderCellWidthChanging"
@width:largest="onHeaderCellWidthLargest"
@selection:column="onSelectionColumn"
/>
</thead>
<tbody>
<MkDataRow
v-for="row in rows"
:key="row.index"
:row="row"
:cells="cells[row.index]"
:bus="bus"
@edit:begin="onCellEditBegin"
@edit:end="onCellEditEnd"
@selection:move="onSelectionMove"
@selection:row="onSelectionRow"
/>
</tbody>
</table>
</template>
<script setup lang="ts">
import { ref, toRefs, watch } from 'vue';
import MkRow from '@/components/grid/MkRow.vue';
import { ColumnSetting, DataSource, GridCell, GridColumn, GridRow } from '@/components/grid/types.js';
import { computed, ref, toRefs, watch } from 'vue';
import {
calcCellWidth,
CELL_ADDRESS_NONE,
CellAddress,
ColumnSetting,
DataSource,
equalCellAddress,
getCellAddress,
GridCell,
GridColumn,
GridEventEmitter,
GridRow,
GridState,
isCellElement,
} from '@/components/grid/types.js';
import MkDataRow from '@/components/grid/MkDataRow.vue';
import MkHeaderRow from '@/components/grid/MkHeaderRow.vue';
const props = defineProps<{
columnSettings: ColumnSetting[],
@ -25,11 +58,230 @@ const props = defineProps<{
}>();
const { columnSettings, data } = toRefs(props);
const columns = ref<GridColumn[]>();
const rows = ref<GridRow[]>();
const columns = ref<GridColumn[]>([]);
const rows = ref<GridRow[]>([]);
const cells = ref<GridCell[][]>([]);
const rangedCells = computed(() => cells.value.flat().filter(it => it.ranged));
const previousCellAddress = ref<CellAddress>(CELL_ADDRESS_NONE);
const editingCellAddress = ref<CellAddress>(CELL_ADDRESS_NONE);
const state = ref<GridState>('normal');
const bus = new GridEventEmitter();
watch(columnSettings, refreshColumnsSetting);
watch(data, refreshData);
watch(rangedCells, () => {
if (rangedCells.value.length <= 1) {
// 1
return;
}
const min = rangedCells.value.reduce(
(acc, value) => {
return {
col: Math.min(acc.col, value.address.col),
row: Math.min(acc.row, value.address.row),
};
},
rangedCells.value[0].address,
);
const max = rangedCells.value.reduce(
(acc, value) => {
return {
col: Math.max(acc.col, value.address.col),
row: Math.max(acc.row, value.address.row),
};
},
rangedCells.value[0].address,
);
expandRange(min, max);
});
if (_DEV_) {
watch(state, (value) => {
console.log(`state: ${value}`);
});
}
function onMouseDown(ev: MouseEvent) {
const cellAddress = getCellAddress(ev.target as HTMLElement);
switch (state.value) {
case 'cellEditing': {
if (availableCellAddress(cellAddress) && !equalCellAddress(editingCellAddress.value, cellAddress)) {
selectionCell(cellAddress);
}
break;
}
case 'normal': {
if (availableCellAddress(cellAddress)) {
selectionCell(cellAddress);
state.value = 'cellSelecting';
}
break;
}
}
}
function onMouseUp() {
switch (state.value) {
case 'cellSelecting': {
state.value = 'normal';
previousCellAddress.value = CELL_ADDRESS_NONE;
break;
}
}
}
function onMouseMove(ev: MouseEvent) {
switch (state.value) {
case 'cellSelecting': {
if (isCellElement(ev.target)) {
const address = getCellAddress(ev.target);
if (!equalCellAddress(previousCellAddress.value, address)) {
if (isCellElement(ev.target)) {
selectionRange(getCellAddress(ev.target));
}
previousCellAddress.value = address;
}
}
break;
}
}
}
function onCellEditBegin(sender: GridCell) {
state.value = 'cellEditing';
editingCellAddress.value = sender.address;
for (const cell of cells.value.flat()) {
if (cell.address.col !== sender.address.col || cell.address.row !== sender.address.row) {
//
cell.selected = false;
}
}
}
function onCellEditEnd() {
editingCellAddress.value = CELL_ADDRESS_NONE;
state.value = 'normal';
}
function onSelectionMove(_: GridCell, next: CellAddress) {
if (availableCellAddress(next)) {
selectionCell(next);
}
}
function onHeaderCellWidthBeginChange(_: GridColumn) {
switch (state.value) {
case 'normal': {
state.value = 'colResizing';
break;
}
}
}
function onHeaderCellWidthEndChange(_: GridColumn) {
switch (state.value) {
case 'colResizing': {
state.value = 'normal';
break;
}
}
}
function onHeaderCellWidthChanging(sender: GridColumn, width: string) {
switch (state.value) {
case 'colResizing': {
const column = columns.value[sender.index];
column.width = width;
break;
}
}
}
function onHeaderCellWidthLargest(sender: GridColumn) {
switch (state.value) {
case 'normal': {
const column = columns.value[sender.index];
const _cells = cells.value;
const largestColumnWidth = columns.value.reduce(
(acc, value) => Math.max(acc, value.contentSize.width),
columns.value[sender.index].contentSize.width,
);
const largestCellWidth = _cells
.map(row => row[column.index])
.reduce(
(acc, value) => Math.max(acc, value.contentSize.width),
_cells[0][column.index].contentSize.width,
);
column.width = `${Math.max(largestColumnWidth, largestCellWidth)}px`;
break;
}
}
}
function onSelectionColumn(sender: GridColumn) {
unSelectionRange();
const targets = cells.value.map(row => row[sender.index].address);
selectionRange(...targets);
}
function onSelectionRow(sender: GridRow) {
unSelectionRange();
const targets = cells.value[sender.index].map(cell => cell.address);
selectionRange(...targets);
}
function selectionCell(target: CellAddress) {
const _cells = cells.value;
for (const row of cells.value) {
for (const cell of row) {
cell.selected = false;
cell.ranged = false;
}
}
_cells[target.row][target.col].selected = true;
_cells[target.row][target.col].ranged = true;
}
function selectionRange(...targets: CellAddress[]) {
const _cells = cells.value;
for (const target of targets) {
_cells[target.row][target.col].ranged = true;
}
}
function unSelectionRange() {
const _cells = cells.value;
for (const row of _cells) {
for (const cell of row) {
cell.selected = false;
cell.ranged = false;
}
}
}
function expandRange(min: CellAddress, max: CellAddress) {
const targetRows = cells.value.slice(min.row, max.row + 1);
for (const row of targetRows) {
for (const cell of row.slice(min.col, max.col + 1)) {
cell.ranged = true;
}
}
}
function availableCellAddress(cellAddress: CellAddress): boolean {
return cellAddress.row >= 0 && cellAddress.col >= 0 && cellAddress.row < rows.value.length && cellAddress.col < columns.value.length;
}
function refreshColumnsSetting() {
const bindToList = columnSettings.value.map(it => it.bindTo);
@ -41,31 +293,44 @@ function refreshColumnsSetting() {
}
function refreshData() {
const _settings = columnSettings.value;
const _data = data.value;
const _rows = _data.map((_, index) => ({ index, cells: Array.of<GridCell>() }));
const _columns = columnSettings.value.map((setting, index) => ({ index, setting, cells: Array.of<GridCell>() }));
const _data: DataSource[] = data.value;
const _rows: GridRow[] = _data.map((_, index) => ({
index,
}));
const _columns: GridColumn[] = columnSettings.value.map((setting, index) => ({
index,
setting,
width: calcCellWidth(setting.width),
contentSize: { width: 0, height: 0 },
}));
const _cells = Array.of<GridCell[]>();
for (const [rowIndex, row] of _rows.entries()) {
const rowCells = Array.of<GridCell>();
for (const [colIndex, column] of _columns.entries()) {
if (!(column.setting.bindTo in _data[rowIndex])) {
continue;
}
const value = _data[rowIndex][column.setting.bindTo];
const value = (column.setting.bindTo in _data[rowIndex])
? _data[rowIndex][column.setting.bindTo]
: undefined;
const cell = {
const cell: GridCell = {
address: { col: colIndex, row: rowIndex },
value,
columnSetting: _settings[colIndex],
column: column,
row: row,
selected: false,
ranged: false,
contentSize: { width: 0, height: 0 },
};
row.cells.push(cell);
column.cells.push(cell);
rowCells.push(cell);
}
_cells.push(rowCells);
}
rows.value = _rows;
columns.value = _columns;
cells.value = _cells;
}
refreshColumnsSetting();
@ -75,24 +340,13 @@ refreshData();
<style module lang="scss">
.grid {
display: flex;
flex-wrap: nowrap;
flex-direction: row;
overflow: scroll;
}
.header {
display: flex;
flex-wrap: nowrap;
table-layout: fixed;
width: fit-content;
user-select: none;
> .cell {
display: block;
padding: 4px 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
border: solid 0.5px var(--divider);
border-spacing: 0;
border-radius: var(--radius);
}
</style>

View File

@ -0,0 +1,195 @@
<template>
<th
ref="rootEl"
:class="$style.cell"
:style="[{ width: column.width }]"
>
<div :class="$style.root">
<div :class="$style.left"/>
<div :class="$style.wrapper" @mouseup="onContentMouseUp">
<div ref="contentEl" :class="$style.contentArea">
{{ text }}
</div>
</div>
<div
:class="$style.right"
@mousedown="onHandleMouseDown"
@dblclick="onHandleDoubleClick"
/>
</div>
</th>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, toRefs, watch } from 'vue';
import { GridColumn, GridEventEmitter } from '@/components/grid/types.js';
const emit = defineEmits<{
//
(ev: 'width:begin-change', sender: GridColumn): void;
(ev: 'width:end-change', sender: GridColumn): void;
(ev: 'width:changing', sender: GridColumn, width: string): void;
(ev: 'width:largest', sender: GridColumn): void;
(ev: 'selection:column', sender: GridColumn): void;
}>();
const props = defineProps<{
column: GridColumn,
bus: GridEventEmitter,
}>();
const { column, bus } = toRefs(props);
const rootEl = ref<InstanceType<typeof HTMLTableCellElement>>();
const contentEl = ref<InstanceType<typeof HTMLDivElement>>();
const resizing = ref<boolean>(false);
const text = computed(() => {
const result = column.value.setting.title ?? column.value.setting.bindTo;
return result.length > 0 ? result : ' ';
});
watch(column, () => {
//
nextTick(updateContentSize);
});
function onContentMouseUp(ev: MouseEvent) {
switch (ev.type) {
case 'mouseup': {
emit('selection:column', column.value);
break;
}
}
}
function onHandleDoubleClick(ev: MouseEvent) {
switch (ev.type) {
case 'dblclick': {
emit('width:largest', column.value);
break;
}
}
}
function onHandleMouseDown(ev: MouseEvent) {
switch (ev.type) {
case 'mousedown': {
if (!resizing.value) {
registerHandleMouseUp();
registerHandleMouseMove();
resizing.value = true;
emit('width:begin-change', column.value);
}
break;
}
}
}
function onHandleMouseMove(ev: MouseEvent) {
if (!rootEl.value) {
//
return;
}
switch (ev.type) {
case 'mousemove': {
if (resizing.value) {
const bounds = rootEl.value.getBoundingClientRect();
const clientWidth = rootEl.value.clientWidth;
const clientRight = bounds.left + clientWidth;
const nextWidth = clientWidth + (ev.clientX - clientRight);
emit('width:changing', column.value, `${nextWidth}px`);
}
break;
}
}
}
function onHandleMouseUp(ev: MouseEvent) {
switch (ev.type) {
case 'mouseup': {
if (resizing.value) {
unregisterHandleMouseUp();
unregisterHandleMouseMove();
resizing.value = false;
emit('width:end-change', column.value);
}
break;
}
}
}
function registerHandleMouseMove() {
unregisterHandleMouseMove();
addEventListener('mousemove', onHandleMouseMove);
}
function unregisterHandleMouseMove() {
removeEventListener('mousemove', onHandleMouseMove);
}
function registerHandleMouseUp() {
unregisterHandleMouseUp();
addEventListener('mouseup', onHandleMouseUp);
}
function unregisterHandleMouseUp() {
removeEventListener('mouseup', onHandleMouseUp);
}
function updateContentSize() {
const clientWidth = contentEl.value?.clientWidth ?? 0;
const clientHeight = contentEl.value?.clientHeight ?? 0;
column.value.contentSize = {
// +3px
width: clientWidth + 3 + 3,
height: clientHeight,
};
}
</script>
<style module lang="scss">
$handleWidth: 3px;
.cell {
border-left: solid 0.5px var(--divider);
cursor: pointer;
}
.root {
display: flex;
flex-direction: row;
height: 100%;
.wrapper {
flex: 1;
display: flex;
flex-direction: row;
overflow: hidden;
justify-content: center;
}
.contentArea {
display: flex;
padding: 4px 0;
overflow: hidden;
white-space: nowrap;
text-align: center;
}
.left {
margin-right: auto;
width: $handleWidth;
min-width: $handleWidth;
}
.right {
margin-left: auto;
width: $handleWidth;
min-width: $handleWidth;
cursor: w-resize;
}
}
</style>

View File

@ -0,0 +1,45 @@
<template>
<tr :class="$style.header">
<MkNumberCell
content="#"
:selectable="false"
:top="true"
/>
<MkHeaderCell
v-for="column in columns"
:key="column.index"
:column="column"
:bus="bus"
@width:beginChange="(sender) => emit('width:begin-change', sender)"
@width:endChange="(sender) => emit('width:end-change', sender)"
@width:changing="(sender, width) => emit('width:changing', sender, width)"
@width:largest="(sender) => emit('width:largest', sender)"
@selection:column="(sender) => emit('selection:column', sender)"
/>
</tr>
</template>
<script setup lang="ts">
import { GridColumn, GridEventEmitter } from '@/components/grid/types.js';
import MkHeaderCell from '@/components/grid/MkHeaderCell.vue';
import MkNumberCell from '@/components/grid/MkNumberCell.vue';
const emit = defineEmits<{
(ev: 'width:begin-change', sender: GridColumn): void;
(ev: 'width:end-change', sender: GridColumn): void;
(ev: 'width:changing', sender: GridColumn, width: string): void;
(ev: 'width:largest', sender: GridColumn): void;
(ev: 'selection:column', sender: GridColumn): void;
}>();
defineProps<{
columns: GridColumn[],
bus: GridEventEmitter,
}>();
</script>
<style module lang="scss">
.header {
}
</style>

View File

@ -0,0 +1,46 @@
<template>
<th :class="[$style.num, [top ? {} : $style.border]]" @mouseup="onMouseUp">
{{ content }}
</th>
</template>
<script setup lang="ts">
import { toRefs } from 'vue';
import { GridRow } from '@/components/grid/types.js';
const emit = defineEmits<{
(ev: 'selection:row', sender: GridRow): void;
}>();
const props = defineProps<{
content: string,
row?: GridRow,
selectable: boolean,
top?: boolean,
}>();
const { content, row, selectable } = toRefs(props);
function onMouseUp(ev: MouseEvent) {
switch (ev.type) {
case 'mouseup': {
if (selectable.value && row.value) {
emit('selection:row', row.value);
}
break;
}
}
}
</script>
<style module lang="scss">
.num {
padding: 0 8px;
min-width: 30px;
width: 30px;
}
.border {
border-top: solid 0.5px var(--divider);
}
</style>

View File

@ -1,23 +0,0 @@
<template>
<div :class="$style.row">
<div v-for="cell in row.cells" :key="JSON.stringify(cell.address)">
<MkCell :cell="cell"/>
</div>
</div>
</template>
<script setup lang="ts">
import MkCell from '@/components/grid/MkCell.vue';
import { GridRow } from '@/components/grid/types.js';
defineProps<{ row: GridRow }>();
</script>
<style module lang="scss">
.row {
display: flex;
flex-wrap: nowrap;
flex-direction: row;
}
</style>

View File

@ -1,14 +1,38 @@
import { EventEmitter } from 'eventemitter3';
export type CellValue = string | boolean | number | undefined | null
export type DataSource = Record<string, CellValue>;
export type GridState = 'normal' | 'cellSelecting' | 'cellEditing' | 'colResizing'
export type RowState = 'normal' | 'added' | 'deleted'
export type Size = {
width: number;
height: number;
}
export type SizeStyle = number | 'auto' | undefined;
export type CellAddress = {
row: number;
col: number;
}
export const CELL_ADDRESS_NONE: CellAddress = {
row: -1,
col: -1,
};
export type GridCell = {
address: CellAddress;
value: CellValue;
columnSetting: ColumnSetting;
column: GridColumn;
row: GridRow;
selected: boolean;
ranged: boolean;
contentSize: Size;
}
export type ColumnType = 'text' | 'number' | 'date' | 'boolean' | 'image';
@ -17,19 +41,70 @@ export type ColumnSetting = {
bindTo: string;
title?: string;
type: ColumnType;
width?: number | 'auto';
width: SizeStyle;
editable?: boolean;
};
export type DataSource = Record<string, CellValue>;
export type GridColumn = {
index: number;
setting: ColumnSetting;
cells: GridCell[];
width: string;
contentSize: Size;
}
export type GridRow = {
index: number;
cells: GridCell[];
}
export class GridEventEmitter extends EventEmitter<{}> {
}
export function isElement(elem: any): elem is HTMLElement {
return elem instanceof HTMLElement;
}
export function isCellElement(elem: any): elem is HTMLTableCellElement {
return elem instanceof HTMLTableCellElement;
}
export function isRowElement(elem: any): elem is HTMLTableRowElement {
return elem instanceof HTMLTableRowElement;
}
export function getCellAddress(elem: HTMLElement, parentNodeCount = 5): CellAddress {
let node = elem;
for (let i = 0; i < parentNodeCount; i++) {
if (isCellElement(node) && isRowElement(node.parentElement)) {
return {
// ヘッダ行ぶんを除く
row: node.parentElement.rowIndex - 1,
// 数値列ぶんを除く
col: node.cellIndex - 1,
};
}
if (!node.parentElement) {
break;
}
node = node.parentElement;
}
throw new Error('Cannot get cell address');
}
export function equalCellAddress(a: CellAddress, b: CellAddress): boolean {
return a.row === b.row && a.col === b.col;
}
export function calcCellWidth(widthSetting: SizeStyle): string {
switch (widthSetting) {
case undefined:
case 'auto': {
return 'auto';
}
default: {
return `${widthSetting}px`;
}
}
}

View File

@ -43,7 +43,18 @@ export class GridItem {
}
static ofEmojiDetailed(it: Misskey.entities.EmojiDetailed): GridItem {
return new GridItem(it.id, it.url, undefined, it.name, it.category ?? '', it.aliases.join(', '), it.license ?? '', it.isSensitive, it.localOnly, it.roleIdsThatCanBeUsedThisEmojiAsReaction.join(', '));
return new GridItem(
it.id,
it.url,
undefined,
it.name,
it.category ?? '',
it.aliases.join(', '),
it.license ?? '',
it.isSensitive,
it.localOnly,
it.roleIdsThatCanBeUsedThisEmojiAsReaction.join(', '),
);
}
public get edited(): boolean {
@ -51,7 +62,7 @@ export class GridItem {
return JSON.stringify(_this) !== origin;
}
public asRecord(): Record<string, unknown> {
return this as Record<string, unknown>;
public asRecord(): Record<string, never> {
return this as Record<string, never>;
}
}

View File

@ -5,14 +5,14 @@
<MkPageHeader/>
</template>
<div class="_gaps" :class="$style.root">
<MkGrid :data="gridItems" :columnSettings="columnSettings"/>
<MkGrid :data="convertedGridItems" :columnSettings="columnSettings"/>
</div>
</MkStickyContainer>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import * as Misskey from 'misskey-js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { GridItem } from '@/pages/admin/custom-emojis-grid.impl.js';
@ -20,19 +20,19 @@ import MkGrid from '@/components/grid/MkGrid.vue';
import { ColumnSetting } from '@/components/grid/types.js';
const columnSettings: ColumnSetting[] = [
{ bindTo: 'url', title: ' ', type: 'image', width: 50 },
{ bindTo: 'name', title: 'name', type: 'text', width: 140 },
{ bindTo: 'category', title: 'category', type: 'text', width: 140 },
{ bindTo: 'aliases', title: 'aliases', type: 'text', width: 140 },
{ bindTo: 'license', title: 'license', type: 'text', width: 140 },
{ bindTo: 'isSensitive', title: 'sensitive', type: 'text', width: 90 },
{ bindTo: 'localOnly', title: 'localOnly', type: 'text', width: 90 },
{ bindTo: 'roleIdsThatCanBeUsedThisEmojiAsReaction', title: 'role', type: 'text', width: 140 },
{ bindTo: 'url', title: '🎨', type: 'image', editable: false, width: 50 },
{ bindTo: 'name', title: 'name', type: 'text', editable: true, width: 140 },
{ bindTo: 'category', title: 'category', type: 'text', editable: true, width: 140 },
{ bindTo: 'aliases', title: 'aliases', type: 'text', editable: true, width: 140 },
{ bindTo: 'license', title: 'license', type: 'text', editable: true, width: 140 },
{ bindTo: 'isSensitive', title: 'sensitive', type: 'boolean', editable: true, width: 90 },
{ bindTo: 'localOnly', title: 'localOnly', type: 'boolean', editable: true, width: 90 },
{ bindTo: 'roleIdsThatCanBeUsedThisEmojiAsReaction', title: 'role', type: 'text', editable: true, width: 140 },
];
const customEmojis = ref<Misskey.entities.EmojiDetailed[]>([]);
const gridItems = ref<GridItem[]>([]);
// const convertedGridItems = computed<DataSource[]>(() => gridItems.value.map(it => it.asRecord()));
const convertedGridItems = computed(() => gridItems.value.map(it => it.asRecord()));
const refreshCustomEmojis = async () => {
customEmojis.value = await misskeyApi('emojis', { detail: true }).then(it => it.emojis);

View File

@ -1,6 +1,6 @@
/*
* version: 2024.2.0-beta.3
* generatedAt: 2024-01-23T01:22:13.177Z
* version: 2024.2.0-beta.4
* generatedAt: 2024-01-23T07:38:51.367Z
*/
import type { SwitchCaseResponseType } from '../api.js';

View File

@ -1,6 +1,6 @@
/*
* version: 2024.2.0-beta.3
* generatedAt: 2024-01-23T01:22:13.175Z
* version: 2024.2.0-beta.4
* generatedAt: 2024-01-23T07:38:51.365Z
*/
import type {
@ -366,6 +366,7 @@ import type {
InviteLimitResponse,
MetaRequest,
MetaResponse,
EmojisRequest,
EmojisResponse,
EmojiRequest,
EmojiResponse,
@ -805,7 +806,7 @@ export type Endpoints = {
'invite/list': { req: InviteListRequest; res: InviteListResponse };
'invite/limit': { req: EmptyRequest; res: InviteLimitResponse };
'meta': { req: MetaRequest; res: MetaResponse };
'emojis': { req: EmptyRequest; res: EmojisResponse };
'emojis': { req: EmojisRequest; res: EmojisResponse };
'emoji': { req: EmojiRequest; res: EmojiResponse };
'miauth/gen-token': { req: MiauthGenTokenRequest; res: MiauthGenTokenResponse };
'mute/create': { req: MuteCreateRequest; res: EmptyResponse };

View File

@ -1,6 +1,6 @@
/*
* version: 2024.2.0-beta.3
* generatedAt: 2024-01-23T01:22:13.173Z
* version: 2024.2.0-beta.4
* generatedAt: 2024-01-23T07:38:51.364Z
*/
import { operations } from './types.js';
@ -368,6 +368,7 @@ export type InviteListResponse = operations['invite/list']['responses']['200']['
export type InviteLimitResponse = operations['invite/limit']['responses']['200']['content']['application/json'];
export type MetaRequest = operations['meta']['requestBody']['content']['application/json'];
export type MetaResponse = operations['meta']['responses']['200']['content']['application/json'];
export type EmojisRequest = operations['emojis']['requestBody']['content']['application/json'];
export type EmojisResponse = operations['emojis']['responses']['200']['content']['application/json'];
export type EmojiRequest = operations['emoji']['requestBody']['content']['application/json'];
export type EmojiResponse = operations['emoji']['responses']['200']['content']['application/json'];

View File

@ -1,6 +1,6 @@
/*
* version: 2024.2.0-beta.3
* generatedAt: 2024-01-23T01:22:13.172Z
* version: 2024.2.0-beta.4
* generatedAt: 2024-01-23T07:38:51.363Z
*/
import { components } from './types.js';

View File

@ -2,8 +2,8 @@
/* eslint @typescript-eslint/no-explicit-any: 0 */
/*
* version: 2024.2.0-beta.3
* generatedAt: 2024-01-23T01:22:13.093Z
* version: 2024.2.0-beta.4
* generatedAt: 2024-01-23T07:38:51.282Z
*/
/**
@ -19027,12 +19027,19 @@ export type operations = {
* **Credential required**: *No*
*/
emojis: {
requestBody: {
content: {
'application/json': {
detail?: boolean | null;
};
};
};
responses: {
/** @description OK (with results) */
200: {
content: {
'application/json': {
emojis: components['schemas']['EmojiSimple'][];
emojis: (components['schemas']['EmojiSimple'] | components['schemas']['EmojiDetailed'])[];
};
};
};

View File

@ -14,8 +14,7 @@
},
"scripts": {
"build": "node ./build.js",
"build:tsc": "npm run tsc",
"tsc": "npm run tsc-esm && npm run tsc-dts",
"build:tsc": "pnpm tsc-esm && pnpm run tsc-dts",
"tsc-esm": "tsc --outDir built/esm",
"tsc-dts": "tsc --outDir built/dts --declaration true --emitDeclarationOnly true --declarationMap true",
"watch": "nodemon -w src -e ts,js,cjs,mjs,json --exec \"pnpm run build:tsc\"",

View File

@ -698,8 +698,8 @@ importers:
specifier: 15.0.0
version: 15.0.0
'@vitejs/plugin-vue':
specifier: 5.0.2
version: 5.0.2(vite@5.0.12)(vue@3.4.15)
specifier: 5.0.3
version: 5.0.3(vite@5.0.12)(vue@3.4.15)
'@vue/compiler-sfc':
specifier: 3.4.3
version: 3.4.3
@ -905,6 +905,9 @@ importers:
'@testing-library/vue':
specifier: 8.0.1
version: 8.0.1(@vue/compiler-sfc@3.4.3)(vue@3.4.15)
'@types/blueimp-load-image':
specifier: ^5.16.6
version: 5.16.6
'@types/escape-regexp':
specifier: 0.0.3
version: 0.0.3
@ -7873,6 +7876,10 @@ packages:
resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==}
dev: true
/@types/blueimp-load-image@5.16.6:
resolution: {integrity: sha512-e7s6CdDCUoBQdCe62Q6OS+DF68M8+ABxCEMh2Isjt4Fl3xuddljCHMN8mak48AMSVGGwUUtNRaZbkzgL5PEWew==}
dev: true
/@types/body-parser@1.19.5:
resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==}
dependencies:
@ -8725,8 +8732,8 @@ packages:
vue: 3.4.15(typescript@5.3.3)
dev: true
/@vitejs/plugin-vue@5.0.2(vite@5.0.12)(vue@3.4.15):
resolution: {integrity: sha512-kEjJHrLb5ePBvjD0SPZwJlw1QTRcjjCA9sB5VyfonoXVBxTS7TMnqL6EkLt1Eu61RDeiuZ/WN9Hf6PxXhPI2uA==}
/@vitejs/plugin-vue@5.0.3(vite@5.0.12)(vue@3.4.15):
resolution: {integrity: sha512-b8S5dVS40rgHdDrw+DQi/xOM9ed+kSRZzfm1T74bMmBDCd8XO87NKlFYInzCtwvtWwXZvo1QxE2OSspTATWrbA==}
engines: {node: ^18.0.0 || >=20.0.0}
peerDependencies:
vite: ^5.0.0