Compare commits

..

9 Commits

Author SHA1 Message Date
Tom Moor e94d4722e4 rename 2025-05-03 11:27:14 -04:00
Tom Moor e30829e68a fix: Include withDrafts in groupMemberships.list 2025-05-03 10:36:22 -04:00
Tom Moor 0b27487c61 defaultScopeWithUser -> withUserScope 2025-05-03 10:33:27 -04:00
Tom Moor aa5bf19134 Remove withCollectionPermissions scope 2025-05-03 09:41:22 -04:00
Tom Moor c0c36bacbb fix: Error loading collection (#9123) 2025-05-03 02:18:56 +00:00
Tom Moor 7bd1ea7c40 chore/attachments-sw-cache (#9122) 2025-05-02 22:15:39 -04:00
Tom Moor 5ebb1e8a61 feat: Add input rule to create new tables (#9118) 2025-05-02 08:19:57 -04:00
Tom Moor 96d6987858 fix: Mobile toolbar overlaps with home indicator (#9119) 2025-05-02 08:19:48 -04:00
Tom Moor 3602198cd8 fix: Subtle collection loading bug (#9120) 2025-05-02 08:19:41 -04:00
18 changed files with 156 additions and 123 deletions
+1
View File
@@ -321,6 +321,7 @@ const Container = styled(Flex)<ContainerProps>`
z-index: ${depths.mobileSidebar};
max-width: 80%;
min-width: 280px;
padding-left: var(--sal);
${fadeOnDesktopBackgrounded()}
@media print {
@@ -38,10 +38,10 @@ function StarredLink({ star }: Props) {
const { ui, collections, documents } = useStores();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const { documentId, collectionId } = star;
const collection = collections.get(collectionId);
const collection = collectionId ? collections.get(collectionId) : undefined;
const locationSidebarContext = useLocationSidebarContext();
const sidebarContext = starredSidebarContext(
star.documentId ?? star.collectionId
star.documentId ?? star.collectionId ?? ""
);
const [expanded, setExpanded] = useState(
(star.documentId
+6 -1
View File
@@ -7,6 +7,7 @@ import { isCode } from "@shared/editor/lib/isCode";
import { findParentNode } from "@shared/editor/queries/findParentNode";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { depths, s } from "@shared/styles";
import { getSafeAreaInsets } from "@shared/utils/browser";
import { HEADER_HEIGHT } from "~/components/Header";
import { Portal } from "~/components/Portal";
import useEventListener from "~/hooks/useEventListener";
@@ -241,12 +242,16 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
if (props.active) {
const rect = document.body.getBoundingClientRect();
const safeAreaInsets = getSafeAreaInsets();
return (
<ReactPortal>
<MobileWrapper
ref={menuRef}
style={{
bottom: `calc(100% - ${height - rect.y}px)`,
bottom: `calc(100% - ${
height - rect.y - safeAreaInsets.bottom
}px)`,
}}
>
{props.children}
+2 -1
View File
@@ -2,7 +2,8 @@ import Extension from "@shared/editor/lib/Extension";
import { InputRule } from "@shared/editor/lib/InputRule";
const rightArrow = new InputRule(/->$/, "→");
const emdash = new InputRule(/--$/, "—");
// Note that the suppression of pipe here prevents conflict with table creation rule.
const emdash = new InputRule(/(?:^|[^\|])(--)$/, "—");
const oneHalf = new InputRule(/(?:^|\s)(1\/2)$/, "½");
const threeQuarters = new InputRule(/(?:^|\s)(3\/4)$/, "¾");
const copyright = new InputRule(/\(c\)$/, "©️");
+1 -1
View File
@@ -22,7 +22,7 @@ class Star extends Model {
document?: Document;
/** The collection ID that is starred. */
collectionId: string;
collectionId?: string;
/** The collection that is starred. */
@Relation(() => Collection, { onDelete: "cascade" })
+20 -36
View File
@@ -13,7 +13,6 @@ import {
Transaction,
Op,
FindOptions,
ScopeOptions,
WhereOptions,
EmptyResultError,
} from "sequelize";
@@ -106,20 +105,6 @@ type AdditionalFindOptions = {
},
}))
@Scopes(() => ({
withCollectionPermissions: (userId: string, paranoid = true) => ({
include: [
{
attributes: ["id", "permission", "sharing", "teamId", "deletedAt"],
model: userId
? Collection.scope({
method: ["withMembership", userId],
})
: Collection,
as: "collection",
paranoid,
},
],
}),
withoutState: {
attributes: {
exclude: ["state"],
@@ -169,13 +154,23 @@ type AdditionalFindOptions = {
],
};
},
withMembership: (userId: string) => {
withMembership: (userId: string, paranoid = true) => {
if (!userId) {
return {};
}
return {
include: [
{
attributes: ["id", "permission", "sharing", "teamId", "deletedAt"],
model: userId
? Collection.scope({
method: ["withMembership", userId],
})
: Collection,
as: "collection",
paranoid,
},
{
association: "memberships",
where: {
@@ -637,21 +632,15 @@ class Document extends ArchivableModel<
return uniq(membershipUserIds);
}
static defaultScopeWithUser(userId: string) {
const collectionScope: Readonly<ScopeOptions> = {
method: ["withCollectionPermissions", userId],
};
const viewScope: Readonly<ScopeOptions> = {
method: ["withViews", userId],
};
const membershipScope: Readonly<ScopeOptions> = {
method: ["withMembership", userId],
};
static withMembershipScope(userId: string, options?: FindOptions<Document>) {
return this.scope([
"defaultScope",
collectionScope,
viewScope,
membershipScope,
{
method: ["withViews", userId],
},
{
method: ["withMembership", userId, options?.paranoid],
},
]);
}
@@ -685,14 +674,12 @@ class Document extends ArchivableModel<
// almost every endpoint needs the collection membership to determine policy permissions.
const scope = this.scope([
"withDrafts",
{
method: ["withCollectionPermissions", userId, rest.paranoid],
},
options.includeState ? "withState" : "withoutState",
{
method: ["withViews", userId],
},
{
method: ["withMembership", userId],
method: ["withMembership", userId, rest.paranoid],
},
]);
@@ -750,9 +737,6 @@ class Document extends ArchivableModel<
const user = userId ? await User.findByPk(userId) : null;
const documents = await this.scope([
"withDrafts",
{
method: ["withCollectionPermissions", userId, rest.paranoid],
},
{
method: ["withViews", userId],
},
+18 -36
View File
@@ -182,25 +182,16 @@ export default class SearchHelper {
},
];
return Document.scope([
"withDrafts",
{
method: ["withViews", user.id],
},
{
method: ["withCollectionPermissions", user.id],
},
{
method: ["withMembership", user.id],
},
]).findAll({
where,
subQuery: false,
order: [["updatedAt", "DESC"]],
include,
offset,
limit,
});
return Document.withMembershipScope(user.id)
.scope("withDrafts")
.findAll({
where,
subQuery: false,
order: [["updatedAt", "DESC"]],
include,
offset,
limit,
});
}
public static async searchCollectionsForUser(
@@ -273,23 +264,14 @@ export default class SearchHelper {
// Final query to get associated document data
const [documents, count] = await Promise.all([
Document.scope([
"withDrafts",
{
method: ["withViews", user.id],
},
{
method: ["withCollectionPermissions", user.id],
},
{
method: ["withMembership", user.id],
},
]).findAll({
where: {
teamId: user.teamId,
id: map(results, "id"),
},
}),
Document.withMembershipScope(user.id)
.scope("withDrafts")
.findAll({
where: {
teamId: user.teamId,
id: map(results, "id"),
},
}),
results.length < limit && offset === 0
? Promise.resolve(results.length)
: countQuery,
+11 -19
View File
@@ -268,7 +268,7 @@ router.post(
}
const [documents, total] = await Promise.all([
Document.defaultScopeWithUser(user.id).findAll({
Document.withMembershipScope(user.id).findAll({
where,
order: [
[
@@ -348,7 +348,7 @@ router.post(
};
}
const documents = await Document.defaultScopeWithUser(user.id).findAll({
const documents = await Document.withMembershipScope(user.id).findAll({
where,
order: [
[
@@ -397,15 +397,11 @@ router.post(
const membershipScope: Readonly<ScopeOptions> = {
method: ["withMembership", user.id],
};
const collectionScope: Readonly<ScopeOptions> = {
method: ["withCollectionPermissions", user.id],
};
const viewScope: Readonly<ScopeOptions> = {
method: ["withViews", user.id],
};
const documents = await Document.scope([
membershipScope,
collectionScope,
viewScope,
"withDrafts",
]).findAll({
@@ -539,12 +535,14 @@ router.post(
delete where.updatedAt;
}
const documents = await Document.defaultScopeWithUser(user.id).findAll({
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
const documents = await Document.withMembershipScope(user.id)
.scope("withDrafts")
.findAll({
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
const data = await Promise.all(
documents.map((document) => presentDocument(ctx, document))
);
@@ -2033,13 +2031,7 @@ router.post(
const collectionIds = await user.collectionIds({
paranoid: false,
});
const collectionScope: Readonly<ScopeOptions> = {
method: ["withCollectionPermissions", user.id],
};
const documents = await Document.scope([
collectionScope,
"withDrafts",
]).findAll({
const documents = await Document.scope("withDrafts").findAll({
attributes: ["id"],
where: {
deletedAt: {
@@ -24,6 +24,7 @@ router.post(
async (ctx: APIContext<T.GroupMembershipsListReq>) => {
const { groupId } = ctx.input.body;
const { user } = ctx.state.auth;
const userId = user.id;
const memberships = await GroupMembership.findAll({
where: {
@@ -44,7 +45,7 @@ router.post(
association: "groupUsers",
required: true,
where: {
userId: user.id,
userId,
},
},
],
@@ -57,15 +58,13 @@ router.post(
const documentIds = memberships
.map((p) => p.documentId)
.filter(Boolean) as string[];
const documents = await Document.scope([
"withDrafts",
{ method: ["withMembership", user.id] },
{ method: ["withCollectionPermissions", user.id] },
]).findAll({
where: {
id: documentIds,
},
});
const documents = await Document.withMembershipScope(userId)
.scope("withDrafts")
.findAll({
where: {
id: documentIds,
},
});
const groups = uniqBy(
memberships.map((membership) => membership.group),
+1 -1
View File
@@ -113,7 +113,7 @@ router.post(
user.collectionIds(),
]);
const documents = await Document.defaultScopeWithUser(user.id).findAll({
const documents = await Document.withMembershipScope(user.id).findAll({
where: {
id: pins.map((pin) => pin.documentId),
collectionId: collectionIds,
+1 -1
View File
@@ -94,7 +94,7 @@ router.post(
.map((star) => star.documentId)
.filter(Boolean) as string[];
const documents = documentIds.length
? await Document.defaultScopeWithUser(user.id).findAll({
? await Document.withMembershipScope(user.id).findAll({
where: {
id: documentIds,
collectionId: collectionIds,
@@ -1,5 +1,4 @@
import Router from "koa-router";
import { Op, Sequelize } from "sequelize";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
@@ -46,14 +45,8 @@ router.post(
const documentIds = memberships
.map((p) => p.documentId)
.filter(Boolean) as string[];
const documents = await Document.scope([
"withDrafts",
{ method: ["withMembership", user.id] },
{ method: ["withCollectionPermissions", user.id] },
]).findAll({
where: {
id: documentIds,
},
const documents = await Document.findByIds(documentIds, {
userId: user.id,
});
const policies = presentPolicies(user, [...documents, ...memberships]);
+1 -4
View File
@@ -99,10 +99,7 @@ export const getDocumentPermission = async ({
documentId: string;
skipMembershipId?: string;
}): Promise<DocumentPermission | undefined> => {
const document = await Document.scope({
method: ["withCollectionPermissions", userId],
}).findOne({ where: { id: documentId } });
const document = await Document.findByPk(documentId, { userId });
const permissions: DocumentPermission[] = [];
const collection = document?.collection;
+2 -2
View File
@@ -44,11 +44,11 @@ export function createTable({
};
}
function createTableInner(
export function createTableInner(
state: EditorState,
rowsCount: number,
colsCount: number,
colWidth: number,
colWidth?: number,
withHeaderRow = true,
cellContent?: Node
) {
+15
View File
@@ -1,5 +1,7 @@
import { chainCommands } from "prosemirror-commands";
import { InputRule } from "prosemirror-inputrules";
import { NodeSpec, Node as ProsemirrorNode } from "prosemirror-model";
import { TextSelection } from "prosemirror-state";
import {
addColumnAfter,
addRowAfter,
@@ -23,6 +25,7 @@ import {
deleteColSelection,
deleteRowSelection,
moveOutOfTable,
createTableInner,
} from "../commands/table";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import { FixTablesPlugin } from "../plugins/FixTables";
@@ -101,6 +104,18 @@ export default class Table extends Node {
};
}
inputRules() {
return [
new InputRule(/^(\|--)$/, (state, _, start, end) => {
const nodes = createTableInner(state, 2, 2);
const tr = state.tr.replaceWith(start - 1, end, nodes).scrollIntoView();
const resolvedPos = tr.doc.resolve(start + 1);
tr.setSelection(TextSelection.near(resolvedPos));
return tr;
}),
];
}
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
state.renderTable(node);
state.closeBlock(node);
+7
View File
@@ -113,4 +113,11 @@ export default createGlobalStyle<Props>`
outline-offset: -1px;
outline-width: initial;
}
:root {
--sat: env(safe-area-inset-top);
--sar: env(safe-area-inset-right);
--sab: env(safe-area-inset-bottom);
--sal: env(safe-area-inset-left);
}
`;
+26
View File
@@ -13,6 +13,32 @@ export function isTouchDevice(): boolean {
return window.matchMedia?.("(hover: none) and (pointer: coarse)")?.matches;
}
/**
* Returns the safe area insets for the current device.
*/
export function getSafeAreaInsets(): {
top: number;
right: number;
bottom: number;
left: number;
} {
// Check if CSS environment variables are supported
const style = getComputedStyle(document.documentElement);
const supportsEnv = window.CSS?.supports?.("top", "env(safe-area-inset-top)");
if (supportsEnv) {
return {
top: parseFloat(style.getPropertyValue("--sat") || "0"),
right: parseFloat(style.getPropertyValue("--sar") || "0"),
bottom: parseFloat(style.getPropertyValue("--sab") || "0"),
left: parseFloat(style.getPropertyValue("--sal") || "0"),
};
}
// Fallback to zero if not supported
return { top: 0, right: 0, bottom: 0, left: 0 };
}
/**
* Returns true if the client is running on a Mac.
*/
+31
View File
@@ -94,6 +94,9 @@ export default () =>
modifyURLPrefix: {
"": `${environment.CDN_URL ?? ""}/static/`,
},
skipWaiting: true,
clientsClaim: true,
cleanupOutdatedCaches: true,
runtimeCaching: [
{
urlPattern: /api\/urls\.unfurl$/,
@@ -109,6 +112,34 @@ export default () =>
},
},
},
{
urlPattern: /api\/attachments\.redirect/,
handler: "CacheFirst",
options: {
cacheName: "attachments-redirect-cache",
expiration: {
maxEntries: 100,
maxAgeSeconds: 120, // 120 seconds
},
cacheableResponse: {
statuses: [0, 200, 302], // Include redirects
},
},
},
{
urlPattern: /api\/files\.get/,
handler: "CacheFirst",
options: {
cacheName: "files-cache",
expiration: {
maxEntries: 50,
maxAgeSeconds: 604800, // 7 days
},
cacheableResponse: {
statuses: [0, 200, 206], // Include partial content for range requests
},
},
},
],
},
manifest: {