Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: use Loro for page revision tracking. #282

Merged
merged 7 commits into from
Jan 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@
"zod": "^3.23.8"
},
"dependencies": {
"base64-js": "^1.5.1",
"loro-crdt": "^1.3.0",
"loro-prosemirror": "^0.2.1",
"sharp": "^0.33.5",
"zlib-sync": "^0.1.9"
},
Expand Down
1,469 changes: 769 additions & 700 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

157 changes: 157 additions & 0 deletions src/lib/components/editors/CollaborativeEditor.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
.container {
display: flex;
flex-direction: row;
gap: 24px;
padding: 24px;
min-height: 100vh;
}

.editors-container {
flex: 1;
display: flex;
flex-direction: column;
gap: 24px;
max-height: calc(100vh - 48px);
margin: 0 auto;
width: 910px;
min-width: 910px;
transition: width 0.3s ease;
}

.container:has(.history-card) .editors-container {
width: 400px;
min-width: 400px;
}

.editor-card {
flex: 1;
display: flex;
flex-direction: column;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.2);
backdrop-filter: blur(8px);
}

.editor-header {
padding: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
color: white;
}

.editor-title {
margin: 0;
color: white;
font-size: 18px;
}

.editor-content {
flex: 1;
padding: 16px;
overflow: auto;
color: rgba(255, 255, 255, 0.9);
}

.editor-content > :global(*) {
height: 100%;
}

.history-card {
width: 500px;
min-width: 500px;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 20px;
border: 1px solid rgba(255, 255, 255, 0.2);
backdrop-filter: blur(8px);
max-height: calc(100vh - 48px);
overflow: auto;
}

.history-title {
margin: 0;
color: white;
font-size: 18px;
position: sticky;
top: 0;
background-color: rgba(255, 255, 255, 0.1);
padding: 12px 24px;
z-index: 1;
backdrop-filter: blur(8px);
border-radius: 24px;
text-align: center;
margin-bottom: 16px;
border: 1px solid rgba(255, 255, 255, 0.2);
}

.history-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}

.status-button {
color: white;
border: none;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s ease;
background-color: rgba(76, 29, 149, 0.5);
border: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(8px);
}

.status-button:hover {
background-color: rgba(139, 92, 246, 0.8);
border-color: rgba(255, 255, 255, 0.3);
transform: translateY(-1px);
}

.variant-filled.badge {
padding: 4px 8px;
font-size: 12px;
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
}

.dag-view-message {
padding-left: 16px;
font-size: 13px;
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
color: rgba(255, 255, 255, 0.9);
}

.dag-view-message > span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.dag-view-message > span:first-child {
flex: 1;
min-width: 0;
}

@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 {
animation: wiggle;
animation-duration: 1s;
}
40 changes: 40 additions & 0 deletions src/lib/components/editors/DagView/DagView.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
.dag-view-message:hover {
background-color: rgba(139, 92, 246, 0.2);
cursor: pointer;
border-radius: 8px;
padding: 4px 8px;
}

.dag-view-message {
color: rgba(255, 255, 255, 0.9);
padding: 4px 8px;
transition: all 0.2s ease;
}

.dag-view-message .author {
color: rgba(255, 255, 255, 0.6);
margin-left: 0.8em;
}

.dag-view-message:hover .author {
color: rgba(255, 255, 255, 0.8);
}

.dag-view-message .operationType {
color: rgba(255, 255, 255, 0.6);
margin-left: 0.8em;
}

.dag-view-message:hover .operationType {
color: rgba(255, 255, 255, 0.8);
}

.dag-view-message .timestamp {
color: rgba(255, 255, 255, 0.6);
margin-left: 0.8em;
}

.dag-view-message:hover .timestamp {
color: rgba(255, 255, 255, 0.8);
}

171 changes: 171 additions & 0 deletions src/lib/components/editors/DagView/DagView.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
<script context="module" lang="ts">
import { visualize, type DagNode, type Row, type Thread } from "./dag-view";

export interface ViewDagNode extends DagNode {
message?: string;
author?: string;
timestamp?: number;
}
</script>

<script lang="ts">
import "./DagView.css";

export let nodes: ViewDagNode[];
export let frontiers: string[];

const CELL_SIZE = 24;
const NODE_RADIUS = 5;

$: view = (() => {
const map = new Map<string, DagNode>();
for (const node of nodes) {
map.set(node.id, node);
}
return visualize(id => map.get(id), frontiers);
})();

function renderConnection(
type: 'input' | 'output',
xFrom: number,
xTo: number,
y: number,
tid: number
): string {
const startX = xFrom * CELL_SIZE / 2 + CELL_SIZE / 4;
const endX = xTo * CELL_SIZE / 2 + CELL_SIZE / 4;
const startY = type === 'input' ? y : y + CELL_SIZE / 2;
const endY = type === 'input' ? y + CELL_SIZE / 2 : y;

let path = "";
if (startX > endX) {
const controlPoint1X = startX;
const controlPoint1Y = endY;
const controlPoint2X = endX;
const controlPoint2Y = startY;
path = `M ${startX} ${startY} C ${controlPoint1X} ${controlPoint1Y}, ${controlPoint2X} ${controlPoint2Y}, ${endX} ${endY}`;
} else {
const controlPoint1X = startX;
const controlPoint1Y = endY;
const controlPoint2X = startX;
const controlPoint2Y = endY;
path = `M ${startX} ${startY} C ${controlPoint1X} ${controlPoint1Y}, ${controlPoint2X} ${controlPoint2Y}, ${endX} ${endY}`;
}

return path;
}

function tidToColor(tid: number): string {
const hue = (tid * 137.508) % 360;
const saturation = 70 + (tid % 30);
const lightness = 45 + (tid % 20);
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
}

function renderConnections(row: Row, type: 'input' | 'output', y: number) {
const ans: { path: string; tid: number }[] = [];
row[type].forEach((thread: Thread, i: number) => {
const connectionA = row.cur_tids.indexOf(thread.tid);
const connectionB = thread.dep_on_active ? row.active_index : -1;
if (connectionA >= 0) {
ans.push({
path: renderConnection(type, i, connectionA, y, thread.tid),
tid: thread.tid
});
}
if (connectionB >= 0) {
ans.push({
path: renderConnection(type, i, connectionB, y, thread.tid),
tid: thread.tid
});
}
});
return ans;
}

function renderRow(row: Row, rowIndex: number, backgroundColor: string) {
const y = CELL_SIZE / 2;
const inputConn = renderConnections(row, 'input', y - CELL_SIZE / 2);
const outputConn = renderConnections(row, 'output', y);
const width = (Math.max(row.cur_tids.length, row.output.length, row.input.length)) * CELL_SIZE / 2 + 8;

return {
width,
inputConn,
outputConn,
row,
y
};
}
</script>

<div class="history-content">
{#each view.rows as row, index}
{@const rowContent = renderRow(row, index, 'white')}
<div style="position: relative; height: {CELL_SIZE}px; display: flex; flex-direction: row; align-items: center">
<svg width={rowContent.width} height={CELL_SIZE}>
{#each rowContent.inputConn as conn}
<path d={conn.path} fill="none" stroke={tidToColor(conn.tid)} stroke-width="2" />
{/each}
{#each rowContent.outputConn as conn}
<path d={conn.path} fill="none" stroke={tidToColor(conn.tid)} stroke-width="2" />
{/each}
{#each row.cur_tids as tid, i}
{#if tid === row.active.tid}
<circle
cx={(i * CELL_SIZE) / 2 + CELL_SIZE / 4}
cy={rowContent.y}
r={NODE_RADIUS}
fill="rgb(100, 100, 230)"
stroke="white"
/>
{/if}
{/each}
</svg>
<div class="dag-view-message">
<span>
{(row.active.node as ViewDagNode).message ?? row.active.node.id}
</span>
<span class="author">
{(row.active.node as ViewDagNode).author}
</span>
<span class="timestamp">
{#if (row.active.node as ViewDagNode).timestamp != null}
{new Date((row.active.node as ViewDagNode).timestamp!).toLocaleString()}
{/if}
</span>
</div>
</div>
{/each}
</div>

<style>
.dag-view-message {
font-size: 12px;
font-family: 'Helvetica Neue', Arial, sans-serif;
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}

.dag-view-message > span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.dag-view-message > span:first-child {
flex: 1;
min-width: 0;
}

.author {
color: #666;
}

.timestamp {
color: #999;
}
</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;
}
Loading
Loading