Add the fill handle: drag the selection's corner to copy/fill, Excel-style
- store: fillSelection(tableId, source, target) repeats a source rectangle's cells (values, formulas -- reference-adjusted via the same shiftFormula() a copy uses -- and formatting) across a target rectangle. target may extend the source in any single direction (up/down/left/right); a source bigger than one row/column cycles through modulo its own size, so it still fills correctly past a non-exact-multiple trailing repetition. - TableGrid.vue: replaces the old decorative per-active-cell square with a proper fill handle -- a small square overlaid at the bottom-right corner of the *selection* (not just the anchor cell), draggable. Dragging shows a live dashed preview of the cells about to be filled (snapping to whichever axis -- vertical or horizontal -- is dragged furthest along, same as Excel), and on mouseup commits via fillSelection() and grows the selection to source + filled area, keeping the original anchor active. Verified interactively against the dev server with Playwright: a formula (=A1*2) filled down four rows lands as =A2*2/=A3*2/=A4*2 with correct values, and a two-row source dragged right/down six cells cycles its pattern correctly (1,2,1,2,1,2, including the trailing partial cycle). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,7 @@ Vorbild von **Apple Numbers**. Zwei Dinge stehen im Mittelpunkt:
|
|||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run dev # Vite-Devserver
|
npm run dev # Vite-Devserver
|
||||||
npm run test # Vitest-Suite (Engine + Store + IO), 121 Tests
|
npm run test # Vitest-Suite (Engine + Store + IO), 127 Tests
|
||||||
npm run build # Typecheck + Produktionsbuild
|
npm run build # Typecheck + Produktionsbuild
|
||||||
npm run lint # ESLint (flat config)
|
npm run lint # ESLint (flat config)
|
||||||
```
|
```
|
||||||
@@ -75,7 +75,14 @@ umbenennen, löschen, per Drag sortieren). Zoom 40–200 % pro Blatt
|
|||||||
oder Einzelzell-Auswahl verankert die kopierte Fläche dort und läuft darüber
|
oder Einzelzell-Auswahl verankert die kopierte Fläche dort und läuft darüber
|
||||||
hinaus. Formeln werden dabei **bezugsangepasst** wie in Excel/Numbers — siehe
|
hinaus. Formeln werden dabei **bezugsangepasst** wie in Excel/Numbers — siehe
|
||||||
„`$`-Bezüge sperren" unten; ein gestrichelter Rahmen markiert die
|
„`$`-Bezüge sperren" unten; ein gestrichelter Rahmen markiert die
|
||||||
Kopierquelle, `Esc` verwirft den Puffer.
|
Kopierquelle (mit eigenem Rahmen um deren erste Zelle, bleibt stehen, auch
|
||||||
|
wenn die aktuelle Auswahl längst woanders ist), `Esc` verwirft den Puffer.
|
||||||
|
- **Ausfüllkästchen** (das kleine Quadrat unten rechts an der Auswahl, wie in
|
||||||
|
Excel/Numbers): ziehen wiederholt die Auswahl — Werte, Formeln
|
||||||
|
(bezugsangepasst) und Formatierung — in die überstrichenen Zellen. Nur eine
|
||||||
|
Achse gleichzeitig (rein runter/hoch/rechts/links, je nachdem wohin weiter
|
||||||
|
gezogen wird); ein mehrzeiliger/-spaltiger Ausgangsbereich wird zyklisch
|
||||||
|
wiederholt, auch über eine unvollständige letzte Wiederholung hinweg.
|
||||||
|
|
||||||
**Tastaturnavigation (Excel-nah)** — Fokus in der Tabelle:
|
**Tastaturnavigation (Excel-nah)** — Fokus in der Tabelle:
|
||||||
|
|
||||||
@@ -308,7 +315,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
|
|||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
`npm run test` — Vitest, 121 Tests:
|
`npm run test` — Vitest, 127 Tests:
|
||||||
|
|
||||||
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
|
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
|
||||||
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
|
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
|
||||||
@@ -353,6 +360,10 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
|
|||||||
wird mitkopiert und am Ziel ersetzt; Ausschneiden folgt trotz `$` der
|
wird mitkopiert und am Ziel ersetzt; Ausschneiden folgt trotz `$` der
|
||||||
verschobenen Zelle, lässt aber unbeteiligte und tabellenfremde Referenzen
|
verschobenen Zelle, lässt aber unbeteiligte und tabellenfremde Referenzen
|
||||||
unangetastet und leert die Quelle; `Esc` verwirft den Puffer.
|
unangetastet und leert die Quelle; `Esc` verwirft den Puffer.
|
||||||
|
- **Ausfüllkästchen** — füllt runter/rechts mit bezugsangepassten Formeln;
|
||||||
|
ein mehrzeiliger/-spaltiger Quellbereich wiederholt sich zyklisch, auch bei
|
||||||
|
einer unvollständigen letzten Wiederholung; füllt ebenso hoch/links;
|
||||||
|
Formatierung wird mitkopiert.
|
||||||
- **IO** — JSON verlustfreier Round-Trip (identisch), defekte/leere Datei →
|
- **IO** — JSON verlustfreier Round-Trip (identisch), defekte/leere Datei →
|
||||||
Fehler ohne Nebenwirkung, unbekannter Tabellenname → `#REF`, v1→v2 beim
|
Fehler ohne Nebenwirkung, unbekannter Tabellenname → `#REF`, v1→v2 beim
|
||||||
Import; CSV Übersicht-Export→Re-Import gleiche Zahlen, RFC-4180-Quoting,
|
Import; CSV Übersicht-Export→Re-Import gleiche Zahlen, RFC-4180-Quoting,
|
||||||
|
|||||||
@@ -188,7 +188,9 @@ function scrollActiveIntoView() {
|
|||||||
else if (bottom > box.scrollTop + box.clientHeight) box.scrollTop = bottom - box.clientHeight;
|
else if (bottom > box.scrollTop + box.clientHeight) box.scrollTop = bottom - box.clientHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
function primaryRect() {
|
type Rect = { c0: number; c1: number; r0: number; r1: number };
|
||||||
|
|
||||||
|
function primaryRect(): Rect | null {
|
||||||
const s = mine.value;
|
const s = mine.value;
|
||||||
if (!s) return null;
|
if (!s) return null;
|
||||||
return {
|
return {
|
||||||
@@ -429,6 +431,103 @@ function onDragUp() {
|
|||||||
window.removeEventListener('blur', onDragUp);
|
window.removeEventListener('blur', onDragUp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- fill handle (drag to copy/fill, Excel/Numbers-style) ---------------
|
||||||
|
/** Small square at the selection's bottom-right corner; dragging it repeats
|
||||||
|
* the selection's cells (values, formulas -- reference-adjusted like a
|
||||||
|
* copy --, and formatting) into the cells dragged over. Extends in a single
|
||||||
|
* direction only, whichever axis is dragged further along, same as Excel. */
|
||||||
|
let fillDrag: { source: Rect; anchor: { col: number; row: number } } | null = null;
|
||||||
|
const fillPreview = ref<Rect | null>(null);
|
||||||
|
|
||||||
|
const fillHandleStyle = computed(() => {
|
||||||
|
if (editing.value || dragging.value || fillDrag) return null;
|
||||||
|
const r = primaryRect();
|
||||||
|
if (!r) return null;
|
||||||
|
return { left: colLeft(r.c1 + 1) - 3 + 'px', top: rowTop(r.r1 + 1) - 3 + 'px' };
|
||||||
|
});
|
||||||
|
const fillPreviewStyle = computed(() => {
|
||||||
|
const p = fillPreview.value;
|
||||||
|
if (!p) return null;
|
||||||
|
const left = colLeft(p.c0);
|
||||||
|
const top = rowTop(p.r0);
|
||||||
|
return {
|
||||||
|
left: left + 'px',
|
||||||
|
top: top + 'px',
|
||||||
|
width: colLeft(p.c1 + 1) - left + 'px',
|
||||||
|
height: rowTop(p.r1 + 1) - top + 'px',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function onFillHandleDown(e: MouseEvent) {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const s = mine.value;
|
||||||
|
const r = primaryRect();
|
||||||
|
if (!s || !r) return;
|
||||||
|
if (editing.value) commitEdit();
|
||||||
|
fillDrag = { source: r, anchor: s.anchor };
|
||||||
|
fillPreview.value = null;
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
window.addEventListener('mousemove', onFillDragMove, true);
|
||||||
|
window.addEventListener('mouseup', onFillDragUp, true);
|
||||||
|
window.addEventListener('blur', onFillDragUp);
|
||||||
|
}
|
||||||
|
function onFillDragMove(e: MouseEvent) {
|
||||||
|
if (!fillDrag) return;
|
||||||
|
if (e.buttons === 0) {
|
||||||
|
onFillDragUp(); // a mouseup we never saw -- self-heal a stuck drag
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hit = cellFromPoint(e.clientX, e.clientY);
|
||||||
|
if (!hit) return;
|
||||||
|
const src = fillDrag.source;
|
||||||
|
const c = clampC(hit.c);
|
||||||
|
const r = clampR(hit.r);
|
||||||
|
const down = r - src.r1;
|
||||||
|
const up = src.r0 - r;
|
||||||
|
const right = c - src.c1;
|
||||||
|
const left = src.c0 - c;
|
||||||
|
const vAmount = Math.max(down, up, 0);
|
||||||
|
const hAmount = Math.max(right, left, 0);
|
||||||
|
if (vAmount === 0 && hAmount === 0) {
|
||||||
|
fillPreview.value = null; // back over the source -- nothing to fill
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fillPreview.value =
|
||||||
|
vAmount >= hAmount
|
||||||
|
? down > 0
|
||||||
|
? { c0: src.c0, c1: src.c1, r0: src.r1 + 1, r1: r }
|
||||||
|
: { c0: src.c0, c1: src.c1, r0: r, r1: src.r0 - 1 }
|
||||||
|
: right > 0
|
||||||
|
? { c0: src.c1 + 1, c1: c, r0: src.r0, r1: src.r1 }
|
||||||
|
: { c0: c, c1: src.c0 - 1, r0: src.r0, r1: src.r1 };
|
||||||
|
}
|
||||||
|
function onFillDragUp() {
|
||||||
|
if (!fillDrag) return;
|
||||||
|
const { source, anchor } = fillDrag;
|
||||||
|
const target = fillPreview.value;
|
||||||
|
fillDrag = null;
|
||||||
|
fillPreview.value = null;
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
window.removeEventListener('mousemove', onFillDragMove, true);
|
||||||
|
window.removeEventListener('mouseup', onFillDragUp, true);
|
||||||
|
window.removeEventListener('blur', onFillDragUp);
|
||||||
|
if (!target) return;
|
||||||
|
store.fillSelection(props.tableId, source, target);
|
||||||
|
// grow the selection to source + filled area, keeping the original anchor active
|
||||||
|
const c0 = Math.min(source.c0, target.c0);
|
||||||
|
const c1 = Math.max(source.c1, target.c1);
|
||||||
|
const r0 = Math.min(source.r0, target.r0);
|
||||||
|
const r1 = Math.max(source.r1, target.r1);
|
||||||
|
store.setSelection({
|
||||||
|
tableId: props.tableId,
|
||||||
|
anchor,
|
||||||
|
focus: { col: anchor.col === c0 ? c1 : c0, row: anchor.row === r0 ? r1 : r0 },
|
||||||
|
});
|
||||||
|
focusRoot();
|
||||||
|
}
|
||||||
|
|
||||||
function selectColumn(c: number, e?: MouseEvent) {
|
function selectColumn(c: number, e?: MouseEvent) {
|
||||||
const t = table.value;
|
const t = table.value;
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
@@ -691,6 +790,7 @@ const totalWidth = computed(() => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
onDragUp();
|
onDragUp();
|
||||||
|
onFillDragUp();
|
||||||
onResizeUp();
|
onResizeUp();
|
||||||
onRowResizeUp();
|
onRowResizeUp();
|
||||||
});
|
});
|
||||||
@@ -778,7 +878,6 @@ defineExpose({
|
|||||||
/>
|
/>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
{{ store.cellDisplay(tableId, c - 1, r - 1) }}
|
{{ store.cellDisplay(tableId, c - 1, r - 1) }}
|
||||||
<span v-if="isActive(c - 1, r - 1)" class="handle" />
|
|
||||||
</template>
|
</template>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -786,6 +885,14 @@ defineExpose({
|
|||||||
</table>
|
</table>
|
||||||
<div v-if="clipMarqueeStyle" class="clip-marquee" :style="clipMarqueeStyle" />
|
<div v-if="clipMarqueeStyle" class="clip-marquee" :style="clipMarqueeStyle" />
|
||||||
<div v-if="clipAnchorStyle" class="clip-anchor" :style="clipAnchorStyle" />
|
<div v-if="clipAnchorStyle" class="clip-anchor" :style="clipAnchorStyle" />
|
||||||
|
<div v-if="fillPreviewStyle" class="fill-preview" :style="fillPreviewStyle" />
|
||||||
|
<div
|
||||||
|
v-if="fillHandleStyle"
|
||||||
|
class="fill-handle"
|
||||||
|
:style="fillHandleStyle"
|
||||||
|
title="Ziehen zum Ausfüllen/Kopieren"
|
||||||
|
@mousedown="onFillHandleDown"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ContextMenu v-model:open="menu.open" :x="menu.x" :y="menu.y" :items="menuItems" />
|
<ContextMenu v-model:open="menu.open" :x="menu.x" :y="menu.y" :items="menuItems" />
|
||||||
@@ -932,16 +1039,24 @@ defineExpose({
|
|||||||
box-shadow: inset 0 0 0 2px var(--accent);
|
box-shadow: inset 0 0 0 2px var(--accent);
|
||||||
z-index: 8;
|
z-index: 8;
|
||||||
}
|
}
|
||||||
.handle {
|
/* small square at the selection's bottom-right corner -- drag to fill/copy */
|
||||||
|
.fill-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: -3px;
|
|
||||||
bottom: -3px;
|
|
||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
border: 1px solid var(--surface);
|
border: 1px solid var(--surface);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
z-index: 6;
|
z-index: 9;
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
/* preview of the cells a fill-handle drag is about to fill */
|
||||||
|
.fill-preview {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
border: 1px dashed var(--accent);
|
||||||
|
background: var(--accent-weak);
|
||||||
|
z-index: 8;
|
||||||
}
|
}
|
||||||
.cell-input {
|
.cell-input {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -620,7 +620,61 @@ describe('clipboard: copy / cut / paste', () => {
|
|||||||
expect(s.cellInput(id, coords('D2').col, coords('D2').row)).toBe('=$D$1+$A$1');
|
expect(s.cellInput(id, coords('D2').col, coords('D2').row)).toBe('=$D$1+$A$1');
|
||||||
expect(val(s, name, 'D2')).toBe(15);
|
expect(val(s, name, 'D2')).toBe(15);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fill handle (drag to copy/fill)', () => {
|
||||||
|
it('fills down: repeats the source rows, formulas shifted per repetition', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const name = s.tableById(id)!.name;
|
||||||
|
setCell(s, name, 'A1', '10');
|
||||||
|
setCell(s, name, 'B1', '=A1*2');
|
||||||
|
|
||||||
|
s.fillSelection(id, { c0: 1, c1: 1, r0: 0, r1: 0 }, { c0: 1, c1: 1, r0: 1, r1: 3 });
|
||||||
|
|
||||||
|
expect(s.cellInput(id, coords('B2').col, coords('B2').row)).toBe('=A2*2');
|
||||||
|
expect(s.cellInput(id, coords('B3').col, coords('B3').row)).toBe('=A3*2');
|
||||||
|
expect(s.cellInput(id, coords('B4').col, coords('B4').row)).toBe('=A4*2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills right: a multi-column source cycles, not just repeats the last column', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const name = s.tableById(id)!.name;
|
||||||
|
setCell(s, name, 'A1', '1');
|
||||||
|
setCell(s, name, 'B1', '2');
|
||||||
|
|
||||||
|
// 2-wide source dragged 5 columns right -> C,D,E,F,G cycle 1,2,1,2,1
|
||||||
|
s.fillSelection(id, { c0: 0, c1: 1, r0: 0, r1: 0 }, { c0: 2, c1: 6, r0: 0, r1: 0 });
|
||||||
|
|
||||||
|
expect(val(s, name, 'C1')).toBe(1);
|
||||||
|
expect(val(s, name, 'D1')).toBe(2);
|
||||||
|
expect(val(s, name, 'E1')).toBe(1);
|
||||||
|
expect(val(s, name, 'F1')).toBe(2);
|
||||||
|
expect(val(s, name, 'G1')).toBe(1); // partial trailing cycle, still fills
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills up/left too (a drag past the source in either direction)', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
const name = s.tableById(id)!.name;
|
||||||
|
setCell(s, name, 'C3', '9');
|
||||||
|
|
||||||
|
s.fillSelection(id, { c0: 2, c1: 2, r0: 2, r1: 2 }, { c0: 2, c1: 2, r0: 0, r1: 1 }); // up
|
||||||
|
expect(val(s, name, 'C1')).toBe(9);
|
||||||
|
expect(val(s, name, 'C2')).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries formatting along, overwriting the destination', () => {
|
||||||
|
const s = fresh();
|
||||||
|
const id = s.addTable(s.doc.activeSheetId);
|
||||||
|
setCell(s, s.tableById(id)!.name, 'A1', 'x');
|
||||||
|
s.setRangeFormat(id, coords('A1'), coords('A1'), { bold: true });
|
||||||
|
|
||||||
|
s.fillSelection(id, { c0: 0, c1: 0, r0: 0, r1: 0 }, { c0: 0, c1: 0, r0: 1, r1: 1 });
|
||||||
|
|
||||||
|
expect(s.cellFormat(id, coords('A2').col, coords('A2').row)).toEqual({ bold: true });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('text box objects', () => {
|
describe('text box objects', () => {
|
||||||
|
|||||||
@@ -1003,6 +1003,47 @@ function createStore() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Excel/Numbers "fill handle": repeat `source`'s cells (values,
|
||||||
|
* formulas -- reference-adjusted the same way a copy is -- and formatting)
|
||||||
|
* across `target`, a rectangle extending purely up, down, left or right
|
||||||
|
* from `source` (never both axes at once -- the caller picks the single
|
||||||
|
* dominant drag direction). Repeats cyclically, so a `target` that isn't an
|
||||||
|
* exact multiple of `source`'s size still fills its last partial cycle.
|
||||||
|
*/
|
||||||
|
function fillSelection(tableId: string, source: Rect, target: Rect) {
|
||||||
|
const t = tableById(tableId);
|
||||||
|
if (!t) return;
|
||||||
|
const srcW = source.c1 - source.c0 + 1;
|
||||||
|
const srcH = source.r1 - source.r0 + 1;
|
||||||
|
const cells = new Map<string, ClipboardCell>();
|
||||||
|
for (let row = source.r0; row <= source.r1; row++) {
|
||||||
|
for (let col = source.c0; col <= source.c1; col++) {
|
||||||
|
const raw = cellInput(tableId, col, row);
|
||||||
|
const format = t.formats?.[fmtKey(col, row)];
|
||||||
|
if (raw || format) {
|
||||||
|
cells.set(`${col - source.c0},${row - source.r0}`, { raw, format: format ? { ...format } : undefined });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let row = target.r0; row <= target.r1; row++) {
|
||||||
|
for (let col = target.c0; col <= target.c1; col++) {
|
||||||
|
// which cell of the source block this target cell cycles back to
|
||||||
|
const dc = (((col - source.c0) % srcW) + srcW) % srcW;
|
||||||
|
const dr = (((row - source.r0) % srcH) + srcH) % srcH;
|
||||||
|
const cell = cells.get(`${dc},${dr}`);
|
||||||
|
const raw = cell?.raw ?? '';
|
||||||
|
const deltaC = col - (source.c0 + dc);
|
||||||
|
const deltaR = row - (source.r0 + dr);
|
||||||
|
setCellInput(tableId, col, row, raw ? shiftFormula(raw, deltaC, deltaR) : raw);
|
||||||
|
clearRangeFormat(tableId, { col, row }, { col, row });
|
||||||
|
if (cell?.format) {
|
||||||
|
setRangeFormat(tableId, { col, row }, { col, row }, cell.format as Partial<Record<keyof CellFormat, unknown>>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The "active cell" is always the anchor -- the first cell of the current
|
// The "active cell" is always the anchor -- the first cell of the current
|
||||||
// selection action (click, shift-click, drag start, ...) -- so it stays put
|
// selection action (click, shift-click, drag start, ...) -- so it stays put
|
||||||
// while a multi-cell selection is being made, matching Excel/Numbers.
|
// while a multi-cell selection is being made, matching Excel/Numbers.
|
||||||
@@ -1197,6 +1238,7 @@ function createStore() {
|
|||||||
cancelClipboard,
|
cancelClipboard,
|
||||||
hasClipboard,
|
hasClipboard,
|
||||||
clipboardRect,
|
clipboardRect,
|
||||||
|
fillSelection,
|
||||||
// document
|
// document
|
||||||
replaceDocument,
|
replaceDocument,
|
||||||
resetToSeed,
|
resetToSeed,
|
||||||
|
|||||||
Reference in New Issue
Block a user