Compare commits

..

1 Commits

Author SHA1 Message Date
Tom Moor 14094f6aa7 chore: Change lint rule to trigger on actor rather than branch name 2025-03-27 22:20:11 -04:00
40 changed files with 103 additions and 418 deletions
+1 -5
View File
@@ -2,11 +2,6 @@ import React from "react";
import styled from "styled-components";
import { fadeIn } from "~/styles/animations";
/**
* Fade in animation for a component.
*
* @param timing - The duration of the fade in animation, default is 250ms.
*/
const Fade = styled.span<{ timing?: number | string }>`
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
`;
@@ -22,6 +17,7 @@ type Props = {
*/
export const ConditionalFade = ({ animate, children }: Props) => {
const [isAnimated] = React.useState(animate);
return isAnimated ? <Fade>{children}</Fade> : <>{children}</>;
};
+3 -2
View File
@@ -645,11 +645,12 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
"section" in item ? item.section?.({ t }) : undefined;
const response = (
<React.Fragment key={`${index}-${item.name}`}>
<>
{currentHeading !== previousHeading && (
<Header key={currentHeading}>{currentHeading}</Header>
)}
<ListItem
key={index}
onPointerMove={handlePointerMove}
onPointerDown={handlePointerDown}
>
@@ -658,7 +659,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
onClick: () => handleClickItem(item),
})}
</ListItem>
</React.Fragment>
</>
);
previousHeading = currentHeading;
+1 -1
View File
@@ -88,7 +88,7 @@ export default class PasteHandler extends Extension {
// If the users selection is currently in a code block then paste
// as plain text, ignore all formatting and HTML content.
if (isInCode(state, { inclusive: true })) {
if (isInCode(state)) {
event.preventDefault();
view.dispatch(state.tr.insertText(text));
return true;
-5
View File
@@ -1,11 +1,6 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
/**
* Hook that provides a dictionary of translated UI strings.
*
* @returns An object containing all translated UI strings used throughout the application
*/
export default function useDictionary() {
const { t } = useTranslation();
-9
View File
@@ -1,15 +1,6 @@
import * as React from "react";
import useWindowSize from "./useWindowSize";
/**
* Hook to calculate the maximum height for an element based on its position and viewport size.
*
* @param options Configuration options
* @param options.elementRef A ref pointing to the element to calculate max height for
* @param options.maxViewportPercentage The maximum height of the element as a percentage of the viewport
* @param options.margin The margin to apply to the positioning
* @returns Object containing the calculated maxHeight and a function to recalculate it
*/
const useMaxHeight = ({
elementRef,
maxViewportPercentage = 90,
-6
View File
@@ -1,11 +1,5 @@
import { useState, useEffect } from "react";
/**
* Hook to check if a media query matches the current viewport.
*
* @param query The CSS media query to check against
* @returns boolean indicating whether the media query matches
*/
export default function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(false);
-5
View File
@@ -1,11 +1,6 @@
import { breakpoints } from "@shared/styles";
import useMediaQuery from "~/hooks/useMediaQuery";
/**
* Hook to detect if the current viewport is mobile-sized.
*
* @returns boolean indicating whether the current viewport is mobile-sized
*/
export default function useMobile(): boolean {
return useMediaQuery(`(max-width: ${breakpoints.tablet - 1}px)`);
}
-5
View File
@@ -1,11 +1,6 @@
import React from "react";
import { useLocation } from "react-router-dom";
/**
* Hook to access URL query parameters from the current location.
*
* @returns URLSearchParams object containing the current URL query parameters
*/
export default function useQuery() {
const location = useLocation();
-5
View File
@@ -2,11 +2,6 @@ import { MobXProviderContext } from "mobx-react";
import * as React from "react";
import RootStore from "~/stores";
/**
* Hook to access the MobX stores from the React context.
*
* @returns The root store containing all application stores
*/
export default function useStores() {
return React.useContext(MobXProviderContext) as typeof RootStore;
}
-5
View File
@@ -1,10 +1,5 @@
import * as React from "react";
/**
* Hook that executes a callback when the component unmounts.
*
* @param callback Function to be called on component unmount
*/
const useUnmount = (callback: (...args: Array<any>) => any) => {
const ref = React.useRef(callback);
ref.current = callback;
-6
View File
@@ -1,11 +1,5 @@
import { useLayoutEffect, useState } from "react";
/**
* Hook to get the current viewport height, accounting for mobile virtual keyboards.
* Uses the VisualViewport API when available, falling back to window.innerHeight.
*
* @returns The current viewport height in pixels
*/
export default function useViewportHeight(): number | void {
// https://developer.mozilla.org/en-US/docs/Web/API/VisualViewport#browser_compatibility
// Note: No support in Firefox at time of writing, however this mainly exists
-7
View File
@@ -13,13 +13,6 @@ const defaultOptions = {
throttle: 100,
};
/**
* Hook to track the window's scroll position.
*
* @param options Configuration options
* @param options.throttle Time in milliseconds to throttle the scroll event
* @returns Object containing the current scroll position (x, y coordinates)
*/
export default function useWindowScrollPosition(options: {
throttle: number;
}): {
-3
View File
@@ -15,9 +15,6 @@ class Import extends Model {
/** The name of the import. */
name: string;
/** Descriptive error message when the import errors out. */
error: string | null;
/** The current state of the import. */
@Field
@observable
@@ -16,7 +16,6 @@ import { useDocumentContext } from "~/components/DocumentContext";
import Facepile from "~/components/Facepile";
import Fade from "~/components/Fade";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import useBoolean from "~/hooks/useBoolean";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import usePersistedState from "~/hooks/usePersistedState";
@@ -64,7 +63,7 @@ function CommentThread({
const history = useHistory();
const location = useLocation();
const sidebarContext = useLocationSidebarContext();
const [autoFocus, setAutoFocusOn, setAutoFocusOff] = useBoolean(thread.isNew);
const [autoFocus, setAutoFocus] = React.useState(thread.isNew);
const can = usePolicy(document);
@@ -157,9 +156,9 @@ function CommentThread({
React.useEffect(() => {
if (!focused && autoFocus) {
setAutoFocusOff();
setAutoFocus(false);
}
}, [focused, autoFocus, setAutoFocusOff]);
}, [focused, autoFocus]);
React.useEffect(() => {
if (focused) {
@@ -274,7 +273,7 @@ function CommentThread({
)}
</ResizingHeightContainer>
{!focused && !recessed && !draft && canReply && (
<Reply onClick={setAutoFocusOn}>{t("Reply")}</Reply>
<Reply onClick={() => setAutoFocus(true)}>{t("Reply")}</Reply>
)}
</Thread>
);
@@ -15,7 +15,6 @@ import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { ImportMenu } from "~/menus/ImportMenu";
import isCloudHosted from "~/utils/isCloudHosted";
type Props = {
/** Import that's displayed as list item. */
@@ -30,10 +29,6 @@ export const ImportListItem = observer(({ importModel }: Props) => {
const showProgress =
importModel.state !== ImportState.Canceled &&
importModel.state !== ImportState.Errored;
const showErrorInfo =
!isCloudHosted &&
importModel.state === ImportState.Errored &&
!!importModel.error;
const stateMap = React.useMemo(
() => ({
@@ -119,12 +114,6 @@ export const ImportListItem = observer(({ importModel }: Props) => {
subtitle={
<>
{stateMap[importModel.state]}&nbsp;&nbsp;
{showErrorInfo && (
<>
{importModel.error}
{`. ${t("Check server logs for more details.")}`}&nbsp;&nbsp;
</>
)}
{t(`{{userName}} requested`, {
userName:
user.id === importModel.createdBy.id
@@ -1,37 +0,0 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.addColumn(
"imports",
"error",
{
type: Sequelize.STRING,
allowNull: true,
},
{ transaction }
);
await queryInterface.addColumn(
"import_tasks",
"error",
{
type: Sequelize.STRING,
allowNull: true,
},
{ transaction }
);
});
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.removeColumn("imports", "error", { transaction });
await queryInterface.removeColumn("import_tasks", "error", {
transaction,
});
});
},
};
-3
View File
@@ -60,9 +60,6 @@ class Import<T extends ImportableIntegrationService> extends ParanoidModel<
@Column(DataType.INTEGER)
documentCount: number;
@Column
error: string | null;
// associations
@BelongsTo(() => Integration, "integrationId")
-3
View File
@@ -45,9 +45,6 @@ class ImportTask<T extends ImportableIntegrationService> extends IdModel<
@Column(DataType.JSONB)
output: ImportTaskOutput | null;
@Column
error: string | null;
// associations
@BelongsTo(() => Import, "importId")
@@ -222,13 +222,6 @@ describe("NotificationHelper", () => {
documentId: document.id,
});
const deletedUser = await buildUser({ teamId: document.teamId });
await buildSubscription({
userId: deletedUser.id,
documentId: document.id,
});
await deletedUser.destroy();
const recipients =
await NotificationHelper.getDocumentNotificationRecipients({
document,
@@ -201,7 +201,6 @@ export default class NotificationHelper {
include: [
{
association: "user",
required: true,
},
],
});
-1
View File
@@ -11,7 +11,6 @@ export default function presentImport(
service: importModel.service,
state: importModel.state,
documentCount: importModel.documentCount,
error: importModel.error,
createdBy: presentUser(importModel.createdBy),
createdById: importModel.createdById,
createdAt: importModel.createdAt,
@@ -1,85 +1,44 @@
import { Op } from "sequelize";
import { GroupUser } from "@server/models";
import {
CollectionGroupEvent,
CollectionUserEvent,
DocumentGroupEvent,
DocumentUserEvent,
Event,
} from "@server/types";
import CollectionSubscriptionRemoveUserTask from "../tasks/CollectionSubscriptionRemoveUserTask";
import DocumentSubscriptionRemoveUserTask from "../tasks/DocumentSubscriptionRemoveUserTask";
import { DocumentGroupEvent, DocumentUserEvent, Event } from "@server/types";
import DocumentSubscriptionTask from "../tasks/DocumentSubscriptionTask";
import BaseProcessor from "./BaseProcessor";
type ReceivedEvent =
| CollectionUserEvent
| CollectionGroupEvent
| DocumentUserEvent
| DocumentGroupEvent;
export default class DocumentSubscriptionProcessor extends BaseProcessor {
static applicableEvents: Event["name"][] = [
"collections.remove_user",
"collections.remove_group",
"documents.remove_user",
"documents.remove_group",
];
async perform(event: ReceivedEvent) {
async perform(event: DocumentUserEvent | DocumentGroupEvent) {
switch (event.name) {
case "collections.remove_user": {
await CollectionSubscriptionRemoveUserTask.schedule(event);
return;
}
case "collections.remove_group":
return this.handleRemoveGroupFromCollection(event);
case "documents.remove_user": {
await DocumentSubscriptionRemoveUserTask.schedule(event);
await DocumentSubscriptionTask.schedule(event);
return;
}
case "documents.remove_group":
return this.handleRemoveGroupFromDocument(event);
return this.handleGroup(event);
default:
}
}
private async handleRemoveGroupFromCollection(event: CollectionGroupEvent) {
private async handleGroup(event: DocumentGroupEvent) {
await GroupUser.findAllInBatches<GroupUser>(
{
where: {
groupId: event.modelId,
userId: {
[Op.ne]: event.actorId,
},
},
batchLimit: 10,
},
async (groupUsers) => {
await Promise.all(
groupUsers.map((groupUser) =>
CollectionSubscriptionRemoveUserTask.schedule({
...event,
name: "collections.remove_user",
userId: groupUser.userId,
})
)
);
}
);
}
private async handleRemoveGroupFromDocument(event: DocumentGroupEvent) {
await GroupUser.findAllInBatches<GroupUser>(
{
where: {
groupId: event.modelId,
},
batchLimit: 10,
},
async (groupUsers) => {
await Promise.all(
groupUsers.map((groupUser) =>
DocumentSubscriptionRemoveUserTask.schedule({
DocumentSubscriptionTask.schedule({
...event,
name: "documents.remove_user",
userId: groupUser.userId,
+24 -38
View File
@@ -49,46 +49,33 @@ export default abstract class ImportsProcessor<
* @param event The import event
*/
public async perform(event: ImportEvent) {
try {
await sequelize.transaction(async (transaction) => {
const importModel = await Import.findByPk<Import<T>>(event.modelId, {
rejectOnEmpty: true,
paranoid: false,
transaction,
lock: transaction.LOCK.UPDATE,
});
if (
!this.canProcess(importModel) ||
importModel.state === ImportState.Errored ||
importModel.state === ImportState.Canceled
) {
return;
}
switch (event.name) {
case "imports.create":
return this.onCreation(importModel, transaction);
case "imports.processed":
return this.onProcessed(importModel, transaction);
case "imports.delete":
return this.onDeletion(importModel, event, transaction);
}
await sequelize.transaction(async (transaction) => {
const importModel = await Import.findByPk<Import<T>>(event.modelId, {
rejectOnEmpty: true,
paranoid: false,
transaction,
lock: transaction.LOCK.UPDATE,
});
} catch (err) {
if (event.name !== "imports.delete" && err instanceof Error) {
const importModel = await Import.findByPk<Import<T>>(event.modelId, {
rejectOnEmpty: true,
paranoid: false,
});
importModel.error = truncate(err.message, { length: 255 });
await importModel.save();
if (
!this.canProcess(importModel) ||
importModel.state === ImportState.Errored ||
importModel.state === ImportState.Canceled
) {
return;
}
throw err; // throw error for retry.
}
switch (event.name) {
case "imports.create":
return this.onCreation(importModel, transaction);
case "imports.processed":
return this.onProcessed(importModel, transaction);
case "imports.delete":
return this.onDeletion(importModel, event, transaction);
}
});
}
public async onFailed(event: ImportEvent) {
@@ -186,7 +173,6 @@ export default abstract class ImportsProcessor<
}
importModel.state = ImportState.Completed;
importModel.error = null; // unset any error from previous attempts.
await importModel.saveWithCtx(
createContext({
user: importModel.createdBy,
+12 -24
View File
@@ -1,6 +1,5 @@
import { JobOptions } from "bull";
import chunk from "lodash/chunk";
import truncate from "lodash/truncate";
import uniqBy from "lodash/uniqBy";
import { Fragment, Node } from "prosemirror-model";
import { Transaction, WhereOptions } from "sequelize";
@@ -64,29 +63,20 @@ export default abstract class APIImportTask<
return;
}
try {
switch (importTask.state) {
case ImportTaskState.Created: {
importTask.state = ImportTaskState.InProgress;
importTask = await importTask.save();
return await this.onProcess(importTask);
}
case ImportTaskState.InProgress:
return await this.onProcess(importTask);
case ImportTaskState.Completed:
return await this.onCompletion(importTask);
default:
}
} catch (err) {
if (err instanceof Error) {
importTask.error = truncate(err.message, { length: 255 });
await importTask.save();
switch (importTask.state) {
case ImportTaskState.Created: {
importTask.state = ImportTaskState.InProgress;
importTask = await importTask.save();
return await this.onProcess(importTask);
}
throw err; // throw error for retry.
case ImportTaskState.InProgress:
return await this.onProcess(importTask);
case ImportTaskState.Completed:
return await this.onCompletion(importTask);
default:
}
}
@@ -118,7 +108,6 @@ export default abstract class APIImportTask<
await importTask.save({ transaction });
const associatedImport = importTask.import;
associatedImport.error = importTask.error; // copy error from ImportTask that caused the failure.
associatedImport.state = ImportState.Errored;
await associatedImport.saveWithCtx(
createContext({
@@ -166,7 +155,6 @@ export default abstract class APIImportTask<
importTask.output = taskOutputWithReplacements;
importTask.state = ImportTaskState.Completed;
importTask.error = null; // unset any error from previous attempts.
await importTask.save({ transaction });
const associatedImport = importTask.import;
@@ -41,7 +41,6 @@ export default class CleanupOldImportsTask extends BaseTask<Props> {
],
batchLimit: 50,
totalLimit: maxImportsPerTask,
paranoid: false,
},
async (imports) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -1,52 +0,0 @@
import { Transaction } from "sequelize";
import { SubscriptionType } from "@shared/types";
import { createContext } from "@server/context";
import Logger from "@server/logging/Logger";
import { Collection, Subscription, User } from "@server/models";
import { can } from "@server/policies";
import { sequelize } from "@server/storage/database";
import { CollectionUserEvent } from "@server/types";
import BaseTask from "./BaseTask";
export default class CollectionSubscriptionRemoveUserTask extends BaseTask<CollectionUserEvent> {
public async perform(event: CollectionUserEvent) {
const user = await User.findByPk(event.userId);
if (!user) {
return;
}
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(event.collectionId);
if (can(user, "read", collection)) {
Logger.debug(
"task",
`Skip unsubscribing user ${user.id} as they have permission to the collection ${event.collectionId} through other means`
);
return;
}
await sequelize.transaction(async (transaction) => {
const subscription = await Subscription.findOne({
where: {
userId: user.id,
collectionId: event.collectionId,
event: SubscriptionType.Document,
},
transaction,
lock: Transaction.LOCK.UPDATE,
});
await subscription?.destroyWithCtx(
createContext({
user,
authType: event.authType,
ip: event.ip,
transaction,
})
);
});
}
}
@@ -8,11 +8,11 @@ import { sequelize } from "@server/storage/database";
import { DocumentUserEvent } from "@server/types";
import BaseTask from "./BaseTask";
export default class DocumentSubscriptionRemoveUserTask extends BaseTask<DocumentUserEvent> {
export default class DocumentSubscriptionTask extends BaseTask<DocumentUserEvent> {
public async perform(event: DocumentUserEvent) {
const user = await User.findByPk(event.userId);
if (!user) {
if (!user || event.name !== "documents.remove_user") {
return;
}
@@ -56,13 +56,11 @@ export default class ErrorTimedOutImportsTask extends BaseTask<Props> {
await sequelize.transaction(async (transaction) => {
importTask.state = ImportTaskState.Errored;
importTask.error = "Timed out";
await importTask.save({ transaction });
// this import could have been seen before in another import_task.
if (!importsErrored[associatedImport.id]) {
associatedImport.state = ImportState.Errored;
associatedImport.error = "Timed out";
await associatedImport.save({ transaction });
importsErrored[associatedImport.id] = true;
}
-4
View File
@@ -10,10 +10,6 @@ type Props = {
captureEvents?: "all" | "pointer" | "click";
};
/**
* EventBoundary is a component that prevents events from propagating to parent elements.
* This is useful for preventing clicks or other interactions from bubbling up the DOM tree.
*/
const EventBoundary: React.FC<Props> = ({
children,
className,
-12
View File
@@ -5,26 +5,14 @@ type JustifyValues = CSSProperties["justifyContent"];
type AlignValues = CSSProperties["alignItems"];
/**
* Flex is a styled component that provides a flexible box layout with convenient props.
* It simplifies the use of flexbox CSS properties with a clean, declarative API.
*/
const Flex = styled.div<{
/** Makes the component grow to fill available space */
auto?: boolean;
/** Changes flex direction to column */
column?: boolean;
/** Sets the align-items CSS property */
align?: AlignValues;
/** Sets the justify-content CSS property */
justify?: JustifyValues;
/** Enables flex-wrap */
wrap?: boolean;
/** Controls flex-shrink behavior */
shrink?: boolean;
/** Reverses the direction (row-reverse or column-reverse) */
reverse?: boolean;
/** Sets gap between flex items in pixels */
gap?: number;
}>`
display: flex;
-5
View File
@@ -11,11 +11,6 @@ type Props = {
className?: string;
};
/**
* Squircle is a component that renders a square with rounded corners (squircle shape).
* It's commonly used for app icons, avatars, and other UI elements where a softer
* square shape is desired.
*/
const Squircle: React.FC<Props> = ({
color,
size = 28,
+1 -9
View File
@@ -1,5 +1,5 @@
import { NodeType } from "prosemirror-model";
import { liftListItem, wrapInList } from "prosemirror-schema-list";
import { wrapInList, liftListItem } from "prosemirror-schema-list";
import { Command } from "prosemirror-state";
import { chainTransactions } from "../lib/chainTransactions";
import { findParentNode } from "../queries/findParentNode";
@@ -29,14 +29,6 @@ export default function toggleList(
return liftListItem(itemType)(state, dispatch);
}
const currentItemType = parentList.node.content.firstChild?.type;
if (currentItemType && currentItemType !== itemType) {
return chainTransactions(clearNodes(), wrapInList(listType))(
state,
dispatch
);
}
if (
isList(parentList.node, schema) &&
listType.validContent(parentList.node.content)
-4
View File
@@ -313,10 +313,6 @@ width: 100%;
background: ${props.theme.mentionHoverBackground};
}
&[data-type="user"] {
gap: 0;
}
&.mention-user::before {
content: "@";
}
+1 -1
View File
@@ -21,7 +21,7 @@ export default class Code extends Mark {
get schema(): MarkSpec {
return {
excludes: "mention placeholder highlight",
excludes: "mention placeholder highlight em strong",
parseDOM: [{ tag: "code", preserveWhitespace: true }],
toDOM: () => ["code", { class: "inline", spellCheck: "false" }],
};
+5 -7
View File
@@ -32,11 +32,6 @@ export default class Mention extends Node {
}
get schema(): NodeSpec {
const toPlainText = (node: ProsemirrorNode) =>
node.attrs.type === MentionType.User
? `@${node.attrs.label}`
: node.attrs.label;
return {
attrs: {
type: {
@@ -93,9 +88,12 @@ export default class Mention extends Node {
"data-actorid": node.attrs.actorId,
"data-url": `mention://${node.attrs.id}/${node.attrs.type}/${node.attrs.modelId}`,
},
toPlainText(node),
String(node.attrs.label),
],
toPlainText,
toPlainText: (node) =>
node.attrs.type === MentionType.User
? `@${node.attrs.label}`
: node.attrs.label,
};
}
-2
View File
@@ -98,8 +98,6 @@ export default class TableCell extends Node {
}
}
// For all other cases, preserve the slice as is to maintain table structure
// including column widths and other attributes
return slice;
},
handleDOMEvents: {
+3 -17
View File
@@ -7,8 +7,6 @@ type Options = {
onlyBlock?: boolean;
/** Only check if the selection is inside a code mark. */
onlyMark?: boolean;
/** If true then code must contain entire selection */
inclusive?: boolean;
};
/**
@@ -22,29 +20,17 @@ export function isInCode(state: EditorState, options?: Options): boolean {
const { nodes, marks } = state.schema;
if (!options?.onlyMark) {
if (
nodes.code_block &&
isNodeActive(nodes.code_block, undefined, {
inclusive: options?.inclusive,
})(state)
) {
if (nodes.code_block && isNodeActive(nodes.code_block)(state)) {
return true;
}
if (
nodes.code_fence &&
isNodeActive(nodes.code_fence, undefined, {
inclusive: options?.inclusive,
})(state)
) {
if (nodes.code_fence && isNodeActive(nodes.code_fence)(state)) {
return true;
}
}
if (!options?.onlyBlock) {
if (marks.code_inline) {
return isMarkActive(marks.code_inline, undefined, {
inclusive: options?.inclusive,
})(state);
return isMarkActive(marks.code_inline)(state);
}
}
+1 -4
View File
@@ -6,8 +6,6 @@ import { getMarksBetween } from "./getMarksBetween";
type Options = {
/** Only return match if the range and attrs is exact */
exact?: boolean;
/** If true then mark must contain entire selection */
inclusive?: boolean;
};
/**
@@ -42,8 +40,7 @@ export const isMarkActive =
Object.keys(attrs).every(
(key) => mark.attrs[key] === attrs[key]
)) &&
(!options?.exact || (start === from && end === to)) &&
(!options?.inclusive || (start <= from && end >= to))
(!options?.exact || (start === from && end === to))
);
}
+10 -34
View File
@@ -3,55 +3,31 @@ import { EditorState } from "prosemirror-state";
import { Primitive } from "utility-types";
import { findParentNode } from "./findParentNode";
type Options = {
/** Only return match if the range and attrs is exact */
exact?: boolean;
/** If true then node must contain entire selection */
inclusive?: boolean;
};
/**
* Checks if a node is active in the current selection or not.
*
* @param type The node type to check.
* @param attrs The attributes to check.
* @param options The options to use.
* @returns A function that checks if a node is active in the current selection or not.
*/
export const isNodeActive =
(type: NodeType, attrs?: Record<string, Primitive>, options?: Options) =>
(state: EditorState): boolean => {
(type: NodeType, attrs: Record<string, Primitive> = {}) =>
(state: EditorState) => {
if (!type) {
return false;
}
const { from, to } = state.selection;
const nodeWithPos = findParentNode(
(node) =>
node.type === type &&
(!attrs ||
Object.keys(attrs).every((key) => node.attrs[key] === attrs[key]))
)(state.selection);
const nodeAfter = state.selection.$from.nodeAfter;
let node = nodeAfter?.type === type ? nodeAfter : undefined;
if (!nodeWithPos) {
return false;
if (!node) {
const parent = findParentNode((n) => n.type === type)(state.selection);
node = parent?.node;
}
if (options?.inclusive) {
// Check if the node's position contains the entire selection
return (
nodeWithPos.pos <= from &&
nodeWithPos.pos + nodeWithPos.node.nodeSize >= to
);
if (!Object.keys(attrs).length || !node) {
return !!node;
}
if (options?.exact) {
// Check if node's range exactly matches selection
return (
nodeWithPos.pos === from &&
nodeWithPos.pos + nodeWithPos.node.nodeSize === to
);
}
return true;
return node.hasMarkup(type, { ...node.attrs, ...attrs });
};
+24 -16
View File
@@ -1,5 +1,16 @@
import { useState, useLayoutEffect } from "react";
const defaultRect = {
top: 0,
left: 0,
bottom: 0,
right: 0,
x: 0,
y: 0,
width: 0,
height: 0,
};
/**
* A hook that returns the size of an element or ref.
*
@@ -8,11 +19,19 @@ import { useState, useLayoutEffect } from "react";
*/
export function useComponentSize(
input: HTMLElement | null | React.RefObject<HTMLElement | null>
) {
): DOMRect | typeof defaultRect {
const element = input instanceof HTMLElement ? input : input?.current;
const [size, setSize] = useState<DOMRect | undefined>(
() => element?.getBoundingClientRect() || new DOMRect()
);
const [size, setSize] = useState(() => element?.getBoundingClientRect());
useLayoutEffect(() => {
const sizeObserver = new ResizeObserver(() => {
element?.dispatchEvent(new CustomEvent("resize"));
});
if (element) {
sizeObserver.observe(element);
}
return () => sizeObserver.disconnect();
}, [element]);
useLayoutEffect(() => {
const handleResize = () => {
@@ -36,7 +55,6 @@ export function useComponentSize(
window.addEventListener("click", handleResize);
window.addEventListener("resize", handleResize);
element?.addEventListener("resize", handleResize);
handleResize();
return () => {
window.removeEventListener("click", handleResize);
@@ -45,15 +63,5 @@ export function useComponentSize(
};
});
useLayoutEffect(() => {
const sizeObserver = new ResizeObserver(() => {
element?.dispatchEvent(new CustomEvent("resize"));
});
if (element) {
sizeObserver.observe(element);
}
return () => sizeObserver.disconnect();
}, [element]);
return size ?? new DOMRect();
return size ?? defaultRect;
}