Compare commits

..

31 Commits

Author SHA1 Message Date
Philipinho fe3732add9 responsive highlight button 2026-02-01 23:55:10 +00:00
Philipinho 9db9eb852c fix text shift 2026-02-01 23:43:36 +00:00
Philipinho 3007060ac4 colors 2026-02-01 23:38:45 +00:00
Philipinho ba9e58ede9 remove counter 2026-02-01 19:05:58 +00:00
Philipinho f2bc0b5049 WIP 2026-02-01 18:44:25 +00:00
Philipinho eba5ae2eb3 dry 2026-02-01 18:42:06 +00:00
Philipinho b6344f2e08 mobile scroll 2026-02-01 18:18:07 +00:00
Philipinho db52b6036e add diff to mobile 2026-02-01 18:01:24 +00:00
Philipinho fe5b236d41 WIP 2026-02-01 17:54:21 +00:00
Philipinho b3c5ca6d5f WIP 2026-02-01 17:39:55 +00:00
Philipinho 483e39db1c WIP 2026-02-01 11:16:38 +00:00
Philipinho 56f476649c scroll 2026-02-01 10:52:45 +00:00
Philipinho 1b82959859 type 2026-02-01 10:46:32 +00:00
Philipinho 4d322e9157 atom 2026-02-01 02:16:53 +00:00
Philipinho 20a7acfccc fix scroll 2026-02-01 00:39:29 +00:00
Philipinho 040ad04a27 scroll 2026-02-01 00:15:58 +00:00
Philipinho d3ca1ed72c legible 2026-02-01 00:01:17 +00:00
Philipinho 207b1b593a cleanup diff count 2026-01-31 23:59:40 +00:00
Philipinho 6c664a366f fix flicker 2026-01-31 23:50:55 +00:00
Philipinho 4873f7b9ff prefetch history 2026-01-31 23:33:25 +00:00
Philipinho 718ca2b674 lazy load history 2026-01-31 23:19:56 +00:00
Philipinho e189d01ce0 dev mode history 2026-01-31 22:46:21 +00:00
Philipinho edd3754e46 WIP 2026-01-31 22:44:52 +00:00
Philipinho b2b147f1bd node deletion 2026-01-31 22:15:16 +00:00
Philipinho 129e21f728 fix inline changes in nested nodes 2026-01-31 21:59:11 +00:00
Philipinho 70124475ab nodes 2026-01-31 21:54:22 +00:00
Philipinho 7d7decb459 highlights 2026-01-31 20:03:43 +00:00
Philipinho 1eba6e93cc WIP 3 2026-01-31 19:03:26 +00:00
Philipinho cd52acc415 WIP 2 2026-01-31 19:01:43 +00:00
Philipinho a09e35ba8f WIP 2026-01-31 18:34:25 +00:00
Philipinho 8e2cf9bb02 fix 2026-01-31 16:46:40 +00:00
8 changed files with 197 additions and 49 deletions
@@ -44,21 +44,21 @@ export function HistoryEditor({
if (previousContent) { if (previousContent) {
try { try {
const schema = editor.schema; const schema = editor.schema;
const oldContent = Node.fromJSON(schema, previousContent); const docOld = Node.fromJSON(schema, previousContent);
const newContent = Node.fromJSON(schema, content); const docNew = Node.fromJSON(schema, content);
const tr = recreateTransform(oldContent, newContent, { const tr = recreateTransform(docOld, docNew, {
complexSteps: false, complexSteps: true,
wordDiffs: true, wordDiffs: true,
simplifyDiff: true, simplifyDiff: true,
}); });
const changeSet = ChangeSet.create(oldContent).addSteps( const changeSet = ChangeSet.create(docOld).addSteps(
tr.doc, tr.doc,
tr.mapping.maps, tr.mapping.maps,
[], [],
); );
const changes = simplifyChanges(changeSet.changes, newContent); const changes = simplifyChanges(changeSet.changes, docNew);
editor.commands.setContent(content); editor.commands.setContent(content);
@@ -84,7 +84,7 @@ export function HistoryEditor({
changeIndex++; changeIndex++;
const currentIndex = changeIndex; const currentIndex = changeIndex;
let foundSpecialNode: { node: Node; pos: number } | null = null; let foundSpecialNode: { node: Node; pos: number } | null = null;
newContent.nodesBetween(change.fromB, change.toB, (node, pos) => { docNew.nodesBetween(change.fromB, change.toB, (node, pos) => {
if (specialNodeTypes.has(node.type.name)) { if (specialNodeTypes.has(node.type.name)) {
const nodeEnd = pos + node.nodeSize; const nodeEnd = pos + node.nodeSize;
if (change.fromB <= pos && change.toB >= nodeEnd) { if (change.fromB <= pos && change.toB >= nodeEnd) {
@@ -117,7 +117,7 @@ export function HistoryEditor({
changeIndex++; changeIndex++;
const currentIndex = changeIndex; const currentIndex = changeIndex;
let foundDeletedNode: { node: Node; pos: number } | null = null; let foundDeletedNode: { node: Node; pos: number } | null = null;
oldContent.nodesBetween(change.fromA, change.toA, (node, pos) => { docOld.nodesBetween(change.fromA, change.toA, (node, pos) => {
if (specialNodeTypes.has(node.type.name)) { if (specialNodeTypes.has(node.type.name)) {
const nodeEnd = pos + node.nodeSize; const nodeEnd = pos + node.nodeSize;
if (change.fromA <= pos && change.toA >= nodeEnd) { if (change.fromA <= pos && change.toA >= nodeEnd) {
@@ -140,7 +140,7 @@ export function HistoryEditor({
}), }),
); );
} else { } else {
const deletedText = oldContent.textBetween( const deletedText = docOld.textBetween(
change.fromA, change.fromA,
change.toA, change.toA,
"", "",
@@ -161,7 +161,7 @@ export function HistoryEditor({
} }
} }
decorationSet = DecorationSet.create(newContent, decorations); decorationSet = DecorationSet.create(docNew, decorations);
} catch (e) { } catch (e) {
console.error("History diff failed:", e); console.error("History diff failed:", e);
editor.commands.setContent(content); editor.commands.setContent(content);
@@ -32,9 +32,7 @@ export class HistoryListener {
return; return;
} }
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(page.id, { const lastHistory = await this.pageHistoryRepo.findPageLastHistory(page.id);
includeContent: true,
});
if ( if (
!lastHistory || !lastHistory ||
@@ -215,6 +215,7 @@ export class PageController {
} }
} }
// TODO: scope to workspaces
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('/history') @Post('/history')
async getPageHistory( async getPageHistory(
@@ -9,9 +9,7 @@ export class PageHistoryService {
constructor(private pageHistoryRepo: PageHistoryRepo) {} constructor(private pageHistoryRepo: PageHistoryRepo) {}
async findById(historyId: string): Promise<PageHistory> { async findById(historyId: string): Promise<PageHistory> {
return await this.pageHistoryRepo.findById(historyId, { return await this.pageHistoryRepo.findById(historyId);
includeContent: true,
});
} }
async findHistoryByPageId( async findHistoryByPageId(
@@ -17,32 +17,15 @@ import { DB } from '@docmost/db/types/db';
export class PageHistoryRepo { export class PageHistoryRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {} constructor(@InjectKysely() private readonly db: KyselyDB) {}
private baseFields: Array<keyof PageHistory> = [
'id',
'pageId',
'slugId',
'title',
'icon',
'coverPhoto',
'lastUpdatedById',
'spaceId',
'workspaceId',
'createdAt',
];
async findById( async findById(
pageHistoryId: string, pageHistoryId: string,
opts?: { trx?: KyselyTransaction,
includeContent?: boolean;
trx?: KyselyTransaction;
},
): Promise<PageHistory> { ): Promise<PageHistory> {
const db = dbOrTx(this.db, opts?.trx); const db = dbOrTx(this.db, trx);
return await db return await db
.selectFrom('pageHistory') .selectFrom('pageHistory')
.select(this.baseFields) .selectAll()
.$if(opts?.includeContent, (qb) => qb.select('content'))
.select((eb) => this.withLastUpdatedBy(eb)) .select((eb) => this.withLastUpdatedBy(eb))
.where('id', '=', pageHistoryId) .where('id', '=', pageHistoryId)
.executeTakeFirst(); .executeTakeFirst();
@@ -80,7 +63,7 @@ export class PageHistoryRepo {
async findPageHistoryByPageId(pageId: string, pagination: PaginationOptions) { async findPageHistoryByPageId(pageId: string, pagination: PaginationOptions) {
const query = this.db const query = this.db
.selectFrom('pageHistory') .selectFrom('pageHistory')
.select(this.baseFields) .selectAll()
.select((eb) => this.withLastUpdatedBy(eb)) .select((eb) => this.withLastUpdatedBy(eb))
.where('pageId', '=', pageId); .where('pageId', '=', pageId);
@@ -93,19 +76,12 @@ export class PageHistoryRepo {
}); });
} }
async findPageLastHistory( async findPageLastHistory(pageId: string, trx?: KyselyTransaction) {
pageId: string, const db = dbOrTx(this.db, trx);
opts?: {
includeContent?: boolean;
trx?: KyselyTransaction;
},
) {
const db = dbOrTx(this.db, opts?.trx);
return await db return await db
.selectFrom('pageHistory') .selectFrom('pageHistory')
.select(this.baseFields) .selectAll()
.$if(opts?.includeContent, (qb) => qb.select('content'))
.where('pageId', '=', pageId) .where('pageId', '=', pageId)
.limit(1) .limit(1)
.orderBy('createdAt', 'desc') .orderBy('createdAt', 'desc')
@@ -0,0 +1,145 @@
# prosemirror-changeset
This is a helper module that can turn a sequence of document changes
into a set of insertions and deletions, for example to display them in
a change-tracking interface. Such a set can be built up incrementally,
in order to do such change tracking in a halfway performant way during
live editing.
This code is licensed under an [MIT
licence](https://github.com/ProseMirror/prosemirror-changeset/blob/master/LICENSE).
## Programming interface
Insertions and deletions are represented as spans’—ranges in the
document. The deleted spans refer to the original document, whereas
the inserted ones point into the current document.
It is possible to associate arbitrary data values with such spans, for
example to track the user that made the change, the timestamp at which
it was made, or the step data necessary to invert it again.
### class Change`<Data = any>`
A replaced range with metadata associated with it.
* **`fromA`**`: number`\
The start of the range deleted/replaced in the old document.
* **`toA`**`: number`\
The end of the range in the old document.
* **`fromB`**`: number`\
The start of the range inserted in the new document.
* **`toB`**`: number`\
The end of the range in the new document.
* **`deleted`**`: readonly Span[]`\
Data associated with the deleted content. The length of these
spans adds up to `this.toA - this.fromA`.
* **`inserted`**`: readonly Span[]`\
Data associated with the inserted content. Length adds up to
`this.toB - this.fromB`.
* `static `**`merge`**`<Data>(x: readonly Change[], y: readonly Change[], combine: fn(dataA: Data, dataB: Data) → Data) → readonly Change[]`\
This merges two changesets (the end document of x should be the
start document of y) into a single one spanning the start of x to
the end of y.
### class Span`<Data = any>`
Stores metadata for a part of a change.
* **`length`**`: number`\
The length of this span.
* **`data`**`: Data`\
The data associated with this span.
### class ChangeSet`<Data = any>`
A change set tracks the changes to a document from a given point
in the past. It condenses a number of step maps down to a flat
sequence of replacements, and simplifies replacments that
partially undo themselves by comparing their content.
* **`changes`**`: readonly Change[]`\
Replaced regions.
* **`addSteps`**`(newDoc: Node, maps: readonly StepMap[], data: Data | readonly Data[]) → ChangeSet`\
Computes a new changeset by adding the given step maps and
metadata (either as an array, per-map, or as a single value to be
associated with all maps) to the current set. Will not mutate the
old set.
Note that due to simplification that happens after each add,
incrementally adding steps might create a different final set
than adding all those changes at once, since different document
tokens might be matched during simplification depending on the
boundaries of the current changed ranges.
* **`startDoc`**`: Node`\
The starting document of the change set.
* **`map`**`(f: fn(range: Span) → Data) → ChangeSet`\
Map the span's data values in the given set through a function
and construct a new set with the resulting data.
* **`changedRange`**`(b: ChangeSet, maps?: readonly StepMap[]) → {from: number, to: number}`\
Compare two changesets and return the range in which they are
changed, if any. If the document changed between the maps, pass
the maps for the steps that changed it as second argument, and
make sure the method is called on the old set and passed the new
set. The returned positions will be in new document coordinates.
* `static `**`create`**`<Data = any>(doc: Node, combine?: fn(dataA: Data, dataB: Data) → Data = (a, b) => a === b ? a : null as any, tokenEncoder?: TokenEncoder = DefaultEncoder) → ChangeSet`\
Create a changeset with the given base object and configuration.
The `combine` function is used to compare and combine metadata—it
should return null when metadata isn't compatible, and a combined
version for a merged range when it is.
When given, a token encoder determines how document tokens are
serialized and compared when diffing the content produced by
changes. The default is to just compare nodes by name and text
by character, ignoring marks and attributes.
* **`simplifyChanges`**`(changes: readonly Change[], doc: Node) → Change[]`\
Simplifies a set of changes for presentation. This makes the
assumption that having both insertions and deletions within a word
is confusing, and, when such changes occur without a word boundary
between them, they should be expanded to cover the entire set of
words (in the new document) they touch. An exception is made for
single-character replacements.
### interface TokenEncoder`<T>`
A token encoder can be passed when creating a `ChangeSet` in order
to influence the way the library runs its diffing algorithm. The
encoder determines how document tokens (such as nodes and
characters) are encoded and compared.
Note that both the encoding and the comparison may run a lot, and
doing non-trivial work in these functions could impact
performance.
* **`encodeCharacter`**`(char: number, marks: readonly Mark[]) → T`\
Encode a given character, with the given marks applied.
* **`encodeNodeStart`**`(node: Node) → T`\
Encode the start of a node or, if this is a leaf node, the
entire node.
* **`encodeNodeEnd`**`(node: Node) → T`\
Encode the end token for the given node. It is valid to encode
every end token in the same way.
* **`compareTokens`**`(a: T, b: T) → boolean`\
Compare the given tokens. Should return true when they count as
equal.
@@ -0,0 +1,30 @@
# prosemirror-recreate-transform
> reduced and modified fork of https://gitlab.com/mpapp-public/prosemirror-recreate-steps
This is a non-core module of [ProseMirror](http://prosemirror.net).
ProseMirror is a well-behaved rich semantic content editor based on
contentEditable, with support for collaborative editing and custom
document schemas.
Every change to the document is recorded by ProseMirror as a step.
This module allows recreating the steps needed to go from document
A to B should these not be available otherwise. Recreating steps
can be interesting for example in order to show the changes between
two document versions without having access to the original steps.
Recreating a `Transform` works this way:
```js
import { recreateTransform } from "@technik-sde/prosemirror-recreate-transform";
let tr = recreateTransform(
startDoc,
endDoc,
{
complexSteps: true, // Whether step types other than ReplaceStep are allowed.
wordDiffs: false, // Whether diffs in text nodes should cover entire words.
simplifyDiffs: true // Whether steps should be merged, where possible
}
);
```
@@ -1,4 +1,4 @@
// https://gitlab.com/mpapp-public/prosemirror-recreate-steps - MIT // https://gitlab.com/mpapp-public/prosemirror-recreate-steps
// https://github.com/sueddeutsche/prosemirror-recreate-transform - MIT // https://github.com/sueddeutsche/prosemirror-recreate-transform
export { recreateTransform, RecreateTransform } from "./recreateTransform"; export { recreateTransform, RecreateTransform } from "./recreateTransform";
export type { Options } from "./recreateTransform"; export type { Options } from "./recreateTransform";