Skip to content

Commit 4cdcf2b

Browse files
committed
feat: enhance Excalidraw element handling and add PreparedElement type; implement invite acceptance action
1 parent 6353ba6 commit 4cdcf2b

4 files changed

Lines changed: 138 additions & 44 deletions

File tree

app/components/board/Excalidraw.tsx

Lines changed: 59 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { AppState, BinaryFileData, Collaborator, DataURL, ExcalidrawInitialDataS
44
import { isInitializedImageElement, throttleRAF, measureText, getFontString, isTextElement } from '~/other/excalidraw';
55
import { ClientData, ClientToServerEvents, SceneBroadcastData, ServerToClientEvents, StatsData } from '~/other/types';
66
import { CollabUser, BoardsManager } from '@excali-boards/boards-api-client';
7-
import { BoardExcalidrawState, BoardProps } from './types';
7+
import { BoardExcalidrawState, BoardProps, PreparedElement } from './types';
88
import { Component, ContextType, Suspense } from 'react';
99
import { PresenceContext } from '~/components/Context';
1010
import { Box, Flex, Spinner } from '@chakra-ui/react';
@@ -753,17 +753,16 @@ export class ExcalidrawBoard extends Component<BoardProps, BoardExcalidrawState>
753753
if (selectedElementIds.length === 0) return;
754754

755755
const gapPx = 15;
756-
756+
const rowTolerance = 5;
757757
const shouldSnap: boolean = !!appState.gridModeEnabled;
758758
const snapUnit: number = appState.gridSize ?? appState.gridStep ?? 10;
759759
const snap = (v: number): number => (shouldSnap ? Math.round(v / snapUnit) * snapUnit : v);
760760

761761
const elements = api.getSceneElementsIncludingDeleted();
762-
763762
const isSelected = (id: string): boolean => selectedElementIds.includes(id);
764763
const isNonDeletedSelectedText = (el: OrderedExcalidrawElement): el is Ordered<ExcalidrawTextElement> => isSelected(el.id) && !el.isDeleted && isTextElement(el);
765764

766-
const prepared = elements.filter(isNonDeletedSelectedText).map((el) => {
765+
const prepared: PreparedElement[] = elements.filter(isNonDeletedSelectedText).map((el) => {
767766
const normalizedText: string = (el.originalText ?? el.text ?? '').replace(/\s*\n\s*/g, ' ').replace(/\s+/g, ' ').trim();
768767
const metrics = measureText(normalizedText, getFontString(el), el.lineHeight);
769768

@@ -772,31 +771,65 @@ export class ExcalidrawBoard extends Component<BoardProps, BoardExcalidrawState>
772771
x: el.x,
773772
y: el.y,
774773
height: metrics.height,
774+
width: metrics.width,
775775
patch: {
776-
autoResize: true,
776+
autoResize: true as const,
777777
text: normalizedText,
778778
originalText: normalizedText,
779779
width: metrics.width,
780780
height: metrics.height,
781781
},
782782
};
783-
}).sort((a, b) => (a.y - b.y) || (a.x - b.x));
784-
783+
}).sort((a, b) => a.y - b.y || a.x - b.x);
785784
if (prepared.length === 0) return;
786785

787-
const minX = Math.min(...prepared.map((p) => p.x));
788-
const minY = Math.min(...prepared.map((p) => p.y));
789-
const targetX = snap(minX);
790-
const startY = snap(minY);
786+
const rows: PreparedElement[][] = [];
787+
for (let i = 0; i < prepared.length; i++) {
788+
const current: PreparedElement = prepared[i]!;
789+
if (rows.length === 0) rows.push([current]);
790+
else {
791+
const lastRow: PreparedElement[] = rows[rows.length - 1]!;
792+
const firstInRow: PreparedElement = lastRow[0]!;
793+
const lastRowY: number = firstInRow.y;
794+
795+
if (Math.abs(current.y - lastRowY) <= rowTolerance) lastRow.push(current);
796+
else rows.push([current]);
797+
}
798+
}
799+
800+
const minX: number = Math.min(...prepared.map((p) => p.x));
801+
const minY: number = Math.min(...prepared.map((p) => p.y));
802+
const targetX: number = snap(minX);
803+
let currentY: number = snap(minY);
804+
805+
const updatesById = new Map<string, Partial<ExcalidrawTextElement>>();
791806

792-
const maxHeight = Math.max(...prepared.map((p) => p.height));
793-
const step = snap(maxHeight + gapPx);
807+
for (let i = 0; i < rows.length; i++) {
808+
const row: PreparedElement[] = rows[i]!;
809+
const rowMaxHeight: number = Math.max(...row.map((p) => p.height));
794810

795-
const updatesById = new Map(prepared.map((p, i) => [p.id, {
796-
...p.patch,
797-
x: targetX,
798-
y: startY + i * step,
799-
}]));
811+
if (row.length === 1) {
812+
const single: PreparedElement = row[0]!;
813+
updatesById.set(single.id, {
814+
...single.patch,
815+
x: targetX,
816+
y: currentY,
817+
});
818+
} else {
819+
let currentX: number = targetX;
820+
for (let j = 0; j < row.length; j++) {
821+
const item: PreparedElement = row[j]!;
822+
updatesById.set(item.id, {
823+
...item.patch,
824+
x: currentX,
825+
y: currentY,
826+
});
827+
currentX = snap(currentX + item.width + gapPx);
828+
}
829+
}
830+
831+
currentY = snap(currentY + rowMaxHeight + gapPx);
832+
}
800833

801834
api.updateScene({
802835
elements: elements.map((el) => {
@@ -805,8 +838,15 @@ export class ExcalidrawBoard extends Component<BoardProps, BoardExcalidrawState>
805838
}),
806839
});
807840

841+
const rowCount: number = rows.length;
842+
const multiRowCount: number = rows.filter((r) => r.length > 1).length;
843+
const message: string =
844+
multiRowCount > 0
845+
? `Tidied ${prepared.length} elements (${rowCount} rows, ${multiRowCount} horizontal).`
846+
: `Tidied ${prepared.length} text element${prepared.length > 1 ? 's' : ''}.`;
847+
808848
api.setToast({
809-
message: `Tidied ${prepared.length} text element${prepared.length > 1 ? 's' : ''}.`,
849+
message,
810850
closable: true,
811851
duration: 1000,
812852
});

app/components/board/types.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,17 @@ export type BoardExcalidrawState = {
4141
socketIO: Socket<ServerToClientEvents, ClientToServerEvents> | null;
4242
} & DefaultBoardState;
4343

44-
45-
44+
export type PreparedElement = {
45+
id: string;
46+
x: number;
47+
y: number;
48+
height: number;
49+
width: number;
50+
patch: {
51+
autoResize: true;
52+
text: string;
53+
originalText: string;
54+
width: number;
55+
height: number;
56+
};
57+
};

app/routes/invites.$code._index.tsx

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Button, Container, Text, VStack, Badge, Divider, HStack, useToast, Icon, Spinner, Flex, Avatar } from '@chakra-ui/react';
2-
import { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction, redirect } from '@remix-run/node';
2+
import { LoaderFunctionArgs, MetaFunction, redirect } from '@remix-run/node';
33
import { getIpHeaders, makeResObject, makeResponse } from '~/utils/functions.server';
4-
import { Form, useActionData, useLoaderData, useNavigation } from '@remix-run/react';
4+
import { useFetcher, useLoaderData } from '@remix-run/react';
55
import { UseInviteOutput } from '@excali-boards/boards-api-client';
66
import { ConfettiContainer } from '~/components/other/Confetti';
77
import { themeColor, WebReturnType } from '~/other/types';
@@ -54,42 +54,55 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
5454
if (!ipHeaders) throw makeResponse(null, 'Failed to get client IP.');
5555

5656
const DBInviteDetails = await api?.invites.getInviteDetails({ auth: token, code, headers: ipHeaders });
57-
if (!DBInviteDetails || 'error' in DBInviteDetails) throw makeResponse(DBInviteDetails, 'Failed to fetch invite details.');
57+
if (!DBInviteDetails || 'error' in DBInviteDetails) return makeResObject(DBInviteDetails, 'Failed to fetch invite details.');
5858

5959
return DBInviteDetails.data;
6060
};
6161

62-
export const action = async ({ request, params }: ActionFunctionArgs) => {
63-
const { code } = validateParams(params, ['code']);
64-
65-
const token = await authenticator.isAuthenticated(request);
66-
if (!token) return redirect(`/login?backTo=${encodeURIComponent(request.url)}`);
67-
68-
const ipHeaders = getIpHeaders(request);
69-
if (!ipHeaders) return makeResObject(null, 'Failed to get client IP.');
70-
71-
const result = await api?.invites.useInvite({ auth: token, code, headers: ipHeaders });
72-
if (!result || 'error' in result) return makeResObject(result, 'Failed to accept invite.');
73-
74-
return makeResObject(result, 'Successfully accepted invite.');
75-
};
76-
7762
export default function AcceptInvite() {
7863
const invite = useLoaderData<typeof loader>();
7964
const { user } = useContext(RootContext) || {};
8065

81-
const actionData = useActionData<WebReturnType<UseInviteOutput>>();
82-
const navigation = useNavigation();
66+
const fetcher = useFetcher<WebReturnType<UseInviteOutput>>();
67+
const actionData = fetcher.data;
8368
const toast = useToast();
8469

8570
const [showConfetti, setShowConfetti] = useState(false);
8671

87-
const isSubmitting = navigation.state === 'submitting';
72+
const isSubmitting = fetcher.state === 'submitting';
8873

8974
useEffect(() => {
9075
if (actionData && 'data' in actionData) setShowConfetti(true);
9176
}, [actionData, toast]);
9277

78+
if (invite && 'error' in invite) {
79+
return (
80+
<Container maxW='lg' mt={{ base: 16, md: 32 }}>
81+
<Flex
82+
direction='column'
83+
align='center'
84+
p={8}
85+
rounded='lg'
86+
bg='alpha100'
87+
transition='all 0.3s ease'
88+
w='full'
89+
gap={6}
90+
>
91+
<Icon as={FaGift} boxSize={14} color='red.300' />
92+
<Text fontSize='2xl' fontWeight='bold'>
93+
Invalid Invite
94+
</Text>
95+
96+
<Divider />
97+
98+
<Text fontSize='md' textAlign='center'>
99+
{invite.error}
100+
</Text>
101+
</Flex>
102+
</Container>
103+
);
104+
}
105+
93106
const expiresAt = new Date(invite.expiresAt);
94107
const isExpired = expiresAt < new Date();
95108

@@ -208,7 +221,7 @@ export default function AcceptInvite() {
208221

209222
<Divider />
210223

211-
<Form method='post' style={{ width: '100%' }}>
224+
<fetcher.Form method='post' action={`/invites/${invite.code}/accept`} style={{ width: '100%' }}>
212225
<Button
213226
type='submit'
214227
size='lg'
@@ -220,7 +233,7 @@ export default function AcceptInvite() {
220233
>
221234
{isExpired ? 'Invite Expired' : isAtMaxUses ? 'Invite Maxed Out' : isInviter ? 'Owned Invite' : 'Accept Invite'}
222235
</Button>
223-
</Form>
236+
</fetcher.Form>
224237
</Flex>
225238
</Container>
226239
);
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { ActionFunctionArgs, json, redirect } from '@remix-run/node';
2+
import { authenticator } from '~/utils/auth.server';
3+
import { getIpHeaders, makeResObject } from '~/utils/functions.server';
4+
import { validateParams } from '~/other/utils';
5+
import { api } from '~/utils/web.server';
6+
7+
export const action = async ({ request, params }: ActionFunctionArgs) => {
8+
const { code } = validateParams(params, ['code']);
9+
10+
const token = await authenticator.isAuthenticated(request);
11+
if (!token) {
12+
const inviteUrl = new URL(request.url);
13+
inviteUrl.pathname = inviteUrl.pathname.replace(/\/accept$/, '');
14+
inviteUrl.search = '';
15+
return redirect(`/login?backTo=${encodeURIComponent(inviteUrl.toString())}`);
16+
}
17+
18+
const ipHeaders = getIpHeaders(request);
19+
if (!ipHeaders) return json(makeResObject(null, 'Failed to get client IP.'));
20+
21+
const result = await api?.invites.useInvite({ auth: token, code, headers: ipHeaders });
22+
if (!result || 'error' in result) return json(makeResObject(result, 'Failed to accept invite.'));
23+
24+
return json(makeResObject(result, 'Successfully accepted invite.'));
25+
};
26+
27+
export default function AcceptInviteAction() {
28+
return null;
29+
}

0 commit comments

Comments
 (0)