Live collaboration with Yjs over ActionCable
All editors now work on a server document at the same time instead of taking turns behind an edit lock. - The document's content is a sequence of Yjs updates (document_updates); DocumentChannel stores and relays them immediately, checks access on subscribe and again whenever links or memberships change. - app/frontend/collab/: the store stays position-based; a binding maps it to a Yjs form with stable row/column ids, so a row inserted by one person doesn't misplace what another is typing. Remote changes are applied only after pending local ones went into Yjs. - Per-person undo (Y.UndoManager), presence via awareness: the others' selections as coloured boxes with names, avatars in the toolbar. - Existing JSON documents convert on first open, deterministically, so two people opening one at once end up with the same Yjs state. - Editors compact the stored updates after 200 and keep a JSON snapshot current for export and duplicating. Edit lock and polling are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
|
||||
import { numberToCol, cycleReferenceLocks } from '../engine';
|
||||
import { useDocument } from '../store/document';
|
||||
import { useLibrary } from '../store/library';
|
||||
import { DEFAULT_ROW_H, type SelectionState } from '../store/types';
|
||||
import { useFormulaAutocomplete } from '../composables/useFormulaAutocomplete';
|
||||
import { useKeyboardPointMode } from '../composables/useKeyboardPointMode';
|
||||
@@ -22,6 +23,7 @@ import FormulaSuggest from './FormulaSuggest.vue';
|
||||
const props = defineProps<{ tableId: string; selected: boolean }>();
|
||||
|
||||
const store = useDocument();
|
||||
const library = useLibrary();
|
||||
const table = computed(() => store.tableById(props.tableId));
|
||||
|
||||
const DEFAULT_W = 104;
|
||||
@@ -270,6 +272,28 @@ const rowHot = (r: number) => selRects.value.some((k) => r >= k.r0 && r <= k.r1)
|
||||
// ---- clipboard source marker (one dashed rectangle, Excel-style "marching
|
||||
// ants" -- overlaid once over the whole range, not per cell). --------------
|
||||
const clipRect = computed(() => store.clipboardRect.value);
|
||||
/** Where the others (live collaboration) have their selection in this table. */
|
||||
const peerRects = computed(() =>
|
||||
library.peers.value
|
||||
.filter((p) => p.rect && p.tableId === props.tableId)
|
||||
.map((p) => {
|
||||
const k = p.rect!;
|
||||
const left = colLeft(k.c0);
|
||||
const top = rowTop(k.r0);
|
||||
return {
|
||||
id: p.clientId,
|
||||
name: p.name,
|
||||
style: {
|
||||
left: left + 'px',
|
||||
top: top + 'px',
|
||||
width: colLeft(Math.min(k.c1, (table.value?.cols ?? 1) - 1) + 1) - left + 'px',
|
||||
height: rowTop(Math.min(k.r1, (table.value?.rows ?? 1) - 1) + 1) - top + 'px',
|
||||
borderColor: p.color,
|
||||
'--peer': p.color,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
const clipMarqueeStyle = computed(() => {
|
||||
const k = clipRect.value;
|
||||
if (!k || k.tableId !== props.tableId) return null;
|
||||
@@ -1411,6 +1435,9 @@ defineExpose({
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-for="p in peerRects" :key="p.id" class="peer-sel" :style="p.style">
|
||||
<span class="peer-name">{{ p.name }}</span>
|
||||
</div>
|
||||
<div v-if="clipMarqueeStyle" class="clip-marquee" :style="clipMarqueeStyle" />
|
||||
<div v-if="clipAnchorStyle" class="clip-anchor" :style="clipAnchorStyle" />
|
||||
<div v-if="fillPreviewStyle" class="fill-preview" :style="fillPreviewStyle" />
|
||||
@@ -1578,6 +1605,26 @@ defineExpose({
|
||||
.cell.is-active {
|
||||
box-shadow: inset 0 0 0 2px var(--accent);
|
||||
}
|
||||
/* someone else's selection (live collaboration) */
|
||||
.peer-sel {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 2px solid;
|
||||
z-index: 7;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.peer-name {
|
||||
position: absolute;
|
||||
top: -17px;
|
||||
left: -2px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px 3px 3px 0;
|
||||
background: var(--peer);
|
||||
color: #fff;
|
||||
font-size: 10.5px;
|
||||
line-height: 15px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* one dashed rectangle around the whole copied range, not per cell */
|
||||
.clip-marquee {
|
||||
position: absolute;
|
||||
|
||||
Reference in New Issue
Block a user