Skip to content
Open
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
4 changes: 2 additions & 2 deletions apps/roam/src/components/Export.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
Radio,
FormGroup,
} from "@blueprintjs/core";
import { baseShapeUtils } from "~/components/canvas/baseShapeUtils";
import React, { useState, useEffect, useMemo, FormEvent } from "react";
import MenuItemSelect from "roamjs-components/components/MenuItemSelect";
import { saveAs } from "file-saver";
Expand Down Expand Up @@ -50,7 +51,6 @@ import {
TLParentId,
getIndexAbove,
TLShape,
defaultShapeUtils,
defaultBindingUtils,
} from "tldraw";
import {
Expand Down Expand Up @@ -433,7 +433,7 @@ const ExportDialog: ExportDialogComponent = ({

const tlStore = createTLStore({
migrations,
shapeUtils: [...defaultShapeUtils, ...customShapeUtils],
shapeUtils: [...baseShapeUtils, ...customShapeUtils],
bindingUtils: [...defaultBindingUtils, ...customBindingUtils],
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { createMigrationIds } from "tldraw";
import { RelationBinding } from "./DiscourseRelationBindings";
import { getRelationColor } from "./DiscourseRelationUtil";
import { DISCOURSE_NODE_SHAPE_TYPE } from "~/components/canvas/DiscourseNodeUtil";
import { backfillTextShapeUrl, isTextShapeRecord } from "~/utils/textShapeLink";

const SEQUENCE_ID_BASE = "com.roam-research.discourse-graphs";

Expand Down Expand Up @@ -52,6 +53,7 @@ export const createMigrations = ({
AddSizeAndFontFamily: 3,
RemoveNullAssetFileSize: 4,
MigrateNodeTypeToDiscourseNode: 5,
AddTextShapeUrl: 6,
});
return createMigrationSequence({
sequenceId: `${SEQUENCE_ID_BASE}`,
Expand Down Expand Up @@ -203,6 +205,17 @@ export const createMigrations = ({
shape.type = DISCOURSE_NODE_SHAPE_TYPE;
},
},
{
id: versions["AddTextShapeUrl"],
scope: "record",
filter: (r: any) => isTextShapeRecord(r),
up: (shape: any) => {
backfillTextShapeUrl(shape);
},
down: (shape: any) => {
delete shape.props.url;
},
},
],
});
};
79 changes: 79 additions & 0 deletions apps/roam/src/components/canvas/TextShapeWithLinkUtil.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import React from "react";
import {
T,
TextShapeUtil,
TLTextShape,
stopEventPropagation,
textShapeProps,
} from "tldraw";

export type TextShapeWithLinkProps = TLTextShape["props"] & { url: string };

const LINK_ICON =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' fill='none'%3E%3Cpath stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M13 5H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6M19 5h6m0 0v6m0-6L13 17'/%3E%3C/svg%3E";

// tldraw does not export HyperlinkButton, so this mirrors its markup to keep
// text links visually identical to geo links.
const HyperlinkButton = ({
url,
zoomLevel,
}: {
url: string;
zoomLevel: number;
}): JSX.Element => (
<a
className={[
"tl-hyperlink-button",
"dg-text-link-button",
zoomLevel < 0.32 ? "tl-hyperlink-button__hidden" : "",
]
.filter(Boolean)
.join(" ")}
href={url}
target="_blank"
rel="noopener noreferrer"
onPointerDown={stopEventPropagation}
onPointerUp={stopEventPropagation}
title={url}
draggable={false}
>
<div
className="tl-hyperlink__icon"
style={{
mask: `url("${LINK_ICON}") center 100% / 100% no-repeat`,
WebkitMask: `url("${LINK_ICON}") center 100% / 100% no-repeat`,
}}
/>
</a>
);

export const getTextShapeUrl = (shape: TLTextShape): string =>
(shape.props as Partial<TextShapeWithLinkProps>).url ?? "";

const textShapeWithLinkProps = { ...textShapeProps, url: T.linkUrl };

// Adds the `url` prop that tldraw's built-in Edit link action gates on
// (`'url' in shape.props`), so text shapes reuse the geo link UI unchanged.
export class TextShapeWithLinkUtil extends TextShapeUtil {
static override props = textShapeWithLinkProps;

override getDefaultProps(): TextShapeWithLinkProps {
return { ...super.getDefaultProps(), url: "" };
}

// The cast bridges two React type copies in the dependency tree, which make
// the base signature's JSX.Element nominally distinct from ours.
override component(
shape: TLTextShape,
): ReturnType<TextShapeUtil["component"]> {
const url = getTextShapeUrl(shape);
return (
<>
{super.component(shape)}
{url && (
<HyperlinkButton url={url} zoomLevel={this.editor.getZoomLevel()} />
)}
</>
) as ReturnType<TextShapeUtil["component"]>;
}
}
4 changes: 2 additions & 2 deletions apps/roam/src/components/canvas/Tldraw.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import React, {
useEffect,
useCallback,
} from "react";
import { baseShapeUtils } from "./baseShapeUtils";
import { Icon } from "@blueprintjs/core";
import ExtensionApiContextProvider, {
useExtensionAPI,
Expand All @@ -24,7 +25,6 @@ import {
TldrawUi,
defaultBindingUtils,
defaultShapeTools,
defaultShapeUtils,
defaultTools,
useEditor,
VecModel,
Expand Down Expand Up @@ -1339,7 +1339,7 @@ const TldrawCanvasShared = ({
// instanceId={initialState.instanceId}
autoFocus={false}
initialState="select"
shapeUtils={[...defaultShapeUtils, ...customShapeUtils]}
shapeUtils={[...baseShapeUtils, ...customShapeUtils]}
tools={[...defaultTools, ...defaultShapeTools, ...customTools]}
bindingUtils={[...defaultBindingUtils, ...customBindingUtils]}
components={editorComponents}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { useSync } from "@tldraw/sync";
import { baseShapeUtils } from "./baseShapeUtils";
import {
TLAnyBindingUtilConstructor,
TLAnyShapeUtilConstructor,
TLAssetStore,
TLStoreWithStatus,
defaultBindingUtils,
defaultShapeUtils,
MigrationSequence,
} from "tldraw";
import { useMemo } from "react";
Expand Down Expand Up @@ -68,7 +68,7 @@ export const useCloudflareSyncStore = ({
}): CloudflareCanvasStoreAdapterResult => {
const assets = useMemo(() => createRoamAssetStore(), []);
const shapeUtils = useMemo(
() => [...defaultShapeUtils, ...customShapeUtils],
() => [...baseShapeUtils, ...customShapeUtils],
[customShapeUtils],
);
const bindingUtils = useMemo(
Expand Down
19 changes: 19 additions & 0 deletions apps/roam/src/components/canvas/baseShapeUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {
defaultShapeUtils,
TextShapeUtil,
TLAnyShapeUtilConstructor,
} from "tldraw";
import { TextShapeWithLinkUtil } from "./TextShapeWithLinkUtil";

// tldraw throws when a shape type is registered twice, so the stock text util
// has to be replaced rather than appended. Every store must use this same list.
export const baseShapeUtils: TLAnyShapeUtilConstructor[] =
defaultShapeUtils.map((util) =>
util === TextShapeUtil ? TextShapeWithLinkUtil : util,
);

// Fail loudly rather than shipping a store whose schema lacks `url` while the
// UI still writes it, which would only surface as validation errors on save.
if (!baseShapeUtils.includes(TextShapeWithLinkUtil)) {
throw new Error("Failed to replace TextShapeUtil in the default shape utils");
}
8 changes: 8 additions & 0 deletions apps/roam/src/components/canvas/tldrawStyles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ export default /* css */ `
display: none;
}

/* A text shape's bounds hug its glyphs, so tldraw's in-bounds link button
lands on the last word; sit it just outside the right edge instead. */
.dg-text-link-button {
top: 50%;
right: 0;
transform: translate(100%, -50%);
}

/* Shape Render Fix */
svg.tl-svg-container {
overflow: visible;
Expand Down
4 changes: 2 additions & 2 deletions apps/roam/src/components/canvas/useRoamStore.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { TLRecord } from "@tldraw/tlschema";
import { baseShapeUtils } from "./baseShapeUtils";
import nanoid from "nanoid";
import { useRef, useMemo, useEffect, useState } from "react";
import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid";
Expand All @@ -14,7 +15,6 @@ import {
import { SerializedStore, StoreSnapshot } from "@tldraw/store";
import {
defaultBindingUtils,
defaultShapeUtils,
getIndices,
loadSnapshot,
MigrationSequence,
Expand Down Expand Up @@ -91,7 +91,7 @@ const createCanvasStore = ({
}): TLStore =>
createTLStore({
migrations,
shapeUtils: [...defaultShapeUtils, ...customShapeUtils],
shapeUtils: [...baseShapeUtils, ...customShapeUtils],
bindingUtils: [...defaultBindingUtils, ...customBindingUtils],
});

Expand Down
69 changes: 69 additions & 0 deletions apps/roam/src/utils/__tests__/syncWorkerRoomSchema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { createTLSchema, defaultShapeSchemas } from "tldraw";
import { baseShapeUtils } from "~/components/canvas/baseShapeUtils";

// Mirrors apps/tldraw-sync-worker/worker/TldrawDurableObject.ts. The worker
// blanks declared shape types to {}, which is safe only for types the client
// also registers without migrations. Blanking a default type drops its
// migration sequence, so the room reports version 0 against the client's 2 and
// every client is rejected as too old.
const textUtil = baseShapeUtils.find(
(util) => util.type === "text",
) as unknown as {
props: never;
migrations: never;
};

const clientSchema = createTLSchema({
shapes: {
...defaultShapeSchemas,
text: { props: textUtil.props, migrations: textUtil.migrations },
"discourse-node": {},
},
});

const textSequenceVersion = (schema: ReturnType<typeof createTLSchema>) =>
(schema.serialize() as unknown as { sequences: Record<string, number> })
.sequences["com.tldraw.shape.text"];

describe("sync worker room schema", () => {
it("stays migration-compatible when text keeps its own migrations", () => {
const workerSchema = createTLSchema({
shapes: {
...defaultShapeSchemas,
text: {
...defaultShapeSchemas.text,
props: {
...defaultShapeSchemas.text.props,
url: defaultShapeSchemas.geo.props.url,
},
},
"discourse-node": {},
},
});

expect(textSequenceVersion(workerSchema)).toBe(
textSequenceVersion(clientSchema),
);
expect(
workerSchema.migrateStoreSnapshot({
store: {} as never,
schema: clientSchema.serialize(),
}).type,
).toBe("success");
});

it("breaks if text is blanked to {} the way custom types are", () => {
const brokenSchema = createTLSchema({
shapes: { ...defaultShapeSchemas, text: {} },
});

expect(textSequenceVersion(brokenSchema)).toBe(0);
expect(
brokenSchema.migrateStoreSnapshot({
store: {} as never,
schema: clientSchema.serialize(),
}).type,
).toBe("error");
});
});
Loading
Loading