Compare commits

..

1 Commits

Author SHA1 Message Date
Philipinho 62f0a2278d fix(editor): prevent stuck list after pasting plain text
marked.parse() emits a trailing newline that became a whitespace text
node at the body level, which parseSlice converted into a spurious
paragraph at the end of the target — inside a list item this blocked
the "Enter exits list" behavior since splitListItem's empty-last-block
check never fired.
Strip whitespace-only text nodes between block elements before parsing
the slice, and place the cursor at the end of the inserted content.
Also extend transformPasted to drop trailing hardBreaks and whitespace
text nodes for the HTML-clipboard path.
2026-05-12 22:32:44 +01:00
4 changed files with 38 additions and 49 deletions
@@ -81,6 +81,7 @@ export const MarkdownClipboard = Extension.create({
const parsed = markdownToHtml(text.replace(/\n+$/, "")); const parsed = markdownToHtml(text.replace(/\n+$/, ""));
const body = elementFromString(parsed); const body = elementFromString(parsed);
stripBlockLevelWhitespaceNodes(body);
normalizeTableColumnWidths(body); normalizeTableColumnWidths(body);
const contentNodes = DOMParser.fromSchema( const contentNodes = DOMParser.fromSchema(
@@ -91,7 +92,7 @@ export const MarkdownClipboard = Extension.create({
tr.replaceRange(from, to, contentNodes); tr.replaceRange(from, to, contentNodes);
const insertEnd = tr.mapping.map(from, 1); const insertEnd = tr.mapping.map(from, 1);
tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(from, insertEnd - 2)), -1)); tr.setSelection(TextSelection.near(tr.doc.resolve(insertEnd), -1));
tr.setMeta('paste', true) tr.setMeta('paste', true)
view.dispatch(tr); view.dispatch(tr);
return true; return true;
@@ -104,21 +105,28 @@ export const MarkdownClipboard = Extension.create({
transformPasted: (slice) => { transformPasted: (slice) => {
let { content, openStart, openEnd } = slice; let { content, openStart, openEnd } = slice;
// Remove trailing paragraphs that contain only whitespace const isTrailingNoise = (node: any) => {
while (content.childCount > 1) { if (!node) return false;
const lastChild = content.lastChild; if (node.type.name === "hardBreak") return true;
if ( if (node.isText && (node.text ?? "").trim() === "") return true;
lastChild?.type.name === "paragraph" && if (node.type.name === "paragraph") {
lastChild.textContent.trim() === "" let onlyNoise = true;
) { node.content.forEach((c: any) => {
if (c.type.name === "hardBreak") return;
if (c.isText && (c.text ?? "").trim() === "") return;
onlyNoise = false;
});
return onlyNoise;
}
return false;
};
while (content.childCount > 1 && isTrailingNoise(content.lastChild)) {
const children = []; const children = [];
for (let i = 0; i < content.childCount - 1; i++) { for (let i = 0; i < content.childCount - 1; i++) {
children.push(content.child(i)); children.push(content.child(i));
} }
content = Fragment.from(children); content = Fragment.from(children);
} else {
break;
}
} }
if (content !== slice.content) { if (content !== slice.content) {
@@ -140,6 +148,21 @@ function elementFromString(value) {
return new window.DOMParser().parseFromString(wrappedValue, "text/html").body; return new window.DOMParser().parseFromString(wrappedValue, "text/html").body;
} }
// marked.parse() emits "<p>...</p>\n<p>...</p>\n" — those literal newlines
// become whitespace text nodes that parseSlice (preserveWhitespace: true)
// converts into spurious empty paragraphs at the insertion site. Inside a
// list item the trailing one prevents Enter from exiting the list.
function stripBlockLevelWhitespaceNodes(body: HTMLElement): void {
Array.from(body.childNodes).forEach((node) => {
if (
node.nodeType === 3 /* TEXT_NODE */ &&
(node.textContent ?? "").trim() === ""
) {
body.removeChild(node);
}
});
}
const DEFAULT_PASTE_COL_WIDTH_PX = 150; const DEFAULT_PASTE_COL_WIDTH_PX = 150;
function parsePixelWidth(el: Element): number | null { function parsePixelWidth(el: Element): number | null {
@@ -1,21 +0,0 @@
export function LogTiming() {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor,
) {
const original = descriptor.value;
descriptor.value = async function (...args: any[]) {
const start = performance.now();
try {
return await original.apply(this, args);
} finally {
const ms = performance.now() - start;
console.log(
`[perm-timing] ${target.constructor.name}.${propertyKey} ${ms.toFixed(2)}ms`,
);
}
};
return descriptor;
};
}
@@ -13,12 +13,10 @@ import {
SpaceCaslSubject, SpaceCaslSubject,
} from '../interfaces/space-ability.type'; } from '../interfaces/space-ability.type';
import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils'; import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
import { LogTiming } from '../../../common/helpers/log-timing';
@Injectable() @Injectable()
export default class SpaceAbilityFactory { export default class SpaceAbilityFactory {
constructor(private readonly spaceMemberRepo: SpaceMemberRepo) {} constructor(private readonly spaceMemberRepo: SpaceMemberRepo) {}
@LogTiming()
async createForUser(user: User, spaceId: string) { async createForUser(user: User, spaceId: string) {
const userSpaceRoles = await this.spaceMemberRepo.getUserSpaceRoles( const userSpaceRoles = await this.spaceMemberRepo.getUserSpaceRoles(
user.id, user.id,
@@ -17,7 +17,6 @@ import {
executeWithCursorPagination, executeWithCursorPagination,
} from '@docmost/db/pagination/cursor-pagination'; } from '@docmost/db/pagination/cursor-pagination';
import { PagePermissionMember } from './types/page-permission.types'; import { PagePermissionMember } from './types/page-permission.types';
import { LogTiming } from '../../../common/helpers/log-timing';
export { PagePermissionMember } from './types/page-permission.types'; export { PagePermissionMember } from './types/page-permission.types';
@@ -361,7 +360,6 @@ export class PagePermissionRepo {
/** /**
* Check if user can access a page by verifying they have permission on ALL restricted ancestors. * Check if user can access a page by verifying they have permission on ALL restricted ancestors.
*/ */
@LogTiming()
async canUserAccessPage(userId: string, pageId: string): Promise<boolean> { async canUserAccessPage(userId: string, pageId: string): Promise<boolean> {
const deniedAncestor = await this.db const deniedAncestor = await this.db
.withRecursive('ancestors', (qb) => .withRecursive('ancestors', (qb) =>
@@ -406,7 +404,6 @@ export class PagePermissionRepo {
* - array_agg(role ORDER BY depth)[1]: role on the nearest restricted ancestor * - array_agg(role ORDER BY depth)[1]: role on the nearest restricted ancestor
* - Zero rows (no restricted ancestors): both NULL → defer to space permissions (true) * - Zero rows (no restricted ancestors): both NULL → defer to space permissions (true)
*/ */
@LogTiming()
async canUserEditPage( async canUserEditPage(
userId: string, userId: string,
pageId: string, pageId: string,
@@ -463,7 +460,6 @@ export class PagePermissionRepo {
* - canAccess: user has permission on all restricted ancestors (always true if no restrictions) * - canAccess: user has permission on all restricted ancestors (always true if no restrictions)
* - canEdit: user has writer on nearest restricted ancestor (always true if no restrictions) * - canEdit: user has writer on nearest restricted ancestor (always true if no restrictions)
*/ */
@LogTiming()
async getUserPageAccessLevel( async getUserPageAccessLevel(
userId: string, userId: string,
pageId: string, pageId: string,
@@ -674,7 +670,6 @@ export class PagePermissionRepo {
* Returns page IDs with their permission level (canEdit). * Returns page IDs with their permission level (canEdit).
* Single query implementation for efficiency. * Single query implementation for efficiency.
*/ */
@LogTiming()
async filterAccessiblePageIds(opts: { async filterAccessiblePageIds(opts: {
pageIds: string[]; pageIds: string[];
userId: string; userId: string;
@@ -752,7 +747,6 @@ export class PagePermissionRepo {
return results.map((r) => r.id); return results.map((r) => r.id);
} }
@LogTiming()
async filterAccessiblePageIdsWithPermissions( async filterAccessiblePageIdsWithPermissions(
pageIds: string[], pageIds: string[],
userId: string, userId: string,
@@ -883,7 +877,6 @@ export class PagePermissionRepo {
* Check if a page or any of its ancestors has restrictions. * Check if a page or any of its ancestors has restrictions.
* Used to determine if page-level permission checks are needed. * Used to determine if page-level permission checks are needed.
*/ */
@LogTiming()
async hasRestrictedAncestor(pageId: string): Promise<boolean> { async hasRestrictedAncestor(pageId: string): Promise<boolean> {
const result = await this.db const result = await this.db
.withRecursive('ancestors', (qb) => .withRecursive('ancestors', (qb) =>
@@ -910,7 +903,6 @@ export class PagePermissionRepo {
* Check if any page in a space has restrictions. * Check if any page in a space has restrictions.
* Used as a quick check to skip heavy permission filtering when no restrictions exist. * Used as a quick check to skip heavy permission filtering when no restrictions exist.
*/ */
@LogTiming()
async hasRestrictedPagesInSpace(spaceId: string): Promise<boolean> { async hasRestrictedPagesInSpace(spaceId: string): Promise<boolean> {
const result = await this.db const result = await this.db
.selectNoFrom((eb) => .selectNoFrom((eb) =>
@@ -932,7 +924,6 @@ export class PagePermissionRepo {
* Given a list of parent page IDs, return which ones have at least one accessible child. * Given a list of parent page IDs, return which ones have at least one accessible child.
* Efficient batch query for sidebar hasChildren calculation. * Efficient batch query for sidebar hasChildren calculation.
*/ */
@LogTiming()
async getParentIdsWithAccessibleChildren( async getParentIdsWithAccessibleChildren(
parentIds: string[], parentIds: string[],
userId: string, userId: string,
@@ -1009,7 +1000,6 @@ export class PagePermissionRepo {
* Used to filter pages from public shares - if a page is restricted, it and all its * Used to filter pages from public shares - if a page is restricted, it and all its
* children should be hidden. * children should be hidden.
*/ */
@LogTiming()
async getRestrictedSubtreeIds(rootPageId: string): Promise<string[]> { async getRestrictedSubtreeIds(rootPageId: string): Promise<string[]> {
const results = await this.db const results = await this.db
.withRecursive('descendants', (qb) => .withRecursive('descendants', (qb) =>
@@ -1071,7 +1061,6 @@ export class PagePermissionRepo {
* access the page (have permission on ALL restricted ancestors). * access the page (have permission on ALL restricted ancestors).
* Returns all userIds if the page has no restricted ancestors. * Returns all userIds if the page has no restricted ancestors.
*/ */
@LogTiming()
async getUserIdsWithPageAccess( async getUserIdsWithPageAccess(
pageId: string, pageId: string,
userIds: string[], userIds: string[],