Skip to content

Commit

Permalink
feat: start on work-in-progress Loro integration with the backend.
Browse files Browse the repository at this point in the history
  • Loading branch information
zicklag committed Jan 24, 2025
1 parent 2b7b65d commit 3872c4a
Show file tree
Hide file tree
Showing 24 changed files with 593 additions and 554 deletions.
120 changes: 25 additions & 95 deletions src/lib/components/editors/CompositeMarkdownEditor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,7 @@
import type { HTMLAttributes } from 'svelte/elements';
import RichMarkdownEditor from './RichMarkdownEditor.svelte';
import MarkdownEditor from './MarkdownEditor.svelte';
import DagView from './DagView.svelte';
import { LoroDoc } from 'loro-crdt';
import { CursorAwareness } from 'loro-prosemirror';
import { convertSyncStepsToNodes } from './editor-history';
import './CollaborativeEditor.css';
import type { ViewDagNode } from './DagView.svelte';
import { toByteArray } from 'base64-js';
import type { SvelteComponent } from 'svelte';
let {
content = $bindable(''),
Expand All @@ -21,44 +15,6 @@
markdownMode?: boolean;
} & HTMLAttributes<HTMLDivElement> = $props();
let showHistory = $state(false);
let dagInfo: { nodes: ViewDagNode[]; frontiers: string[] } = $state({
nodes: [],
frontiers: []
});
let loroDoc = new LoroDoc();
let idA = loroDoc.peerIdStr;
let awareness = new CursorAwareness(idA);
const savedState = localStorage.getItem('loro-editor-state');
if (savedState) {
try {
const blob = toByteArray(savedState);
loroDoc.import(blob);
console.log("imported saved state")
console.log(loroDoc.toJSON())
dagInfo = convertSyncStepsToNodes(loroDoc);
} catch (e) {
console.error('Failed to load saved state:', e);
}
}
// 初始化时开启时间戳记录
loroDoc.setRecordTimestamp(true);
loroDoc.setChangeMergeInterval(10);
// 监听变化更新历史信息
loroDoc.subscribe((event) => {
if (event.by === "local") {
dagInfo = convertSyncStepsToNodes(loroDoc);
}
});
function toggleHistory() {
showHistory = !showHistory;
}
let shouldWiggle = $state(false);
const contentProxy = {
Expand All @@ -78,51 +34,33 @@
}
}
};
// svelte-ignore non_reactive_update
let richEditorEl: SvelteComponent;
</script>

<div class="container">
<div class="editors-container">
<div class="editor-card">
<div class="editor-header">
<h3 class="editor-title">Editor</h3>
<div class="flex gap-2">
{#if maxLength != undefined}
<div
class="variant-filled badge transition-transform"
class:too-long-content-badge={shouldWiggle}
onanimationend={() => (shouldWiggle = false)}
>
Length: {content.length} / {maxLength}
</div>
{/if}
<button class="variant-filled badge" onclick={() => (markdownMode = !markdownMode)}>
{markdownMode ? 'Switch to Rich Text' : 'Switch to Markdown'}
</button>
<button class="status-button" onclick={toggleHistory}>
{showHistory ? 'Hide History' : 'Show History'}
</button>
</div>
</div>
<div class="editor-content">
{#if !markdownMode}
<RichMarkdownEditor
loro={loroDoc}
awareness={awareness}
containerId={loroDoc.getMap("doc").id}
bind:content={contentProxy.value}
/>
{:else}
<MarkdownEditor bind:content={contentProxy.value} />
{/if}
<div {...attrs} class="relative">
<div class="absolute -left-4 -top-4 z-10 flex w-full flex-row gap-1">
{#if maxLength != undefined}
<div
class="variant-filled badge transition-transform"
class:too-long-content-badge={shouldWiggle}
onanimationend={() => (shouldWiggle = false)}
>
Length: {content.length} / {maxLength}
</div>
</div>
</div>
{/if}

{#if showHistory}
<div class="history-card">
<h3 class="history-title">Operation History</h3>
<DagView nodes={dagInfo.nodes} frontiers={dagInfo.frontiers} />
</div>
<div class="flex-grow"></div>

<button class="variant-filled badge" onclick={() => (markdownMode = !markdownMode)}
>{markdownMode ? 'Switch to Rich Text' : 'Switch to Markdown'}</button
>
</div>
{#if !markdownMode}
<RichMarkdownEditor bind:this={richEditorEl} bind:content={contentProxy.value} />
{:else}
<MarkdownEditor bind:content={contentProxy.value} />
{/if}
</div>

Expand All @@ -144,7 +82,7 @@
transform: rotate(10deg);
}
100% {
transform: rotate(0deg);
transform: rotate(0deb);
}
}
Expand All @@ -153,12 +91,4 @@
animation: wiggle;
animation-duration: 1s;
}
.flex {
display: flex;
}
.gap-2 {
gap: 0.5rem;
}
</style>
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,4 @@
.timestamp {
color: #999;
}
</style>
</style>
7 changes: 7 additions & 0 deletions src/lib/components/editors/DagView/dag-view-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { DagNode } from './dag-view';

export interface ViewDagNode extends DagNode {
message?: string;
author?: string;
timestamp?: number;
}
File renamed without changes.
File renamed without changes.
53 changes: 53 additions & 0 deletions src/lib/components/editors/DagView/editor-history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { ViewDagNode } from './dag-view-types';
import type { LoroDoc } from 'loro-crdt';

export function convertSyncStepsToNodes(doc: LoroDoc): {
nodes: ViewDagNode[];
frontiers: string[];
} {
const frontiers = doc.oplogFrontiers();
const stack = frontiers.concat();
const nodes: ViewDagNode[] = [];
const visited = new Set<string>();
const processedIds = new Set<string>();

// Build an ordered list of nodes using DFS
while (stack.length > 0) {
const top = stack.pop()!;
const change = doc.getChangeAt(top);

const nodeId = idToString({
peer: change.peer,
counter: top.counter
});

if (!processedIds.has(nodeId)) {
processedIds.add(nodeId);

const deps = change.deps.map(idToString);

nodes.push({
id: nodeId,
deps,
lamport: change.lamport,
message: `Change at ${change.counter} (length: ${change.length})`,
// author: change.peer || '',
timestamp: change.timestamp ? change.timestamp * 1000 : Date.now()
});

for (const dep of change.deps) {
const depId = idToString(dep);
if (!visited.has(depId)) {
stack.push(dep);
visited.add(depId);
}
}
}
}

return { nodes: nodes, frontiers: frontiers.map(idToString) };
}

export function idToString(id: { peer: string; counter: number }): string {
return `${id.counter}@${id.peer}`;
}
164 changes: 164 additions & 0 deletions src/lib/components/editors/LoroCompositeMarkdownEditor.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import RichMarkdownEditor from './LoroRichMarkdownEditor.svelte';
import MarkdownEditor from './MarkdownEditor.svelte';
import DagView from './DagView/DagView.svelte';
import { LoroDoc } from 'loro-crdt';
import { CursorAwareness } from 'loro-prosemirror';
import { convertSyncStepsToNodes } from './DagView/editor-history';
import './CollaborativeEditor.css';
import type { ViewDagNode } from './DagView/DagView.svelte';
import { toByteArray } from 'base64-js';
let {
content = $bindable(''),
markdownMode = $bindable(false),
maxLength,
...attrs
}: {
content: string;
maxLength?: number;
markdownMode?: boolean;
} & HTMLAttributes<HTMLDivElement> = $props();
let showHistory = $state(false);
let dagInfo: { nodes: ViewDagNode[]; frontiers: string[] } = $state({
nodes: [],
frontiers: []
});
let loroDoc = new LoroDoc();
let idA = loroDoc.peerIdStr;
let awareness = new CursorAwareness(idA);
const savedState = globalThis.localStorage?.getItem('loro-editor-state');
if (savedState) {
try {
const blob = toByteArray(savedState);
loroDoc.import(blob);
console.log("imported saved state")
console.log(loroDoc.toJSON())
dagInfo = convertSyncStepsToNodes(loroDoc);
} catch (e) {
console.error('Failed to load saved state:', e);
}
}
// 初始化时开启时间戳记录
loroDoc.setRecordTimestamp(true);
loroDoc.setChangeMergeInterval(10);
// 监听变化更新历史信息
loroDoc.subscribe((event) => {
if (event.by === "local") {
dagInfo = convertSyncStepsToNodes(loroDoc);
}
});
function toggleHistory() {
showHistory = !showHistory;
}
let shouldWiggle = $state(false);
const contentProxy = {
get value() {
return content;
},
set value(value) {
if (maxLength != undefined) {
if (value && value.length > maxLength) {
shouldWiggle = true;
content = value.slice(0, maxLength);
} else {
content = value;
}
} else {
content = value;
}
}
};
</script>

<div class="container">
<div class="editors-container">
<div class="editor-card">
<div class="editor-header">
<h3 class="editor-title">Editor</h3>
<div class="flex gap-2">
{#if maxLength != undefined}
<div
class="variant-filled badge transition-transform"
class:too-long-content-badge={shouldWiggle}
onanimationend={() => (shouldWiggle = false)}
>
Length: {content.length} / {maxLength}
</div>
{/if}
<button class="variant-filled badge" onclick={() => (markdownMode = !markdownMode)}>
{markdownMode ? 'Switch to Rich Text' : 'Switch to Markdown'}
</button>
<button class="status-button" onclick={toggleHistory}>
{showHistory ? 'Hide History' : 'Show History'}
</button>
</div>
</div>
<div class="editor-content">
{#if !markdownMode}
<RichMarkdownEditor
loro={loroDoc}
awareness={awareness}
containerId={loroDoc.getMap("doc").id}
bind:content={contentProxy.value}
/>
{:else}
<MarkdownEditor bind:content={contentProxy.value} />
{/if}
</div>
</div>
</div>

{#if showHistory}
<div class="history-card">
<h3 class="history-title">Operation History</h3>
<DagView nodes={dagInfo.nodes} frontiers={dagInfo.frontiers} />
</div>
{/if}
</div>

<style>
@keyframes wiggle {
0% {
transform: rotate(0deg);
}
20% {
transform: rotate(-10deg);
}
40% {
transform: rotate(10deg);
}
60% {
transform: rotate(-10deg);
}
80% {
transform: rotate(10deg);
}
100% {
transform: rotate(0deg);
}
}
.too-long-content-badge {
@apply variant-filled-error;
animation: wiggle;
animation-duration: 1s;
}
.flex {
display: flex;
}
.gap-2 {
gap: 0.5rem;
}
</style>
Loading

0 comments on commit 3872c4a

Please sign in to comment.