Compare commits

..

36 Commits

Author SHA1 Message Date
Trisha Lim
d73896b24c missing highlight 2025-04-01 16:28:09 +07:00
Trisha Lim
1ab581358e replace focus styles with green 2025-04-01 16:13:19 +07:00
Trisha Lim
9fca21a3f8 remove line highlight for old lines, add highlight for focus 2025-04-01 16:12:13 +07:00
Guido D'Orsi
a11f531d4b Merge pull request #1752 from garden-co/fix/twoslash-dark
fix popover dark mode colors
2025-04-01 11:10:33 +02:00
Trisha Lim
9dd717bf0e lint fixes 2025-04-01 11:36:02 +07:00
Trisha Lim
42551bb4fd fix console errors on react guide 2025-04-01 11:34:49 +07:00
Benjamin S. Leveritt
5c5de61cb6 Merge pull request #1755 from garden-co/fix-multiauth-resolve
Use the new Resolve API for the multiauth example app
2025-03-31 17:44:56 +01:00
pax-k
7fdfc7fddb chore: changeset 2025-03-31 19:43:56 +03:00
pax-k
b108c6166e chore: changeset 2025-03-31 14:42:31 +03:00
pax-k
e0bc9a7f67 fix(example): use the new Resolve API 2025-03-31 14:41:39 +03:00
Benjamin S. Leveritt
f900495f8d Merge pull request #1742 from garden-co/1731-add-out-of-bounds-indicator
1731 Add out of bounds indicator
2025-03-31 12:13:25 +01:00
Benjamin S. Leveritt
4188c7a18d Rename variables 2025-03-31 11:49:48 +01:00
Benjamin S. Leveritt
7315960477 Fixes from comments 2025-03-31 11:40:41 +01:00
Benjamin S. Leveritt
815f485ee5 Fix log 2025-03-31 09:57:05 +01:00
Benjamin S. Leveritt
402008a08f Drop console.logs from build 2025-03-31 09:57:05 +01:00
Benjamin S. Leveritt
bc1576cb92 Adds tests 2025-03-31 09:57:05 +01:00
Benjamin S. Leveritt
acbe66ed60 Replace out of bounds circle with arrow 2025-03-31 09:57:04 +01:00
Benjamin S. Leveritt
ec1fd2aaa2 Fix cursor label positioning relative to the bounds 2025-03-31 09:57:04 +01:00
Benjamin S. Leveritt
bd796555f2 Tweaks label 2025-03-31 09:57:04 +01:00
Benjamin S. Leveritt
153f6ec245 Add proportional label placement 2025-03-31 09:57:04 +01:00
Benjamin S. Leveritt
42d007da13 WIP cursor labels 2025-03-31 09:57:04 +01:00
Benjamin S. Leveritt
c764eeff56 Adds debug flag 2025-03-31 09:57:04 +01:00
Benjamin S. Leveritt
a7a00e6a7c Joins label 2025-03-31 09:57:04 +01:00
Benjamin S. Leveritt
bc65695eee Merges OutOfBoundsMarker with Cursor 2025-03-31 09:57:03 +01:00
Benjamin S. Leveritt
153231aecb Removes OoB labels 2025-03-31 09:57:03 +01:00
Benjamin S. Leveritt
d814899d71 Adds an out of bounds marker 2025-03-31 09:57:03 +01:00
Benjamin S. Leveritt
0b6c35c08a Adds isOutOfBounds test 2025-03-31 09:57:03 +01:00
Benjamin S. Leveritt
e62ea5a8ac Adds Boundary viz 2025-03-31 09:57:03 +01:00
Benjamin S. Leveritt
a5bffd7312 Adds ViewBox type 2025-03-31 09:57:03 +01:00
Benjamin S. Leveritt
9aa91ec525 Adds additional logging during bootstrapping
More logs
2025-03-31 09:57:03 +01:00
Benjamin S. Leveritt
9b8c299ba5 Add basic creds to .env.example 2025-03-31 09:57:03 +01:00
Guido D'Orsi
7486ca768d Merge pull request #1753 from garden-co/fix/llms-txt-content
fix: missing content on llms.txt
2025-03-31 10:25:01 +02:00
Trisha Lim
fa4d501eb0 fix missing intro page on llms.txt 2025-03-31 08:43:38 +07:00
Trisha Lim
a126d5dbf8 fix: missing content on llms.txt 2025-03-31 08:35:43 +07:00
Trisha Lim
d697cc5713 fix popover dark mode colors 2025-03-30 23:10:57 +07:00
Guido D'Orsi
d95c8cc302 Merge pull request #1717 from garden-co/fix/missing-toc
fix: missing TOC on docs intro
2025-03-30 00:09:05 +01:00
34 changed files with 1039 additions and 464 deletions

View File

@@ -0,0 +1,5 @@
---
"multiauth": patch
---
Use the new Resolve API

View File

@@ -1,3 +1,3 @@
VITE_CURSOR_FEED_ID=example-cursor-feed-id
VITE_GROUP_ID=co_example-group-id
VITE_CURSOR_FEED_ID=multi-cursors-250425-1708
VITE_GROUP_ID=co_zXE8C8sd9QxEbxnt3neRvFRPFUc
VITE_OLD_CURSOR_AGE_SECONDS=5

View File

@@ -7,10 +7,9 @@
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"format-and-lint": "biome check .",
"format-and-lint:fix": "biome check . --write"
"format-and-lint:fix": "biome check . --write",
"test": "vitest"
},
"dependencies": {
"@react-spring/web": "^9.7.5",
@@ -30,6 +29,7 @@
"postcss": "^8.4.27",
"tailwindcss": "^3.4.17",
"typescript": "~5.6.2",
"vite": "^6.0.11"
"vite": "^6.0.11",
"vitest": "3.0.5"
}
}

View File

@@ -16,6 +16,7 @@ function App() {
const [cursorFeedID, setCursorFeedID] = useState<ID<CursorFeed> | null>(null);
useEffect(() => {
console.log("Loading cursor feed...", me.id);
if (!me?.id) return;
const loadCursorFeed = async () => {
const id = await loadCursorContainer(

View File

@@ -0,0 +1,16 @@
import { ViewBox } from "../types";
export function Boundary({ bounds }: { bounds: ViewBox }) {
return (
<>
<rect
x={bounds.x}
y={bounds.y}
width={bounds.width}
height={bounds.height}
stroke="red"
fill="none"
/>
</>
);
}

View File

@@ -1,9 +1,11 @@
import { useAccount } from "jazz-react";
import { CoFeedEntry, co } from "jazz-tools";
import { CursorMoveEvent, useCanvas } from "../hooks/useCanvas";
import { Cursor as CursorType } from "../types";
import { Cursor as CursorType, ViewBox } from "../types";
import { centerOfBounds } from "../utils/centerOfBounds";
import { getColor } from "../utils/getColor";
import { getName } from "../utils/getName";
import { Boundary } from "./Boundary";
import { CanvasBackground } from "./CanvasBackground";
import { CanvasDemoContent } from "./CanvasDemoContent";
import { Cursor } from "./Cursor";
@@ -12,6 +14,16 @@ const OLD_CURSOR_AGE_SECONDS = Number(
import.meta.env.VITE_OLD_CURSOR_AGE_SECONDS,
);
const DEBUG = import.meta.env.VITE_DEBUG === "true";
// For debugging purposes, we can set a fixed bounds
const debugBounds: ViewBox = {
x: 320,
y: 320,
width: 640,
height: 640,
};
interface CanvasProps {
remoteCursors: CoFeedEntry<co<CursorType>>[];
onCursorMove: (move: CursorMoveEvent) => void;
@@ -28,8 +40,12 @@ function Canvas({ remoteCursors, onCursorMove, name }: CanvasProps) {
mousePosition,
bgPosition,
dottedGridSize,
viewBox,
} = useCanvas({ onCursorMove });
const bounds = DEBUG ? debugBounds : viewBox;
const center = centerOfBounds(bounds);
return (
<svg width="100%" height="100%" {...svgProps}>
<CanvasBackground
@@ -38,6 +54,7 @@ function Canvas({ remoteCursors, onCursorMove, name }: CanvasProps) {
/>
<CanvasDemoContent />
{DEBUG && <Boundary bounds={bounds} />}
{remoteCursors.map((entry) => {
if (
@@ -48,14 +65,21 @@ function Canvas({ remoteCursors, onCursorMove, name }: CanvasProps) {
return null;
}
const name = getName(entry.by?.profile?.name, entry.tx.sessionID);
const color = getColor(entry.tx.sessionID);
const age = new Date().getTime() - new Date(entry.madeAt).getTime();
return (
<Cursor
key={entry.tx.sessionID}
position={entry.value.position}
color={getColor(entry.tx.sessionID)}
color={color}
isDragging={false}
isRemote={true}
name={getName(entry.by?.profile?.name, entry.tx.sessionID)}
name={name}
age={age}
centerOfBounds={center}
bounds={bounds}
/>
);
})}
@@ -67,6 +91,8 @@ function Canvas({ remoteCursors, onCursorMove, name }: CanvasProps) {
isDragging={isDragging}
isRemote={false}
name={name}
centerOfBounds={center}
bounds={bounds}
/>
) : null}
</svg>

View File

@@ -1,4 +1,8 @@
import { animated, to, useSpring } from "@react-spring/web";
import { Vec2, ViewBox } from "../types";
import { calculateBoundaryIntersection } from "../utils/boundaryIntersection";
import { isOutOfBounds } from "../utils/isOutOfBounds";
import { CursorLabel } from "./CursorLabel";
interface CursorProps {
position: { x: number; y: number };
@@ -6,18 +10,56 @@ interface CursorProps {
isDragging: boolean;
isRemote: boolean;
name: string;
age?: number;
centerOfBounds: Vec2;
bounds?: ViewBox;
}
const LABEL_BOUNDS_PADDING = 32;
const CURSOR_VISIBILITY_OFFSET = 20;
export function Cursor({
position,
color,
isDragging,
isRemote,
name,
age = 0,
centerOfBounds,
bounds,
}: CursorProps) {
if (!bounds) return null;
const intersectionPoint = calculateBoundaryIntersection(
centerOfBounds,
position,
bounds,
);
const labelBounds = {
x: bounds.x + LABEL_BOUNDS_PADDING / 2,
y: bounds.y + LABEL_BOUNDS_PADDING / 2,
width: bounds.width - LABEL_BOUNDS_PADDING,
height: bounds.height - LABEL_BOUNDS_PADDING,
};
const cursorIntersectionPoint = calculateBoundaryIntersection(
centerOfBounds,
position,
labelBounds,
);
const isStrictlyOutOfBounds = isOutOfBounds(position, bounds);
const shouldHideCursor = isOutOfBounds(
position,
bounds,
CURSOR_VISIBILITY_OFFSET,
);
const springs = useSpring({
x: position.x,
y: position.y,
opacity: age > 60000 ? 0 : 1,
immediate: !isRemote,
config: {
tension: 170,
@@ -25,40 +67,75 @@ export function Cursor({
},
});
const intersectionSprings = useSpring({
x: intersectionPoint.x,
y: intersectionPoint.y,
config: {
tension: 170,
friction: 26,
},
});
return (
<animated.g
transform={to(
[springs.x, springs.y],
(x: number, y: number) => `translate(${x}, ${y})`,
)}
>
<polygon
points="0,0 0,20 14.3,14.3"
fill={
isDragging ? color : `color-mix(in oklch, ${color}, transparent 56%)`
}
stroke={color}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
<text
x="10"
y="25"
fill={color}
stroke="white"
strokeWidth="3"
strokeLinejoin="round"
paintOrder="stroke"
fontSize="14"
dominantBaseline="hanging"
style={{
fontFamily: "Inter, Manrope, system-ui, sans-serif",
fontWeight: 500,
}}
<>
<animated.g
transform={to(
[springs.x, springs.y],
(x: number, y: number) => `translate(${x}, ${y})`,
)}
>
{name}
</text>
</animated.g>
{isStrictlyOutOfBounds ? (
<circle cx={0} cy={0} r={4} fill={color} />
) : null}
{!shouldHideCursor ? (
<polygon
points="0,0 0,20 14.3,14.3"
fill={
isDragging
? color
: `color-mix(in oklch, ${color}, transparent 56%)`
}
stroke={color}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
) : null}
</animated.g>
{isRemote ? (
<>
<CursorLabel
name={name}
color={color}
position={cursorIntersectionPoint}
bounds={bounds}
isOutOfBounds={isStrictlyOutOfBounds}
/>
{isStrictlyOutOfBounds ? (
<animated.g
transform={to(
[intersectionSprings.x, intersectionSprings.y],
(x: number, y: number) => {
const angle =
Math.atan2(centerOfBounds.y - y, centerOfBounds.x - x) *
(180 / Math.PI);
return `translate(${x}, ${y}) rotate(${angle})`;
},
)}
>
<path
d="M 8,-4 L 2,0 L 8,4"
fill="none"
stroke={color}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</animated.g>
) : null}
</>
) : null}
</>
);
}

View File

@@ -0,0 +1,93 @@
import { animated, to, useSpring } from "@react-spring/web";
import { useEffect, useRef, useState } from "react";
import { Vec2, ViewBox } from "../types";
import { getLabelPosition } from "../utils/getLabelPosition";
const DEBUG = import.meta.env.VITE_DEBUG === "true";
interface CursorLabelProps {
name: string;
color: string;
position: Vec2;
bounds?: ViewBox;
isOutOfBounds?: boolean;
}
interface TextDimensions {
width: number;
height: number;
}
export function CursorLabel({
name,
color,
position,
bounds,
isOutOfBounds,
}: CursorLabelProps) {
const textRef = useRef<SVGTextElement>(null);
const [dimensions, setDimensions] = useState<TextDimensions>({
width: 0,
height: 0,
});
useEffect(() => {
const bbox = textRef.current?.getBBox();
setDimensions({ width: bbox?.width ?? 0, height: bbox?.height ?? 0 });
}, [name]);
const labelPosition = getLabelPosition(
position,
dimensions,
bounds,
isOutOfBounds,
);
const labelSprings = useSpring<Vec2>({
...labelPosition,
config: {
tension: 170,
friction: 26,
},
});
return (
<>
<animated.text
ref={textRef}
x={to([labelSprings.x], (x) => x)}
y={to([labelSprings.y], (y) => y)}
fill={color}
stroke="white"
strokeWidth="3"
strokeLinejoin="round"
paintOrder="stroke"
fontSize="14"
dominantBaseline="hanging"
textAnchor="start"
>
{name}
</animated.text>
{DEBUG ? (
<>
<text x={position.x} y={position.y} fill="red" fontSize="8">
{position.x}, {position.y}
</text>
<text x={labelPosition.x} y={labelPosition.y} fill="red" fontSize="8">
{bounds
? `${bounds.x - labelPosition.x}, ${bounds.y - labelPosition.y}`
: "no bounds"}
</text>
<line
x1={position.x}
y1={position.y}
x2={labelPosition.x}
y2={labelPosition.y}
stroke="red"
strokeWidth="1"
strokeLinejoin="round"
/>
</>
) : null}
</>
);
}

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import type { ViewBox } from "../types";
import { throttleTime } from "../utils/throttleTime";
export interface CursorMoveEvent {
@@ -13,7 +14,7 @@ export function useCanvas({
onCursorMove: (event: CursorMoveEvent) => void;
throttleMs?: number;
}) {
const [viewBox, setViewBox] = useState({
const [viewBox, setViewBox] = useState<ViewBox>({
x: 0,
y: 0,
width: window.innerWidth,
@@ -134,5 +135,6 @@ export function useCanvas({
mousePosition,
bgPosition,
dottedGridSize,
viewBox,
};
}

View File

@@ -18,3 +18,10 @@ export type RemoteCursor = Cursor & {
isRemote: true;
isDragging: boolean;
};
export type ViewBox = {
x: number;
y: number;
width: number;
height: number;
};

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { calculateBoundaryIntersection } from "../boundaryIntersection";
describe("calculateBoundaryIntersection", () => {
const bounds = { x: 0, y: 0, width: 100, height: 100 };
it("should handle vertical lines (dx = 0)", () => {
const center = { x: 50, y: 50 };
const point = { x: 50, y: 150 }; // Straight up from center
const intersection = calculateBoundaryIntersection(center, point, bounds);
expect(intersection).toEqual({ x: 50, y: 100 }); // Should intersect at bottom boundary
});
it("should handle horizontal lines (dy = 0)", () => {
const center = { x: 50, y: 50 };
const point = { x: 150, y: 50 }; // Straight right from center
const intersection = calculateBoundaryIntersection(center, point, bounds);
expect(intersection).toEqual({ x: 100, y: 50 }); // Should intersect at right boundary
});
it("should handle vertical lines at boundaries", () => {
const center = { x: 0, y: 50 };
const point = { x: 0, y: 150 }; // Vertical line at x=0
const intersection = calculateBoundaryIntersection(center, point, bounds);
expect(intersection).toEqual({ x: 0, y: 100 }); // Should intersect at bottom boundary
});
it("should handle horizontal lines at boundaries", () => {
const center = { x: 50, y: 0 };
const point = { x: 150, y: 0 }; // Horizontal line at y=0
const intersection = calculateBoundaryIntersection(center, point, bounds);
expect(intersection).toEqual({ x: 100, y: 0 }); // Should intersect at right boundary
});
});

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { getLabelPosition } from "../getLabelPosition";
describe("getLabelPosition", () => {
const dimensions = { width: 100, height: 20 };
const bounds = { x: 0, y: 0, width: 1000, height: 1000 };
it("should position label with default offset when cursor is in bounds", () => {
const position = { x: 500, y: 500 };
const result = getLabelPosition(position, dimensions, bounds, false);
expect(result).toEqual({
x: position.x + 15,
y: position.y + 25,
});
});
it("should position label with default offset when bounds are undefined", () => {
const position = { x: 500, y: 500 };
const result = getLabelPosition(position, dimensions, undefined, true);
expect(result).toEqual({
x: position.x + 15,
y: position.y + 25,
});
});
it("should adjust label position based on cursor position when out of bounds", () => {
const position = { x: 800, y: 600 };
const result = getLabelPosition(position, dimensions, bounds, true);
// At x=800, percentageH = 0.8, so x offset should be -80 (0.8 * width)
// At y=600, percentageV = 0.6, so y offset should be -12 (0.6 * height)
expect(result).toEqual({
x: position.x - 0.8 * dimensions.width,
y: position.y - 0.6 * dimensions.height,
});
});
it("should handle cursor at bounds edges", () => {
const position = { x: 1000, y: 1000 }; // Bottom-right corner
const result = getLabelPosition(position, dimensions, bounds, true);
// At the edges, percentages should be 1, so full dimension should be subtracted
expect(result).toEqual({
x: position.x - dimensions.width,
y: position.y - dimensions.height,
});
});
it("should handle cursor at bounds origin", () => {
const position = { x: 0, y: 0 }; // Top-left corner
const result = getLabelPosition(position, dimensions, bounds, true);
// At origin, percentages should be 0, so no offset from position
expect(result).toEqual({
x: position.x,
y: position.y,
});
});
});

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { isOutOfBounds } from "../isOutOfBounds";
describe("isOutOfBounds", () => {
it("should return true if the position is out of bounds", () => {
expect(
isOutOfBounds(
{ x: 101, y: 101 },
{ x: 0, y: 0, width: 100, height: 100 },
),
).toBe(true);
});
it("should return false if the position is within bounds", () => {
expect(
isOutOfBounds({ x: 50, y: 50 }, { x: 0, y: 0, width: 100, height: 100 }),
).toBe(false);
});
it("should return false if the position is inside the grace area", () => {
expect(
isOutOfBounds(
{ x: 110, y: 110 },
{ x: 0, y: 0, width: 100, height: 100 },
20,
),
).toBe(false);
});
});

View File

@@ -0,0 +1,77 @@
import { Vec2, ViewBox } from "../types";
/**
* Calculate the intersection point of a line and a boundary.
* @param center - The origin of the line.
* @param point - The end of the line to calculate the intersection for.
* @param bounds - The bounds of the boundary.
* @returns The intersection point.
*/
export function calculateBoundaryIntersection(
center: Vec2,
point: Vec2,
bounds: ViewBox,
): Vec2 {
// Calculate direction vector
const dx = point.x - center.x;
const dy = point.y - center.y;
// Calculate all possible intersections
let horizontalIntersection: Vec2 | null = null;
let verticalIntersection: Vec2 | null = null;
// Check horizontal bounds
if (dx !== 0) {
// Skip horizontal bounds check if line is vertical
if (point.x < bounds.x) {
const y = center.y + (dy * (bounds.x - center.x)) / dx;
if (y >= bounds.y && y <= bounds.y + bounds.height) {
horizontalIntersection = { x: bounds.x, y };
}
} else if (point.x > bounds.x + bounds.width) {
const y = center.y + (dy * (bounds.x + bounds.width - center.x)) / dx;
if (y >= bounds.y && y <= bounds.y + bounds.height) {
horizontalIntersection = { x: bounds.x + bounds.width, y };
}
}
}
// Check vertical bounds
if (dy !== 0) {
// Skip vertical bounds check if line is horizontal
if (point.y < bounds.y) {
const x = center.x + (dx * (bounds.y - center.y)) / dy;
if (x >= bounds.x && x <= bounds.x + bounds.width) {
verticalIntersection = { x, y: bounds.y };
}
} else if (point.y > bounds.y + bounds.height) {
const x = center.x + (dx * (bounds.y + bounds.height - center.y)) / dy;
if (x >= bounds.x && x <= bounds.x + bounds.width) {
verticalIntersection = { x, y: bounds.y + bounds.height };
}
}
}
// Choose the intersection point that's closest to the actual point
if (horizontalIntersection && verticalIntersection) {
const horizontalDist = Math.hypot(
point.x - horizontalIntersection.x,
point.y - horizontalIntersection.y,
);
const verticalDist = Math.hypot(
point.x - verticalIntersection.x,
point.y - verticalIntersection.y,
);
return horizontalDist < verticalDist
? horizontalIntersection
: verticalIntersection;
}
return (
horizontalIntersection ||
verticalIntersection || {
x: Math.max(bounds.x, Math.min(bounds.x + bounds.width, point.x)),
y: Math.max(bounds.y, Math.min(bounds.y + bounds.height, point.y)),
}
);
}

View File

@@ -0,0 +1,17 @@
import { Vec2, ViewBox } from "../types";
/**
* Get the center of a bounds.
* @param bounds - The bounds to get the center of.
* @returns The center of the bounds.
*/
export function centerOfBounds(bounds?: ViewBox): Vec2 {
if (!bounds) {
return { x: 0, y: 0 };
}
return {
x: bounds.x + bounds.width / 2,
y: bounds.y + bounds.height / 2,
};
}

View File

@@ -0,0 +1,40 @@
import { Vec2, ViewBox } from "../types";
interface TextDimensions {
width: number;
height: number;
}
interface LabelPosition {
x: number;
y: number;
}
/**
* Calculate the position of a cursor label based on cursor position, label dimensions, and bounds
* Such that the label is always on the same side of the bounds as the cursor
* @param position - The cursor position
* @param dimensions - The dimensions of the label text
* @param bounds - The viewport bounds
* @param isOutOfBounds - Whether the cursor is outside the bounds
* @returns The calculated label position
*/
export function getLabelPosition(
position: Vec2,
dimensions: TextDimensions,
bounds?: ViewBox,
isOutOfBounds?: boolean,
): LabelPosition {
if (!isOutOfBounds || !bounds) {
return { x: position.x + 15, y: position.y + 25 };
}
// Calculate the percentage of the bounds that the intersection point is from the left
const percentageH = (position.x - bounds.x) / bounds.width;
const percentageV = (position.y - bounds.y) / bounds.height;
return {
x: position.x - percentageH * dimensions.width,
y: position.y - percentageV * dimensions.height,
};
}

View File

@@ -0,0 +1,21 @@
import { Vec2, ViewBox } from "../types";
/**
* Check if a position is out of bounds of a view box.
* @param position - The position to check.
* @param bounds - The bounds of the view box.
* @param grace - The grace distance to allow for the position to be out of bounds.
* @returns True if the position is out of bounds, false otherwise.
*/
export function isOutOfBounds(
position: Vec2,
bounds: ViewBox,
grace: number = 0,
): boolean {
return (
position.x < bounds.x - grace ||
position.x > bounds.x + bounds.width + grace ||
position.y < bounds.y - grace ||
position.y > bounds.y + bounds.height + grace
);
}

View File

@@ -1,15 +1,30 @@
import { Account, Group, type ID } from "jazz-tools";
import { CursorContainer, CursorFeed } from "../schema";
/**
* Creates a new group to own the cursor container.
* @param me - The account of the current user.
* @returns The group.
*/
function createGroup(me: Account) {
const group = Group.create({
owner: me,
});
group.addMember("everyone", "writer");
console.log("Created group");
console.log(`Add "VITE_GROUP_ID=${group.id}" to your .env file`);
return group;
}
export async function loadGroup(me: Account, groupID: ID<Group>) {
if (groupID === undefined) {
console.log("No group ID found in .env, creating group...");
return createGroup(me);
}
const group = await Group.load(groupID, {});
if (group === null) {
const group = Group.create({
owner: me,
});
group.addMember("everyone", "writer");
console.log("Created group:", group.id);
return group;
if (group === null || group === undefined) {
console.log("Group not found, creating group...");
return createGroup(me);
}
return group;
}
@@ -24,23 +39,25 @@ export async function loadGroup(me: Account, groupID: ID<Group>) {
*/
export async function loadCursorContainer(
me: Account,
cursorFeedID: ID<CursorFeed>,
cursorFeedID = "cursor-feed",
groupID: ID<Group>,
): Promise<ID<CursorFeed> | undefined> {
if (!me) return;
console.log("Loading group...");
const group = await loadGroup(me, groupID);
const cursorContainerID = CursorContainer.findUnique(
cursorFeedID,
group?.id as ID<Group>,
);
console.log("Loading cursor container:", cursorContainerID);
const cursorContainer = await CursorContainer.load(cursorContainerID, {
resolve: {
cursorFeed: true,
},
});
if (cursorContainer === null) {
if (cursorContainer === null || cursorContainer === undefined) {
console.log("Global cursors does not exist, creating...");
const cursorContainer = CursorContainer.create(
{
@@ -61,6 +78,6 @@ export async function loadCursorContainer(
"Global cursors already exists, loading...",
cursorContainer.id,
);
return cursorContainer.cursorFeed.id;
return cursorContainer.cursorFeed?.id;
}
}

View File

@@ -4,4 +4,12 @@ import { defineConfig } from "vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
build: {
minify: "esbuild",
terserOptions: {
compress: {
drop_console: true,
},
},
},
});

View File

@@ -1,7 +1,7 @@
import { useAccount, useIsAuthenticated } from "jazz-react";
export function Home() {
const { me, logOut } = useAccount({ root: {} });
const { me, logOut } = useAccount({ resolve: { root: true } });
const isAuthenticated = useIsAuthenticated();
if (!me) return;

View File

@@ -153,10 +153,10 @@ Update the schema to include a `validate` method.
<CodeGroup>
```ts
// schema.ts
export class DraftBubbleTeaOrder extends CoMap { // old
name = co.optional.string; // old
export class DraftBubbleTeaOrder extends CoMap {
name = co.optional.string;
validate() {
validate() { // [!code ++:9]
const errors: string[] = [];
if (!this.name) {
@@ -165,7 +165,7 @@ export class DraftBubbleTeaOrder extends CoMap { // old
return { errors };
}
} // old
}
```
</CodeGroup>
@@ -174,33 +174,33 @@ Then perform the validation on submit.
<CodeGroup>
```tsx
// CreateOrder.tsx
export function CreateOrder() { // old
const { me } = useAccount(); // old
const [draft, setDraft] = useState<DraftBubbleTeaOrder>(); // old
export function CreateOrder() {
const { me } = useAccount();
const [draft, setDraft] = useState<DraftBubbleTeaOrder>();
useEffect(() => { // old
setDraft(DraftBubbleTeaOrder.create({})); // old
}, [me?.id]); // old
useEffect(() => {
setDraft(DraftBubbleTeaOrder.create({}));
}, [me?.id]);
const onSave = (e: React.FormEvent<HTMLFormElement>) => { // old
e.preventDefault(); // old
if (!draft) return; // old
const onSave = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!draft) return;
const validation = draft.validate();
const validation = draft.validate(); // [!code ++:5]
if (validation.errors.length > 0) {
console.log(validation.errors);
return;
}
const order = draft as BubbleTeaOrder; // old
const order = draft as BubbleTeaOrder;
console.log("Order created:", order); // old
}; // old
console.log("Order created:", order);
};
if (!draft) return; // old
if (!draft) return;
return <OrderForm order={draft} onSave={onSave} />; // old
} // old
return <OrderForm order={draft} onSave={onSave} />;
}
```
</CodeGroup>
@@ -213,15 +213,15 @@ By storing the draft in the user's account, they can come back to it anytime wit
<CodeGroup>
```ts
// schema.ts
export class BubbleTeaOrder extends CoMap { // old
name = co.string; // old
} // old
export class BubbleTeaOrder extends CoMap {
name = co.string;
}
export class DraftBubbleTeaOrder extends CoMap { // old
name = co.optional.string; // old
} // old
export class DraftBubbleTeaOrder extends CoMap {
name = co.optional.string;
}
export class AccountRoot extends CoMap {
export class AccountRoot extends CoMap { // [!code ++:15]
draft = co.ref(DraftBubbleTeaOrder);
}
@@ -243,22 +243,22 @@ Let's not forget to update the `AccountSchema`.
<CodeGroup>
```ts
import { JazzProvider } from "jazz-react"; // old
import { JazzAccount } from "./schema";
import { JazzProvider } from "jazz-react";
import { JazzAccount } from "./schema"; // [!code ++]
export function MyJazzProvider({ children }: { children: React.ReactNode }) { // old
return ( // old
<JazzProvider // old
export function MyJazzProvider({ children }: { children: React.ReactNode }) {
return (
<JazzProvider
sync={{ peer: "wss://cloud.jazz.tools/?key=you@example.com" }}
AccountSchema={JazzAccount}
>// old
{children} // old
</JazzProvider> // old
); // old
} // old
AccountSchema={JazzAccount} // [!code ++]
>
{children}
</JazzProvider>
);
}
// Register the Account schema so `useAccount` returns our custom `JazzAccount`
declare module "jazz-react" {
declare module "jazz-react" { // [!code ++:5]
interface Register {
Account: JazzAccount;
}
@@ -271,36 +271,36 @@ Instead of creating a new draft every time we use the create form, let's use the
<CodeGroup>
```tsx
// CreateOrder.tsx
export function CreateOrder() {// old
const { me } = useAccount({ root: { draft: {} } });
export function CreateOrder() {
const { me } = useAccount({ root: { draft: {} } }); // [!code ++:3]
if (!me?.root) return;
const onSave = (e: React.FormEvent<HTMLFormElement>) => {// old
e.preventDefault();// old
const onSave = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const draft = me.root.draft;
const draft = me.root.draft; // [!code ++:2]
if (!draft) return;
const validation = draft.validate();// old
if (validation.errors.length > 0) {// old
console.log(validation.errors);// old
return;// old
}// old
const validation = draft.validate();
if (validation.errors.length > 0) {
console.log(validation.errors);
return;
}
const order = draft as BubbleTeaOrder;// old
console.log("Order created:", order);// old
const order = draft as BubbleTeaOrder;
console.log("Order created:", order);
// create a new empty draft
me.root.draft = DraftBubbleTeaOrder.create(
me.root.draft = DraftBubbleTeaOrder.create( // [!code ++:3]
{},
);
};// old
};
return <CreateOrderForm id={me.root.draft.id} onSave={onSave} />
} // old
}
function CreateOrderForm({
function CreateOrderForm({ // [!code ++:13]
id,
onSave,
}: {
@@ -330,23 +330,23 @@ Simply add a `hasChanges` checker to your schema.
<CodeGroup>
```ts
// schema.ts
export class DraftBubbleTeaOrder extends CoMap { // old
name = co.optional.string; // old
export class DraftBubbleTeaOrder extends CoMap {
name = co.optional.string;
validate() { // old
const errors: string[] = []; // old
validate() {
const errors: string[] = [];
if (!this.name) { // old
errors.push("Plese enter a name."); // old
} // old
if (!this.name) {
errors.push("Plese enter a name.");
}
return { errors }; // old
} // old
return { errors };
}
get hasChanges() {
get hasChanges() { // [!code ++:3]
return Object.keys(this._edits).length;
}
} // old
}
```
</CodeGroup>

View File

@@ -46,22 +46,22 @@ Collaborative Values (CoValues), build a UI and subscribe to changes, set permis
<CodeGroup>
{/* prettier-ignore */}
```tsx
import React from "react"; // old
import ReactDOM from "react-dom/client"; // old
import App from "./App.tsx"; // old
import "./index.css"; // old
import { JazzProvider } from "jazz-react"; // old
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { JazzProvider } from "jazz-react"; // [!code ++]
ReactDOM.createRoot(document.getElementById("root")!).render( // old
<React.StrictMode> // old
<JazzProvider
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<JazzProvider // [!code ++:6]
// replace `you@example.com` with your email as a temporary API key
sync={{ peer: "wss://cloud.jazz.tools/?key=you@example.com" }}
>
<App />
</JazzProvider>
</React.StrictMode>// old
); // old
</React.StrictMode>
);
```
</CodeGroup>
@@ -143,18 +143,18 @@ Let's modify `src/App.tsx` to prepare for creating an Issue and then rendering i
import { useState } from "react";
import { Issue } from "./schema";
import { IssueComponent } from "./components/Issue.tsx";
// old
function App() {// old
function App() {
const [issue, setIssue] = useState<Issue>();
// old
if (issue) {
return <IssueComponent issue={issue} />;
} else {
return <button>Create Issue</button>;
}
} // old
// old
export default App; // old
}
export default App;
```
</CodeGroup>
@@ -163,14 +163,14 @@ Now, finally, let's implement creating an issue:
<CodeGroup>
{/* prettier-ignore */}
```tsx
import { useState } from "react"; // old
import { Issue } from "./schema"; // old
import { IssueComponent } from "./components/Issue.tsx"; // old
// old
function App() {// old
const [issue, setIssue] = useState<Issue>(); // old
// old
const createIssue = () => {
import { useState } from "react";
import { Issue } from "./schema";
import { IssueComponent } from "./components/Issue.tsx";
function App() {
const [issue, setIssue] = useState<Issue>();
const createIssue = () => { // [!code ++:11]
const newIssue = Issue.create(
{
title: "Buy terrarium",
@@ -181,15 +181,15 @@ function App() {// old
);
setIssue(newIssue);
};
// old
if (issue) {// old
return <IssueComponent issue={issue} />; // old
} else { // old
if (issue) {
return <IssueComponent issue={issue} />;
} else {
return <button onClick={createIssue}>Create Issue</button>;
} // old
} // old
// old
export default App; // old
}
}
export default App;
```
</CodeGroup>
@@ -234,37 +234,38 @@ Let's modify `src/App.tsx`:
<CodeGroup>
{/* prettier-ignore */}
```tsx
import { useState } from "react"; // old
import { Issue } from "./schema"; // old
import { IssueComponent } from "./components/Issue.tsx"; // old
import { useCoState } from "jazz-react";
import { ID } from "jazz-tools"
// old
function App() { // old
const [issueID, setIssueID] = useState<ID<Issue>>();
// old
const issue = useCoState(Issue, issueID);
// old
const createIssue = () => {// old
const newIssue = Issue.create(// old
{ // old
title: "Buy terrarium", // old
description: "Make sure it's big enough for 10 snails.", // old
estimate: 5, // old
status: "backlog", // old
}, // old
); // old
import { useState } from "react";
import { Issue } from "./schema";
import { IssueComponent } from "./components/Issue.tsx";
import { useCoState } from "jazz-react"; // [!code ++]
import { ID } from "jazz-tools" // [!code ++]
function App() {
const [issue, setIssue] = useState<Issue>(); // [!code --]
const [issueID, setIssueID] = useState<ID<Issue>>(); // [!code ++]
const issue = useCoState(Issue, issueID); // [!code ++]
const createIssue = () => {
const newIssue = Issue.create(
{
title: "Buy terrarium",
description: "Make sure it's big enough for 10 snails.",
estimate: 5,
status: "backlog",
},
);
setIssueID(newIssue.id);
}; // old
// old
if (issue) { // old
return <IssueComponent issue={issue} />; // old
} else { // old
return <button onClick={createIssue}>Create Issue</button>; // old
} // old
} // old
// old
export default App; // old
};
if (issue) {
return <IssueComponent issue={issue} />;
} else {
return <button onClick={createIssue}>Create Issue</button>;
}
}
export default App;
```
</CodeGroup>
@@ -273,12 +274,12 @@ And now for the exciting part! Let's make `src/components/Issue.tsx` an editing
<CodeGroup>
{/* prettier-ignore */}
```tsx
import { Issue } from "../schema"; // old
// old
export function IssueComponent({ issue }: { issue: Issue }) { // old
return ( // old
<div className="grid grid-cols-6 text-sm border-r border-b [&>*]:p-2 [&>*]:border-l [&>*]:border-t"> // old
<input type="text"
import { Issue } from "../schema";
export function IssueComponent({ issue }: { issue: Issue }) {
return (
<div className="grid grid-cols-6 text-sm border-r border-b [&>*]:p-2 [&>*]:border-l [&>*]:border-t">
<input type="text" // [!code ++:22]
value={issue.title}
onChange={(event) => { issue.title = event.target.value }}/>
<textarea className="col-span-3"
@@ -300,9 +301,9 @@ export function IssueComponent({ issue }: { issue: Issue }) { // old
<option value="in progress">In Progress</option>
<option value="done">Done</option>
</select>
</div> // old
); // old
} // old
</div>
);
}
```
</CodeGroup>
@@ -380,40 +381,40 @@ So let's store the ID in the browser's URL and make sure our useState is in sync
<CodeGroup>
{/* prettier-ignore */}
```tsx
import { useState } from "react"; // old
import { Issue } from "./schema"; // old
import { IssueComponent } from "./components/Issue.tsx"; // old
import { useCoState } from "jazz-react"; // old
import { ID } from "jazz-tools" // old
// old
function App() { // old
const [issueID, setIssueID] = useState<ID<Issue> | undefined>(
import { useState } from "react";
import { Issue } from "./schema";
import { IssueComponent } from "./components/Issue.tsx";
import { useCoState } from "jazz-react";
import { ID } from "jazz-tools"
function App() {
const [issueID, setIssueID] = useState<ID<Issue> | undefined>( // [!code ++:3]
(window.location.search?.replace("?issue=", "") || undefined) as ID<Issue> | undefined,
);
// old
const issue = useCoState(Issue, issueID); // old
// old
const createIssue = () => {// old
const newIssue = Issue.create(// old
{ // old
title: "Buy terrarium", // old
description: "Make sure it's big enough for 10 snails.", // old
estimate: 5, // old
status: "backlog", // old
}, // old
); // old
setIssueID(newIssue.id); // old
window.history.pushState({}, "", `?issue=${newIssue.id}`);
}; // old
// old
if (issue) { // old
return <IssueComponent issue={issue} />; // old
} else { // old
return <button onClick={createIssue}>Create Issue</button>; // old
} // old
} // old
// old
export default App; // old
const issue = useCoState(Issue, issueID);
const createIssue = () => {
const newIssue = Issue.create(
{
title: "Buy terrarium",
description: "Make sure it's big enough for 10 snails.",
estimate: 5,
status: "backlog",
},
);
setIssueID(newIssue.id);
window.history.pushState({}, "", `?issue=${newIssue.id}`); // [!code ++]
};
if (issue) {
return <IssueComponent issue={issue} />;
} else {
return <button onClick={createIssue}>Create Issue</button>;
}
}
export default App;
```
</CodeGroup>
@@ -439,45 +440,45 @@ All we have to do is create a new group to own each new issue and add "everyone"
<CodeGroup>
{/* prettier-ignore */}
```tsx
import { useState } from "react"; // old
import { Issue } from "./schema"; // old
import { IssueComponent } from "./components/Issue.tsx"; // old
import { useAccount, useCoState } from "jazz-react";
import { useState } from "react";
import { Issue } from "./schema";
import { IssueComponent } from "./components/Issue.tsx";
import { useAccount, useCoState } from "jazz-react"; // [!code ++:2]
import { ID, Group } from "jazz-tools"
// old
function App() { // old
const { me } = useAccount();
const [issueID, setIssueID] = useState<ID<Issue> | undefined>(// old
(window.location.search?.replace("?issue=", "") || undefined) as ID<Issue> | undefined,// old
); // old
// old
const issue = useCoState(Issue, issueID); // old
// old
const createIssue = () => { // old
const group = Group.create();
function App() {
const { me } = useAccount(); // [!code ++]
const [issueID, setIssueID] = useState<ID<Issue> | undefined>(
(window.location.search?.replace("?issue=", "") || undefined) as ID<Issue> | undefined,
);
const issue = useCoState(Issue, issueID);
const createIssue = () => {
const group = Group.create(); // [!code ++:2]
group.addMember("everyone", "writer");
// old
const newIssue = Issue.create( // old
{ // old
title: "Buy terrarium", // old
description: "Make sure it's big enough for 10 snails.", // old
estimate: 5, // old
status: "backlog", // old
}, // old
{ owner: group },
); // old
setIssueID(newIssue.id); // old
window.history.pushState({}, "", `?issue=${newIssue.id}`); // old
}; // old
// old
if (issue) { // old
return <IssueComponent issue={issue} />; // old
} else { // old
return <button onClick={createIssue}>Create Issue</button>; // old
} // old
} // old
// old
export default App; // old
const newIssue = Issue.create(
{
title: "Buy terrarium",
description: "Make sure it's big enough for 10 snails.",
estimate: 5,
status: "backlog",
},
{ owner: group }, // [!code ++]
);
setIssueID(newIssue.id);
window.history.pushState({}, "", `?issue=${newIssue.id}`);
};
if (issue) {
return <IssueComponent issue={issue} />;
} else {
return <button onClick={createIssue}>Create Issue</button>;
}
}
export default App;
```
</CodeGroup>
@@ -508,16 +509,16 @@ Add the following to `src/schema.ts`:
<CodeGroup>
```ts
import { CoMap, CoList, co } from "jazz-tools";
// old
export class Issue extends CoMap { // old
title = co.string; // old
description = co.string; // old
estimate = co.number; // old
status? = co.optional.literal("backlog", "in progress", "done"); // old
} // old
// old
export class ListOfIssues extends CoList.Of(co.ref(Issue)) {}
import { CoMap, CoList, co } from "jazz-tools"; // [!code ++]
export class Issue extends CoMap {
title = co.string;
description = co.string;
estimate = co.number;
status? = co.optional.literal("backlog", "in progress", "done");
}
export class ListOfIssues extends CoList.Of(co.ref(Issue)) {} // [!code ++:6]
export class Project extends CoMap {
name = co.string;
@@ -533,19 +534,19 @@ First, we'll change `App.tsx` to create and render `Project`s instead of `Issue`
<CodeGroup>
{/* prettier-ignore */}
```tsx
import { useState } from "react"; // old
import { Project, ListOfIssues } from "./schema";
import { useState } from "react";
import { Project, ListOfIssues } from "./schema"; // [!code ++:3]
import { ProjectComponent } from "./components/Project.tsx";
import { ID, Group } from "jazz-tools"
// old
function App() { // old
const [projectID, setProjectID] = useState<ID<Project> | undefined>(
function App() {
const [projectID, setProjectID] = useState<ID<Project> | undefined>( // [!code ++:3]
(window.location.search?.replace("?project=", "") || undefined) as ID<Project> | undefined
);
// old
const issue = useCoState(Issue, issueID); // [!code --]
// old
const createProject = () => {
const createProject = () => { // [!code ++:14]
const group = Group.create();
group.addMember("everyone", "writer");
@@ -559,15 +560,15 @@ function App() { // old
setProjectID(newProject.id);
window.history.pushState({}, "", `?project=${newProject.id}`);
};
// old
if (projectID) {
if (projectID) { // [!code ++:4]
return <ProjectComponent projectID={projectID} />;
} else {
return <button onClick={createProject}>Create Project</button>;
}
} // old
// old
export default App; // old
}
export default App;
```
</CodeGroup>
@@ -631,37 +632,41 @@ But you can also take more precise control over loading by defining a minimum-de
<CodeGroup>
{/* prettier-ignore */}
```tsx
import { ID } from "jazz-tools";// old
import { Project, Issue } from "../schema"; // old
import { IssueComponent } from "./Issue.tsx"; // old
import { useCoState } from "jazz-react"; // old
// old
export function ProjectComponent({ projectID }: { projectID: ID<Project> }) {// old
const project = useCoState(Project, projectID, { resolve: { issues: { $each: true } } });
import { ID } from "jazz-tools";
import { Project, Issue } from "../schema";
import { IssueComponent } from "./Issue.tsx";
import { useCoState } from "jazz-react";
const createAndAddIssue = () => {// old
export function ProjectComponent({ projectID }: { projectID: ID<Project> }) {
const project = useCoState(
Project,
projectID,
{ resolve: { issues: { $each: true } } } // [!code ++]
);
const createAndAddIssue = () => {
project?.issues.push(Issue.create({
title: "",// old
description: "",// old
estimate: 0,// old
status: "backlog",// old
}, project._owner));// old
};// old
// old
return project ? (// old
<div>// old
<h1>{project.name}</h1>// old
<div className="border-r border-b">// old
title: "",
description: "",
estimate: 0,
status: "backlog",
}, project._owner));
};
return project ? (
<div>
<h1>{project.name}</h1>
<div className="border-r border-b">
{project.issues.map((issue) => (
<IssueComponent key={issue.id} issue={issue} />
))}// old
<button onClick={createAndAddIssue}>Create Issue</button>// old
</div>// old
</div>// old
) : (// old
<div>Loading project...</div>// old
);// old
}// old
))}
<button onClick={createAndAddIssue}>Create Issue</button>
</div>
</div>
) : (
<div>Loading project...</div>
);
}
```
</CodeGroup>
@@ -700,39 +705,39 @@ Turns out, we're already mostly there! First, let's remove making the Project pu
<CodeGroup>
{/* prettier-ignore */}
```tsx
import { useState } from "react"; // old
import { Project, ListOfIssues } from "./schema"; // old
import { ProjectComponent } from "./components/Project.tsx"; // old
import { ID, Group } from "jazz-tools" // old
// old
function App() { // old
const [projectID, setProjectID] = useState<ID<Project> | undefined>( // old
(window.location.search?.replace("?project=", "") || undefined) as ID<Project> | undefined, // old
); // old
// old
const createProject = () => { // old
const group = Group.create(); // old
import { useState } from "react";
import { Project, ListOfIssues } from "./schema";
import { ProjectComponent } from "./components/Project.tsx";
import { ID, Group } from "jazz-tools"
function App() {
const [projectID, setProjectID] = useState<ID<Project> | undefined>(
(window.location.search?.replace("?project=", "") || undefined) as ID<Project> | undefined,
);
const createProject = () => {
const group = Group.create();
group.addMember("everyone", "writer"); // [!code --]
// old
const newProject = Project.create( // old
{ // old
name: "New Project", // old
issues: ListOfIssues.create([], { owner: group }) // old
}, // old
group, // old
); // old
setProjectID(newProject.id); // old
window.history.pushState({}, "", `?project=${newProject.id}`); // old
}; // old
// old
if (projectID) { // old
return <ProjectComponent projectID={projectID} />; // old
} else { // old
return <button onClick={createProject}>Create Project</button>; // old
} // old
} // old
// old
export default App; // old
const newProject = Project.create(
{
name: "New Project",
issues: ListOfIssues.create([], { owner: group })
},
group,
);
setProjectID(newProject.id);
window.history.pushState({}, "", `?project=${newProject.id}`);
};
if (projectID) {
return <ProjectComponent projectID={projectID} />;
} else {
return <button onClick={createProject}>Create Project</button>;
}
}
export default App;
```
</CodeGroup>
@@ -741,52 +746,52 @@ Now, inside ProjectComponent, let's add a button to invite guests (read-only) or
<CodeGroup>
{/* prettier-ignore */}
```tsx
import { ID } from "jazz-tools"; // old
import { Project, Issue } from "../schema"; // old
import { IssueComponent } from "./Issue.tsx"; // old
import { useCoState } from "jazz-react"; // old
import { createInviteLink } from "jazz-react";
// old
import { ID } from "jazz-tools";
import { Project, Issue } from "../schema";
import { IssueComponent } from "./Issue.tsx";
import { useCoState } from "jazz-react";
import { createInviteLink } from "jazz-react"; // [!code ++]
export function ProjectComponent({ projectID }: { projectID: ID<Project> }) {// old
const project = useCoState(Project, projectID, { resolve: { issues: { $each: true } } }); // old
const { me } = useAccount();
export function ProjectComponent({ projectID }: { projectID: ID<Project> }) {
const project = useCoState(Project, projectID, { resolve: { issues: { $each: true } } });
const { me } = useAccount(); // [!code ++:6]
const invite = (role: "reader" | "writer") => {
const link = createInviteLink(project, role, { valueHint: "project" });
navigator.clipboard.writeText(link);
};
const createAndAddIssue = () => {// old
project?.issues.push(Issue.create({ // old
title: "",// old
description: "",// old
estimate: 0,// old
status: "backlog",// old
}, project._owner));// old
};// old
// old
return project ? (// old
<div>// old
<h1>{project.name}</h1>// old
{me.canAdmin(project) && (
const createAndAddIssue = () => {
project?.issues.push(Issue.create({
title: "",
description: "",
estimate: 0,
status: "backlog",
}, project._owner));
};
return project ? (
<div>
<h1>{project.name}</h1>
{me.canAdmin(project) && ( // [!code ++:6]
<>
<button onClick={() => invite("reader")}>Invite Guest</button>
<button onClick={() => invite("writer")}>Invite Member</button>
</>
)}
<div className="border-r border-b">// old
{project.issues.map((issue) => ( // old
<IssueComponent key={issue.id} issue={issue} /> // old
))}// old
<button onClick={createAndAddIssue}>Create Issue</button>// old
</div>// old
</div>// old
) : (// old
<div>Loading project...</div>// old
);// old
}// old
<div className="border-r border-b">
{project.issues.map((issue) => (
<IssueComponent key={issue.id} issue={issue} />
))}
<button onClick={createAndAddIssue}>Create Issue</button>
</div>
</div>
) : (
<div>Loading project...</div>
);
}
```
</CodeGroup>

View File

@@ -31,9 +31,10 @@ Render the component within your `JazzProvider`.
```tsx
import { JazzInspector } from "jazz-inspector";
<JazzProvider> // old
<JazzProvider>
// [!code ++]
<JazzInspector />
</JazzProvider> // old
</JazzProvider>
```
</CodeGroup>

View File

@@ -8,7 +8,7 @@ async function getMdxSource(framework: string, slugPath?: string) {
// Try to import the framework-specific file first
try {
if (!slugPath) {
return await import("./index.mdx")
return await import("./index.mdx");
}
return await import(`./${slugPath}/${framework}.mdx`);
} catch (error) {
@@ -85,7 +85,7 @@ export async function generateStaticParams() {
paths.push({
framework,
slug: [],
})
});
for (const heading of docNavigationItems) {
for (const item of heading?.items) {
if (item.href && item.href.startsWith("/docs")) {

View File

@@ -12,15 +12,16 @@ Wrap your application with `<JazzProvider />`, this is where you specify the syn
import { JazzProvider } from "jazz-react";
import { MyAppAccount } from "./schema";
ReactDOM.createRoot(document.getElementById("root")!).render( // old
<JazzProvider
ReactDOM.createRoot(document.getElementById("root")!).render(
<JazzProvider // [!code ++:6]
sync={{ peer: "wss://cloud.jazz.tools/?key=you@example.com" }}
AccountSchema={MyAppAccount}
>
<App />
</JazzProvider>
);// old
);
// [!code ++:6]
// Register the Account schema so `useAccount` returns our custom `MyAppAccount`
declare module "jazz-react" {
interface Register {
@@ -42,21 +43,21 @@ The easiest way to use Jazz with Next.JS is to only use it on the client side. Y
<CodeGroup>
{/* prettier-ignore */}
```tsx
"use client"
import { JazzProvider } from "jazz-react"; // old
import { MyAppAccount } from "./schema"; // old
```tsx
"use client" // [!code ++]
import { JazzProvider } from "jazz-react";
import { MyAppAccount } from "./schema";
export function MyJazzProvider(props: { children: React.ReactNode }) {
return (
<JazzProvider
sync={{ peer: "wss://cloud.jazz.tools/?key=you@example.com" }}
AccountSchema={MyAppAccount}
>
{props.children}
</JazzProvider>
);
}
export function MyJazzProvider(props: { children: React.ReactNode }) {
return (
<JazzProvider
sync={{ peer: "wss://cloud.jazz.tools/?key=you@example.com" }}
AccountSchema={MyAppAccount}
>
{props.children}
</JazzProvider>
);
}
```
</CodeGroup>

View File

@@ -83,29 +83,29 @@ If you want to extend the `profile` to contain additional fields (such as an ava
<CodeGroup>
{/* prettier-ignore */}
```ts
import { Account, Profile, ImageDefinition } from "jazz-tools";
import { Account, Profile, ImageDefinition } from "jazz-tools"; // [!code ++]
export class MyAppAccount extends Account {
profile = co.ref(MyAppProfile);
root = co.ref(MyAppRoot);// old
profile = co.ref(MyAppProfile); // [!code ++]
root = co.ref(MyAppRoot);
}
export class MyAppRoot extends CoMap {// old
myChats = co.ref(ListOfChats);// old
myContacts = co.ref(ListOfAccounts);// old
}// old
export class MyAppRoot extends CoMap {
myChats = co.ref(ListOfChats);
myContacts = co.ref(ListOfAccounts);
}
export class MyAppProfile extends Profile {
export class MyAppProfile extends Profile { // [!code ++:4]
name = co.string; // compatible with default Profile schema
avatar = co.optional.ref(ImageDefinition);
}
// Register the Account schema so `useAccount` returns our custom `MyAppAccount` // old
declare module "jazz-react" {// old
interface Register {// old
Account: MyAppAccount;// old
}// old
}// old
// Register the Account schema so `useAccount` returns our custom `MyAppAccount`
declare module "jazz-react" {
interface Register {
Account: MyAppAccount;
}
}
```
</CodeGroup>
@@ -203,24 +203,24 @@ Now let's say we want to add a `myBookmarks` field to the `root` schema:
{/* prettier-ignore */}
```ts
export class MyAppAccount extends Account {
root = co.ref(MyAppRoot);// old
root = co.ref(MyAppRoot);
async migrate(this: MyAppAccount) {
if (this.root === undefined) { // old
this.root = MyAppRoot.create({ // old
myChats: ListOfChats.create([], Group.create()), // old
myContacts: ListOfAccounts.create([], Group.create()) // old
}); // old
} // old
if (this.root === undefined) {
this.root = MyAppRoot.create({
myChats: ListOfChats.create([], Group.create()),
myContacts: ListOfAccounts.create([], Group.create())
});
}
// We need to load the root field to check for the myContacts field
const { root } = await this.ensureLoaded({
const { root } = await this.ensureLoaded({ // [!code ++:3]
root: {},
});
// we specifically need to check for undefined,
// because myBookmarks might simply be not loaded (`null`) yet
if (root.myBookmarks === undefined) {
if (root.myBookmarks === undefined) { // [!code ++:3]
root.myBookmarks = ListOfBookmarks.create([], Group.create());
}
}

View File

@@ -23,8 +23,8 @@
pre.shiki {
height: 100%;
padding-top: 1em;
padding-bottom: 1em;
padding: 0.65em 0;
background-color: transparent !important;
}
.twoslash-popup-code pre.shiki {
@@ -51,7 +51,7 @@ pre.shiki .line {
html.dark .shiki {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
background-color: transparent !important;
/* Optional, if you also want font styles */
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
@@ -74,4 +74,9 @@ html.dark .shiki span {
background-color: rgba(0, 255, 0, 0.1) !important;
}
@import "@shikijs/twoslash/style-rich.css";
html.dark {
--twoslash-popup-bg: #1b1a1a;
--twoslash-border-color: #2f2e2e;
}
@import "@shikijs/twoslash/style-rich.css";

View File

@@ -2,16 +2,26 @@
import { useState } from "react";
export function IssueTrackerPreview() {
const [title, setTitle] = useState("Buy terrarium");
const [description, setDescription] = useState(
"Make sure it's big enough for 10 snails.",
);
const [estimate, setEstimate] = useState(5);
const [backlog, setBacklog] = useState("backlog");
return (
<div className="p-3 md:-mx-3 rounded border border-stone-100 bg-white dark:bg-black not-prose">
<div className="grid grid-cols-6 text-sm border-r border-b [&>*]:p-2 [&>*]:border-l [&>*]:border-t">
<input type="text" value={"Buy terrarium"} />
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<input
type="text"
className="col-span-3"
value={"Make sure it's big enough for 10 snails."}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<label className="flex">
Estimate:{" "}

View File

@@ -1,4 +1,5 @@
import { Icon } from "gcmp-design-system/src/app/components/atoms/Icon";
import { Fragment } from "react";
import {
CommentDisplayPart,
DeclarationReflection,
@@ -17,7 +18,6 @@ import {
PropCategory,
PropDecl,
} from "./tags";
import { Fragment } from "react";
export async function PackageDocs({
package: packageName,
@@ -37,17 +37,18 @@ export async function PackageDocs({
return (
<section key={category.title}>
<h2>{category.title}</h2>
{category.children.map((child) => (
// Ability to link external documents has been added. Turning it off for now
// https://typedoc.org/documents/External_Documents.html
child.variant !== "document" && (
<RenderPackageChild
child={child}
key={child.id}
inPackage={packageName}
/>
)
))}
{category.children.map(
(child) =>
// Ability to link external documents has been added. Turning it off for now
// https://typedoc.org/documents/External_Documents.html
child.variant !== "document" && (
<RenderPackageChild
child={child}
key={child.id}
inPackage={packageName}
/>
),
)}
</section>
);
})}
@@ -194,11 +195,16 @@ function RenderClassOrInterface({
),
)}
/>
{category.children.map((prop) => (
prop.variant !== "document" && (
<RenderProp prop={prop} klass={classOrInterface} key={prop.id} />
)
))}
{category.children.map(
(prop) =>
prop.variant !== "document" && (
<RenderProp
prop={prop}
klass={classOrInterface}
key={prop.id}
/>
),
)}
</div>
))}
</ClassOrInterface>

View File

@@ -1,4 +1,9 @@
import { Deserializer, FileRegistry, JSONOutput, ProjectReflection } from "typedoc";
import {
Deserializer,
FileRegistry,
JSONOutput,
ProjectReflection,
} from "typedoc";
import JazzBrowserMediaImagesDocs from "../../typedoc/jazz-browser-media-images.json";
import JazzBrowserDocs from "../../typedoc/jazz-browser.json";
@@ -22,5 +27,5 @@ export async function requestProject(
return deserializer.reviveProject(packageName, docs[packageName], {
projectRoot: "/",
registry: new FileRegistry(),
});
});
}

View File

@@ -2,8 +2,8 @@ import { Icon } from "gcmp-design-system/src/app/components/atoms/Icon";
import Link from "next/link";
import { ReactNode } from "react";
import { createHighlighter } from "shiki";
import { jazzLight } from "../../themes/jazzLight.mjs";
import { jazzDark } from "../../themes/jazzDark.mjs";
import { jazzLight } from "../../themes/jazzLight.mjs";
const highlighterPromise = createHighlighter({
langs: ["typescript", "bash", "tsx", "json", "svelte", "vue"],
@@ -32,17 +32,14 @@ export async function Highlight({
lang?: string;
className?: string;
}) {
const html = (await highlighterPromise).codeToHtml(
children,
{
lang,
structure: "inline",
themes: {
light: "jazz-light",
dark: "jazz-dark",
},
const html = (await highlighterPromise).codeToHtml(children, {
lang,
structure: "inline",
themes: {
light: "jazz-light",
dark: "jazz-dark",
},
);
});
return (
<code className={className} dangerouslySetInnerHTML={{ __html: html }} />

View File

@@ -208,7 +208,7 @@ async function readMdxContent(url) {
if (url === "/docs") {
const introPath = path.join(
process.cwd(),
"components/docs/docs-intro.mdx",
"app/(docs)/docs/[framework]/[[...slug]]/index.mdx",
);
try {
const content = await fs.readFile(introPath, "utf8");
@@ -229,7 +229,7 @@ async function readMdxContent(url) {
// Base directory for docs
const baseDir = path.join(
process.cwd(),
"app/(docs)/docs/[framework]/[...slug]",
"app/(docs)/docs/[framework]/[[...slug]]",
);
// If it's a directory, try to read all framework variants

View File

@@ -1,13 +1,13 @@
import createMDX from "@next/mdx";
import { transformerNotationDiff } from "@shikijs/transformers";
import { transformerTwoslash } from "@shikijs/twoslash";
import withToc from "@stefanprobst/rehype-extract-toc";
import withTocExport from "@stefanprobst/rehype-extract-toc/mdx";
import rehypeSlug from "rehype-slug";
import { createHighlighter } from "shiki";
import { transformerNotationDiff, transformerRemoveLineBreak } from '@shikijs/transformers'
import { transformerTwoslash } from "@shikijs/twoslash";
import { SKIP, visit } from "unist-util-visit";
import { jazzLight } from "./themes/jazzLight.mjs";
import { jazzDark } from "./themes/jazzDark.mjs";
import { jazzLight } from "./themes/jazzLight.mjs";
/** @type {import('next').NextConfig} */
const nextConfig = {
@@ -63,10 +63,13 @@ function highlightPlugin() {
transformerTwoslash({
explicitTrigger: true,
throws: process.env.NODE_ENV === "production",
onTwoslashError: process.env.NODE_ENV !== "production" ? (e) => {
console.error(e);
error = e;
} : undefined,
onTwoslashError:
process.env.NODE_ENV !== "production"
? (e) => {
console.error(e);
error = e;
}
: undefined,
}),
transformerNotationDiff(),
],

3
pnpm-lock.yaml generated
View File

@@ -819,6 +819,9 @@ importers:
vite:
specifier: ^6.0.11
version: 6.0.11(@types/node@22.10.2)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.6.1)
vitest:
specifier: 3.0.5
version: 3.0.5(@types/node@22.10.2)(@vitest/browser@3.0.5)(@vitest/ui@3.0.5)(happy-dom@16.8.1)(jiti@2.4.2)(jsdom@25.0.1)(lightningcss@1.29.1)(msw@2.7.0(@types/node@22.10.2)(typescript@5.6.3))(terser@5.37.0)(yaml@2.6.1)
examples/multiauth:
dependencies: