- init
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Draggable / resizable wrapper for a table or chart object on the sheet canvas.
|
||||
*
|
||||
* - the header strip and the frame border move the object;
|
||||
* - the SE handle resizes a chart (free w/h) or proportionally scales a table's
|
||||
* columns (Numbers-style);
|
||||
* - name is editable via double-click, delete via the × button.
|
||||
*
|
||||
* All pointer deltas are divided by `zoom` so dragging tracks the cursor at any
|
||||
* zoom level.
|
||||
*/
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import type { SheetObject } from '../store/types';
|
||||
import { useDocument } from '../store/document';
|
||||
import TableGrid from './TableGrid.vue';
|
||||
import ChartView from './ChartView.vue';
|
||||
import TextBox from './TextBox.vue';
|
||||
|
||||
const props = defineProps<{ object: SheetObject; selected: boolean; zoom: number }>();
|
||||
const emit = defineEmits<{ (e: 'select'): void }>();
|
||||
|
||||
const store = useDocument();
|
||||
|
||||
const renaming = ref(false);
|
||||
const nameDraft = ref('');
|
||||
const nameInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
function startRename() {
|
||||
renaming.value = true;
|
||||
nameDraft.value = props.object.name;
|
||||
nextTick(() => nameInput.value?.select());
|
||||
}
|
||||
function commitRename() {
|
||||
store.renameObject(props.object.id, nameDraft.value);
|
||||
renaming.value = false;
|
||||
}
|
||||
|
||||
// ---- drag to move -------------------------------------------------
|
||||
let drag: { px: number; py: number; ox: number; oy: number } | null = null;
|
||||
function onMoveDown(e: MouseEvent) {
|
||||
if (e.button !== 0) return;
|
||||
emit('select');
|
||||
drag = { px: e.clientX, py: e.clientY, ox: props.object.x, oy: props.object.y };
|
||||
document.body.style.userSelect = 'none';
|
||||
window.addEventListener('mousemove', onMoveMove);
|
||||
window.addEventListener('mouseup', onMoveUp);
|
||||
}
|
||||
function onMoveMove(e: MouseEvent) {
|
||||
if (!drag) return;
|
||||
const dx = (e.clientX - drag.px) / props.zoom;
|
||||
const dy = (e.clientY - drag.py) / props.zoom;
|
||||
store.moveObject(props.object.id, drag.ox + dx, drag.oy + dy);
|
||||
}
|
||||
function onMoveUp() {
|
||||
drag = null;
|
||||
document.body.style.userSelect = '';
|
||||
window.removeEventListener('mousemove', onMoveMove);
|
||||
window.removeEventListener('mouseup', onMoveUp);
|
||||
}
|
||||
|
||||
// ---- SE resize --------------------------------------------------
|
||||
let rs: { px: number; py: number; w: number; h: number } | null = null;
|
||||
const contentEl = ref<HTMLElement | null>(null);
|
||||
|
||||
/** How the SE handle behaves for this object. */
|
||||
const resizeKind = computed<'free' | 'table-viewport' | 'table-columns'>(() => {
|
||||
const o = props.object;
|
||||
if (o.kind === 'chart' || o.kind === 'text') return 'free';
|
||||
return o.view.mode === 'fixed' ? 'table-viewport' : 'table-columns';
|
||||
});
|
||||
|
||||
function onResizeDown(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
emit('select');
|
||||
const o = props.object;
|
||||
const rect = contentEl.value?.getBoundingClientRect();
|
||||
let w: number;
|
||||
let h: number;
|
||||
if (o.kind === 'chart' || o.kind === 'text') {
|
||||
w = o.width;
|
||||
h = o.height;
|
||||
} else if (o.view.mode === 'fixed') {
|
||||
w = o.view.width;
|
||||
h = o.view.height;
|
||||
} else {
|
||||
w = (rect?.width ?? 300) / props.zoom;
|
||||
h = (rect?.height ?? 200) / props.zoom;
|
||||
}
|
||||
rs = { px: e.clientX, py: e.clientY, w, h };
|
||||
document.body.style.userSelect = 'none';
|
||||
window.addEventListener('mousemove', onResizeMove);
|
||||
window.addEventListener('mouseup', onResizeUp);
|
||||
}
|
||||
function onResizeMove(e: MouseEvent) {
|
||||
if (!rs) return;
|
||||
const dx = (e.clientX - rs.px) / props.zoom;
|
||||
const dy = (e.clientY - rs.py) / props.zoom;
|
||||
const o = props.object;
|
||||
if (resizeKind.value === 'free') {
|
||||
if (o.kind === 'chart') store.resizeChart(o.id, rs.w + dx, rs.h + dy);
|
||||
else if (o.kind === 'text') store.resizeText(o.id, rs.w + dx, rs.h + dy);
|
||||
} else if (resizeKind.value === 'table-viewport') {
|
||||
store.setTableView(o.id, { width: rs.w + dx, height: rs.h + dy });
|
||||
} else {
|
||||
const factor = Math.max(0.4, (rs.w + dx) / rs.w);
|
||||
store.scaleTableColumns(o.id, factor, 104);
|
||||
rs = { ...rs, px: e.clientX, w: rs.w + dx };
|
||||
}
|
||||
}
|
||||
function onResizeUp() {
|
||||
rs = null;
|
||||
document.body.style.userSelect = '';
|
||||
window.removeEventListener('mousemove', onResizeMove);
|
||||
window.removeEventListener('mouseup', onResizeUp);
|
||||
}
|
||||
|
||||
const icon = computed(() =>
|
||||
props.object.kind === 'table' ? '▦' : props.object.kind === 'chart' ? '▤' : '¶',
|
||||
);
|
||||
|
||||
const style = computed(() => {
|
||||
const o = props.object;
|
||||
const base: Record<string, string> = { left: o.x + 'px', top: o.y + 'px' };
|
||||
if (o.kind === 'chart' || o.kind === 'text') {
|
||||
base.width = o.width + 'px';
|
||||
base.height = o.height + 'px';
|
||||
}
|
||||
return base;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="canvas-object"
|
||||
:class="{ selected, flex: object.kind === 'chart' || object.kind === 'text' }"
|
||||
:style="style"
|
||||
@mousedown="emit('select')"
|
||||
>
|
||||
<div class="chrome" @mousedown.stop="onMoveDown">
|
||||
<input
|
||||
v-if="renaming"
|
||||
ref="nameInput"
|
||||
v-model="nameDraft"
|
||||
class="name-edit"
|
||||
@mousedown.stop
|
||||
@blur="commitRename"
|
||||
@keydown.enter.prevent="commitRename"
|
||||
@keydown.esc.prevent="renaming = false"
|
||||
/>
|
||||
<span v-else class="name" @dblclick.stop="startRename">
|
||||
{{ icon }} {{ object.name }}
|
||||
</span>
|
||||
<button
|
||||
class="del"
|
||||
title="Objekt löschen"
|
||||
@mousedown.stop
|
||||
@click.stop="store.deleteObject(object.id)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref="contentEl" class="content">
|
||||
<TableGrid v-if="object.kind === 'table'" :table-id="object.id" />
|
||||
<ChartView v-else-if="object.kind === 'chart'" :obj-id="object.id" />
|
||||
<TextBox v-else :obj-id="object.id" />
|
||||
</div>
|
||||
|
||||
<div v-if="selected" class="rs-handle" @mousedown="onResizeDown" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.canvas-object {
|
||||
position: absolute;
|
||||
background: var(--surface);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.canvas-object.selected {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-weak), var(--shadow);
|
||||
}
|
||||
.canvas-object.flex {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.chrome {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 26px;
|
||||
padding: 0 4px 0 10px;
|
||||
background: var(--surface-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-radius: 10px 10px 0 0;
|
||||
cursor: grab;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-dim);
|
||||
user-select: none;
|
||||
}
|
||||
.chrome:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.name-edit {
|
||||
flex: 1;
|
||||
font: inherit;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 4px;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
.del {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.del:hover {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
.content {
|
||||
overflow: hidden;
|
||||
border-radius: 0 0 10px 10px;
|
||||
}
|
||||
.canvas-object.flex .content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.rs-handle {
|
||||
position: absolute;
|
||||
right: -5px;
|
||||
bottom: -5px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
background: var(--accent);
|
||||
border: 2px solid var(--surface);
|
||||
cursor: nwse-resize;
|
||||
z-index: 8;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user