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 body = elementFromString(parsed);
stripBlockLevelWhitespaceNodes(body);
normalizeTableColumnWidths(body);
const contentNodes = DOMParser.fromSchema(
@@ -91,7 +92,7 @@ export const MarkdownClipboard = Extension.create({
tr.replaceRange(from, to, contentNodes);
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)
view.dispatch(tr);
return true;
@@ -104,21 +105,28 @@ export const MarkdownClipboard = Extension.create({
transformPasted: (slice) => {
let { content, openStart, openEnd } = slice;
// Remove trailing paragraphs that contain only whitespace
while (content.childCount > 1) {
const lastChild = content.lastChild;
if (
lastChild?.type.name === "paragraph" &&
lastChild.textContent.trim() === ""
) {
const children = [];
for (let i = 0; i < content.childCount - 1; i++) {
children.push(content.child(i));
}
content = Fragment.from(children);
} else {
break;
const isTrailingNoise = (node: any) => {
if (!node) return false;
if (node.type.name === "hardBreak") return true;
if (node.isText && (node.text ?? "").trim() === "") return true;
if (node.type.name === "paragraph") {
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 = [];
for (let i = 0; i < content.childCount - 1; i++) {
children.push(content.child(i));
}
content = Fragment.from(children);
}
if (content !== slice.content) {
@@ -140,6 +148,21 @@ function elementFromString(value) {
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;
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,
} from '../interfaces/space-ability.type';
import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
import { LogTiming } from '../../../common/helpers/log-timing';
@Injectable()
export default class SpaceAbilityFactory {
constructor(private readonly spaceMemberRepo: SpaceMemberRepo) {}
@LogTiming()
async createForUser(user: User, spaceId: string) {
const userSpaceRoles = await this.spaceMemberRepo.getUserSpaceRoles(
user.id,
@@ -17,7 +17,6 @@ import {
executeWithCursorPagination,
} from '@docmost/db/pagination/cursor-pagination';
import { PagePermissionMember } from './types/page-permission.types';
import { LogTiming } from '../../../common/helpers/log-timing';
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.
*/
@LogTiming()
async canUserAccessPage(userId: string, pageId: string): Promise<boolean> {
const deniedAncestor = await this.db
.withRecursive('ancestors', (qb) =>
@@ -406,7 +404,6 @@ export class PagePermissionRepo {
* - 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)
*/
@LogTiming()
async canUserEditPage(
userId: string,
pageId: string,
@@ -463,7 +460,6 @@ export class PagePermissionRepo {
* - 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)
*/
@LogTiming()
async getUserPageAccessLevel(
userId: string,
pageId: string,
@@ -674,7 +670,6 @@ export class PagePermissionRepo {
* Returns page IDs with their permission level (canEdit).
* Single query implementation for efficiency.
*/
@LogTiming()
async filterAccessiblePageIds(opts: {
pageIds: string[];
userId: string;
@@ -752,7 +747,6 @@ export class PagePermissionRepo {
return results.map((r) => r.id);
}
@LogTiming()
async filterAccessiblePageIdsWithPermissions(
pageIds: string[],
userId: string,
@@ -883,7 +877,6 @@ export class PagePermissionRepo {
* Check if a page or any of its ancestors has restrictions.
* Used to determine if page-level permission checks are needed.
*/
@LogTiming()
async hasRestrictedAncestor(pageId: string): Promise<boolean> {
const result = await this.db
.withRecursive('ancestors', (qb) =>
@@ -910,7 +903,6 @@ export class PagePermissionRepo {
* Check if any page in a space has restrictions.
* Used as a quick check to skip heavy permission filtering when no restrictions exist.
*/
@LogTiming()
async hasRestrictedPagesInSpace(spaceId: string): Promise<boolean> {
const result = await this.db
.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.
* Efficient batch query for sidebar hasChildren calculation.
*/
@LogTiming()
async getParentIdsWithAccessibleChildren(
parentIds: 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
* children should be hidden.
*/
@LogTiming()
async getRestrictedSubtreeIds(rootPageId: string): Promise<string[]> {
const results = await this.db
.withRecursive('descendants', (qb) =>
@@ -1071,7 +1061,6 @@ export class PagePermissionRepo {
* access the page (have permission on ALL restricted ancestors).
* Returns all userIds if the page has no restricted ancestors.
*/
@LogTiming()
async getUserIdsWithPageAccess(
pageId: string,
userIds: string[],