Compare commits

..

6 Commits

Author SHA1 Message Date
Philipinho 2f92e1fecf add yjs utils 2026-02-12 11:13:28 -08:00
Philipinho e709e34f1f dry 2026-02-12 11:09:08 -08:00
Philipinho ae9484e274 rename contentOperation -> operation 2026-02-12 11:01:00 -08:00
Philipinho 3c81441ddb refactor naming
* support prepend
2026-02-12 10:57:30 -08:00
Philipinho 152702ebe0 import module 2026-02-11 23:50:24 -08:00
Philipinho 10b0ac06dd feat: page content update and retrieval output 2026-02-11 23:44:46 -08:00
9 changed files with 291 additions and 78 deletions
@@ -30,18 +30,13 @@ export class CollaborationHandler {
updatePageContent: async (
documentName: string,
payload: {
pageId: string;
prosemirrorJson: any;
operation: string;
user: User;
},
) => {
const { pageId, prosemirrorJson, operation, user } = payload;
this.logger.debug(
'Updating page content via yjs',
documentName,
payload,
);
const { prosemirrorJson, operation, user } = payload;
this.logger.debug('Updating page content via yjs', documentName);
await this.withYdocConnection(
hocuspocus,
documentName,
@@ -63,7 +58,9 @@ export class CollaborationHandler {
} else {
const newContent = prosemirrorJson.content || [];
const yElements = newContent.map(prosemirrorNodeToYElement);
fragment.insert(fragment.length, yElements);
const position =
operation === 'prepend' ? 0 : fragment.length;
fragment.insert(position, yElements);
}
},
);
@@ -1,4 +1,10 @@
import { Logger, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import {
Global,
Logger,
Module,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { AuthenticationExtension } from './extensions/authentication.extension';
import { PersistenceExtension } from './extensions/persistence.extension';
import { CollaborationGateway } from './collaboration.gateway';
@@ -182,6 +182,7 @@ export class PersistenceExtension implements Extension {
async onChange(data: onChangePayload) {
const documentName = data.documentName;
const userId = data.context?.user?.id;
if (!userId) return;
if (!this.contributors.has(documentName)) {
+177
View File
@@ -0,0 +1,177 @@
import {
initProseMirrorDoc,
relativePositionToAbsolutePosition,
} from 'y-prosemirror';
import * as Y from 'yjs';
import { Document } from '@hocuspocus/server';
import { getSchema } from '@tiptap/core';
import { tiptapExtensions } from './collaboration.util';
export type YjsSelection = {
anchor: any;
head: any;
};
export function setYjsMark(
doc: Document,
fragment: Y.XmlFragment,
yjsSelection: YjsSelection,
markName: string,
markAttributes: Record<string, any>,
) {
const schema = getSchema(tiptapExtensions);
const { mapping } = initProseMirrorDoc(fragment, schema);
// Convert JSON positions to Y.js RelativePosition objects
const anchorRelPos = Y.createRelativePositionFromJSON(yjsSelection.anchor);
const headRelPos = Y.createRelativePositionFromJSON(yjsSelection.head);
const anchor = relativePositionToAbsolutePosition(
doc,
fragment,
anchorRelPos,
mapping,
);
const head = relativePositionToAbsolutePosition(
doc,
fragment,
headRelPos,
mapping,
);
if (anchor === null || head === null) {
throw new Error(
'Could not resolve Y.js relative positions to absolute positions',
);
}
const from = Math.min(anchor, head);
const to = Math.max(anchor, head);
// Apply mark directly to Y.js XmlText nodes
// This bypasses updateYFragment which has compatibility issues
applyMarkToYFragment(fragment, from, to, markName, markAttributes);
}
function applyMarkToYFragment(
fragment: Y.XmlFragment,
from: number,
to: number,
markName: string,
markAttributes: Record<string, any>,
) {
let pos = 0;
const processItem = (item: any): boolean => {
if (pos >= to) return false;
if (item instanceof Y.XmlText) {
const textLength = item.length;
const itemEnd = pos + textLength;
if (itemEnd > from && pos < to) {
const formatFrom = Math.max(0, from - pos);
const formatTo = Math.min(textLength, to - pos);
const formatLength = formatTo - formatFrom;
if (formatLength > 0) {
item.format(formatFrom, formatLength, { [markName]: markAttributes });
}
}
pos = itemEnd;
} else if (item instanceof Y.XmlElement) {
pos++; // Opening tag
for (let i = 0; i < item.length; i++) {
if (!processItem(item.get(i))) return false;
}
pos++; // Closing tag
}
return true;
};
for (let i = 0; i < fragment.length; i++) {
if (!processItem(fragment.get(i))) break;
}
}
/**
* Removes a mark from all text in the fragment that has the specified attribute value.
* Useful for deleting comments by commentId.
*/
export function removeYjsMarkByAttribute(
fragment: Y.XmlFragment,
markName: string,
attributeName: string,
attributeValue: string,
) {
const processItem = (item: any) => {
if (item instanceof Y.XmlText) {
// Get all formatting deltas to find ranges with this mark
const deltas = item.toDelta();
let offset = 0;
for (const delta of deltas) {
const length = delta.insert?.length ?? 0;
const attributes = delta.attributes ?? {};
const markAttr = attributes[markName];
if (markAttr && markAttr[attributeName] === attributeValue) {
// Remove the mark by setting it to null
item.format(offset, length, { [markName]: null });
}
offset += length;
}
} else if (item instanceof Y.XmlElement) {
for (let i = 0; i < item.length; i++) {
processItem(item.get(i));
}
}
};
for (let i = 0; i < fragment.length; i++) {
processItem(fragment.get(i));
}
}
/**
* Updates a mark's attributes for all text that has the specified attribute value.
* Useful for resolving/unresolving comments by commentId.
*/
export function updateYjsMarkAttribute(
fragment: Y.XmlFragment,
markName: string,
findByAttribute: { name: string; value: string },
newAttributes: Record<string, any>,
) {
const processItem = (item: any) => {
if (item instanceof Y.XmlText) {
const deltas = item.toDelta();
let offset = 0;
for (const delta of deltas) {
const length = delta.insert?.length ?? 0;
const attributes = delta.attributes ?? {};
const markAttr = attributes[markName];
if (
markAttr &&
markAttr[findByAttribute.name] === findByAttribute.value
) {
// Update the mark with new attributes (merge with existing)
item.format(offset, length, {
[markName]: { ...markAttr, ...newAttributes },
});
}
offset += length;
}
} else if (item instanceof Y.XmlElement) {
for (let i = 0; i < item.length; i++) {
processItem(item.get(i));
}
}
};
for (let i = 0; i < fragment.length; i++) {
processItem(fragment.get(i));
}
}
@@ -5,8 +5,9 @@ import {
IsUUID,
ValidateIf,
} from 'class-validator';
import { Transform } from 'class-transformer';
export type InputFormat = 'json' | 'markdown' | 'html';
export type ContentFormat = 'json' | 'markdown' | 'html';
export class CreatePageDto {
@IsOptional()
@@ -28,6 +29,7 @@ export class CreatePageDto {
content?: string | object;
@ValidateIf((o) => o.content !== undefined)
@Transform(({ value }) => value?.toLowerCase() ?? 'json')
@IsIn(['json', 'markdown', 'html'])
input?: InputFormat;
format?: ContentFormat;
}
+7 -5
View File
@@ -6,6 +6,9 @@ import {
IsString,
IsUUID,
} from 'class-validator';
import { Transform } from 'class-transformer';
import { ContentFormat } from './create-page.dto';
export class PageIdDto {
@IsString()
@@ -23,20 +26,19 @@ export class PageHistoryIdDto {
historyId: string;
}
export type OutputFormat = 'json' | 'markdown' | 'html';
export class PageInfoDto extends PageIdDto {
@IsOptional()
@IsBoolean()
includeSpace?: boolean;
includeSpace: boolean;
@IsOptional()
@IsBoolean()
includeContent?: boolean;
includeContent: boolean;
@IsOptional()
@Transform(({ value }) => value?.toLowerCase())
@IsIn(['json', 'markdown', 'html'])
output?: OutputFormat;
format?: ContentFormat;
}
export class DeletePageDto extends PageIdDto {
@@ -1,8 +1,9 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreatePageDto, InputFormat } from './create-page.dto';
import { CreatePageDto, ContentFormat } from './create-page.dto';
import { IsIn, IsOptional, IsString, ValidateIf } from 'class-validator';
import { Transform } from 'class-transformer';
export type ContentOperation = 'append' | 'replace';
export type ContentOperation = 'append' | 'prepend' | 'replace';
export class UpdatePageDto extends PartialType(CreatePageDto) {
@IsString()
@@ -12,10 +13,12 @@ export class UpdatePageDto extends PartialType(CreatePageDto) {
content?: string | object;
@ValidateIf((o) => o.content !== undefined)
@IsIn(['append', 'replace'])
@Transform(({ value }) => value?.toLowerCase())
@IsIn(['append', 'prepend', 'replace'])
operation?: ContentOperation;
@ValidateIf((o) => o.content !== undefined)
@Transform(({ value }) => value?.toLowerCase() ?? 'json')
@IsIn(['json', 'markdown', 'html'])
input?: InputFormat;
format?: ContentFormat;
}
+40 -4
View File
@@ -70,9 +70,9 @@ export class PageController {
throw new ForbiddenException();
}
if (dto.output && dto.output !== 'json' && page.content) {
if (dto.format && dto.format !== 'json' && page.content) {
const contentOutput =
dto.output === 'markdown'
dto.format === 'markdown'
? jsonToMarkdown(page.content)
: jsonToHtml(page.content);
return {
@@ -99,7 +99,25 @@ export class PageController {
throw new ForbiddenException();
}
return this.pageService.create(user.id, workspace.id, createPageDto);
const page = await this.pageService.create(
user.id,
workspace.id,
createPageDto,
);
if (
createPageDto.format &&
createPageDto.format !== 'json' &&
page.content
) {
const contentOutput =
createPageDto.format === 'markdown'
? jsonToMarkdown(page.content)
: jsonToHtml(page.content);
return { ...page, content: contentOutput };
}
return page;
}
@HttpCode(HttpStatus.OK)
@@ -116,7 +134,25 @@ export class PageController {
throw new ForbiddenException();
}
return this.pageService.update(page, updatePageDto, user);
const updatedPage = await this.pageService.update(
page,
updatePageDto,
user,
);
if (
updatePageDto.format &&
updatePageDto.format !== 'json' &&
updatedPage.content
) {
const contentOutput =
updatePageDto.format === 'markdown'
? jsonToMarkdown(updatedPage.content)
: jsonToHtml(updatedPage.content);
return { ...updatedPage, content: contentOutput };
}
return updatedPage;
}
@HttpCode(HttpStatus.OK)
@@ -4,7 +4,7 @@ import {
Logger,
NotFoundException,
} from '@nestjs/common';
import { CreatePageDto, InputFormat } from '../dto/create-page.dto';
import { CreatePageDto, ContentFormat } from '../dto/create-page.dto';
import { ContentOperation, UpdatePageDto } from '../dto/update-page.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
@@ -99,31 +99,11 @@ export class PageService {
let textContent = undefined;
let ydoc = undefined;
if (createPageDto?.content && createPageDto?.input) {
let prosemirrorJson: any;
switch (createPageDto.input) {
case 'markdown': {
const html = await markdownToHtml(createPageDto.content as string);
prosemirrorJson = htmlToJson(html as string);
break;
}
case 'html': {
prosemirrorJson = htmlToJson(createPageDto.content as string);
break;
}
case 'json':
default: {
prosemirrorJson = createPageDto.content;
break;
}
}
try {
jsonToNode(prosemirrorJson);
} catch (err) {
throw new BadRequestException('Invalid content format');
}
if (createPageDto?.content && createPageDto?.format) {
const prosemirrorJson = await this.parseProsemirrorContent(
createPageDto.content,
createPageDto.format,
);
content = prosemirrorJson;
textContent = jsonToText(prosemirrorJson);
@@ -213,13 +193,13 @@ export class PageService {
if (
updatePageDto.content &&
updatePageDto.operation &&
updatePageDto.input
updatePageDto.format
) {
await this.updatePageContent(
page.id,
updatePageDto.content,
updatePageDto.operation,
updatePageDto.input,
updatePageDto.format,
user,
);
}
@@ -237,39 +217,16 @@ export class PageService {
pageId: string,
content: string | object,
operation: ContentOperation,
input: InputFormat,
format: ContentFormat,
user: User,
): Promise<void> {
let prosemirrorJson: any;
switch (input) {
case 'markdown': {
const html = await markdownToHtml(content as string);
prosemirrorJson = htmlToJson(html as string);
break;
}
case 'html': {
prosemirrorJson = htmlToJson(content as string);
break;
}
case 'json':
default: {
prosemirrorJson = content;
break;
}
}
try {
jsonToNode(prosemirrorJson);
} catch (err) {
throw new BadRequestException('Invalid content format');
}
const prosemirrorJson = await this.parseProsemirrorContent(content, format);
const documentName = `page.${pageId}`;
await this.collaborationGateway.handleYjsEvent(
'updatePageContent',
documentName,
{ pageId, operation, prosemirrorJson, user },
{ operation, prosemirrorJson, user },
);
}
@@ -754,4 +711,36 @@ export class PageService {
): Promise<void> {
await this.pageRepo.removePage(pageId, userId, workspaceId);
}
private async parseProsemirrorContent(
content: string | object,
format: ContentFormat,
): Promise<any> {
let prosemirrorJson: any;
switch (format) {
case 'markdown': {
const html = await markdownToHtml(content as string);
prosemirrorJson = htmlToJson(html as string);
break;
}
case 'html': {
prosemirrorJson = htmlToJson(content as string);
break;
}
case 'json':
default: {
prosemirrorJson = content;
break;
}
}
try {
jsonToNode(prosemirrorJson);
} catch (err) {
throw new BadRequestException('Invalid content format');
}
return prosemirrorJson;
}
}