Compare 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
5 changed files with 126 additions and 140 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 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);
} else {
break;
}
}
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,11 +1,3 @@
export const CacheKey = {
LICENSE_VALID: (workspaceId: string) => `license:valid:${workspaceId}`,
SPACE_ROLES: (userId: string, spaceId: string) =>
`perm:space-roles:${userId}:${spaceId}`,
PAGE_CAN_EDIT: (userId: string, pageId: string) =>
`perm:can-edit:${userId}:${pageId}`,
};
// Permission caches dedupe repeated checks within and across short request bursts.
// 5s keeps staleness on revocations bounded.
export const PERMISSION_CACHE_TTL_MS = 5_000;
@@ -1,27 +0,0 @@
import { Cache } from 'cache-manager';
export async function withCache<T>(
cacheManager: Cache,
key: string,
ttlMs: number,
fn: () => Promise<T>,
): Promise<T> {
try {
const cached = await cacheManager.get<{ v: T }>(key);
if (cached !== undefined && cached !== null) {
return cached.v;
}
} catch (err) {
console.warn(`[withCache] get failed for "${key}", falling back to source`, err);
}
const value = await fn();
try {
await cacheManager.set(key, { v: value }, ttlMs);
} catch (err) {
console.warn(`[withCache] set failed for "${key}"`, err);
}
return value;
}
@@ -1,6 +1,4 @@
import { Inject, Injectable } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils';
@@ -19,11 +17,6 @@ import {
executeWithCursorPagination,
} from '@docmost/db/pagination/cursor-pagination';
import { PagePermissionMember } from './types/page-permission.types';
import { withCache } from '../../../common/helpers/with-cache';
import {
CacheKey,
PERMISSION_CACHE_TTL_MS,
} from '../../../common/helpers/cache-keys';
export { PagePermissionMember } from './types/page-permission.types';
@@ -32,7 +25,6 @@ export class PagePermissionRepo {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly groupRepo: GroupRepo,
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
) {}
async findPageAccessByPageId(
@@ -369,8 +361,40 @@ export class PagePermissionRepo {
* Check if user can access a page by verifying they have permission on ALL restricted ancestors.
*/
async canUserAccessPage(userId: string, pageId: string): Promise<boolean> {
const { canAccess } = await this.canUserEditPage(userId, pageId);
return canAccess;
const deniedAncestor = await this.db
.withRecursive('ancestors', (qb) =>
qb
.selectFrom('pages')
.select(['pages.id as ancestorId', 'pages.parentPageId'])
.where('pages.id', '=', pageId)
.unionAll((eb) =>
eb
.selectFrom('pages')
.innerJoin('ancestors', 'ancestors.parentPageId', 'pages.id')
.select(['pages.id as ancestorId', 'pages.parentPageId']),
),
)
.selectFrom('ancestors')
.innerJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on((eb) =>
eb.or([
eb('pagePermissions.userId', '=', userId),
eb(
'pagePermissions.groupId',
'in',
this.userGroupIdsSubquery(eb, userId),
),
]),
),
)
.select('pageAccess.pageId')
.where('pagePermissions.id', 'is', null)
.executeTakeFirst();
return !deniedAncestor;
}
/**
@@ -388,11 +412,6 @@ export class PagePermissionRepo {
canAccess: boolean;
canEdit: boolean;
}> {
return withCache(
this.cacheManager,
CacheKey.PAGE_CAN_EDIT(userId, pageId),
PERMISSION_CACHE_TTL_MS,
async () => {
const result = await sql<{
canAccess: boolean | null;
canEdit: boolean | null;
@@ -430,8 +449,6 @@ export class PagePermissionRepo {
canAccess: row.canAccess,
canEdit: row.canAccess && (row.canEdit ?? false),
};
},
);
}
/**
@@ -1,6 +1,4 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils';
@@ -15,11 +13,6 @@ import { MemberInfo, UserSpaceRole } from './types';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { withCache } from '../../../common/helpers/with-cache';
import {
CacheKey,
PERMISSION_CACHE_TTL_MS,
} from '../../../common/helpers/cache-keys';
@Injectable()
export class SpaceMemberRepo {
@@ -27,7 +20,6 @@ export class SpaceMemberRepo {
@InjectKysely() private readonly db: KyselyDB,
private readonly groupRepo: GroupRepo,
private readonly spaceRepo: SpaceRepo,
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
) {}
async insertSpaceMember(
@@ -222,11 +214,6 @@ export class SpaceMemberRepo {
userId: string,
spaceId: string,
): Promise<UserSpaceRole[]> {
return withCache(
this.cacheManager,
CacheKey.SPACE_ROLES(userId, spaceId),
PERMISSION_CACHE_TTL_MS,
async () => {
const roles = await this.db
.selectFrom('spaceMembers')
.select(['userId', 'role'])
@@ -235,11 +222,7 @@ export class SpaceMemberRepo {
.unionAll(
this.db
.selectFrom('spaceMembers')
.innerJoin(
'groupUsers',
'groupUsers.groupId',
'spaceMembers.groupId',
)
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
.select(['groupUsers.userId', 'spaceMembers.role'])
.where('groupUsers.userId', '=', userId)
.where('spaceMembers.spaceId', '=', spaceId),
@@ -250,8 +233,6 @@ export class SpaceMemberRepo {
return undefined;
}
return roles;
},
);
}
async getUserIdsWithSpaceAccess(