diff --git a/package-lock.json b/package-lock.json index de08c8b..dfe0d84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rete-history-plugin", - "version": "2.1.1", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rete-history-plugin", - "version": "2.1.1", + "version": "2.2.0", "license": "MIT", "dependencies": { "@babel/runtime": "^7.21.0" @@ -16,11 +16,18 @@ "rete": "^2.0.1", "rete-area-plugin": "^2.0.0", "rete-cli": "~2.0.1", + "rete-comment-plugin": "^2.2.0", "typescript": "~4.8.4" }, "peerDependencies": { "rete": "^2.0.1", - "rete-area-plugin": "^2.0.0" + "rete-area-plugin": "^2.0.0", + "rete-comment-plugin": "^2.2.0" + }, + "peerDependenciesMeta": { + "rete-comment-plugin": { + "optional": true + } } }, "node_modules/@ampproject/remapping": { @@ -6432,10 +6439,11 @@ } }, "node_modules/rete-area-plugin": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/rete-area-plugin/-/rete-area-plugin-2.1.0.tgz", - "integrity": "sha512-xiqCaxh6AHh9oS2p/gJuI7c3bW4mHj8KsT41jYrvH+b7aZ5DKnxdfq10EFQbfdTwK+kgZiCMM2jVzBQ8Qju2Dg==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/rete-area-plugin/-/rete-area-plugin-2.3.2.tgz", + "integrity": "sha512-s7OrCz6xNwVl8EaJ7tXnJVQD9nbd+JAnf82qx/UDweRV86Q1qj2+nT8XkD5m1gSnvSZQqTlMptSiWUrouTEO+w==", "dev": true, + "license": "MIT", "dependencies": { "@babel/runtime": "^7.21.0" }, @@ -6501,6 +6509,20 @@ "node": ">=14.17" } }, + "node_modules/rete-comment-plugin": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/rete-comment-plugin/-/rete-comment-plugin-2.2.0.tgz", + "integrity": "sha512-jrC52O5tCaHPI/IGximliy9Pv0NEZnWoJkEQ2/aaYNg9Cv6XvhmMy8tSExUM3CFhYbgNpzeRYywhffm5wJ7DlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "peerDependencies": { + "rete": "^2.0.1", + "rete-area-plugin": "^2.1.5" + } + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", diff --git a/package.json b/package.json index 1ec1065..d96cdad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rete-history-plugin", - "version": "2.1.1", + "version": "2.2.0", "description": "", "scripts": { "build": "rete build -c rete.config.ts", @@ -25,13 +25,20 @@ }, "peerDependencies": { "rete": "^2.0.1", - "rete-area-plugin": "^2.0.0" + "rete-area-plugin": "^2.0.0", + "rete-comment-plugin": "^2.2.0" + }, + "peerDependenciesMeta": { + "rete-comment-plugin": { + "optional": true + } }, "devDependencies": { "globals": "^15.9.0", "rete": "^2.0.1", "rete-area-plugin": "^2.0.0", "rete-cli": "~2.0.1", + "rete-comment-plugin": "^2.2.0", "typescript": "~4.8.4" }, "dependencies": { diff --git a/rete.config.ts b/rete.config.ts index 0ed9fe7..3e3c5a4 100644 --- a/rete.config.ts +++ b/rete.config.ts @@ -6,6 +6,7 @@ export default { name: 'ReteHistoryPlugin', globals: { 'rete': 'Rete', - 'rete-area-plugin': 'ReteAreaPlugin' + 'rete-area-plugin': 'ReteAreaPlugin', + 'rete-comment-plugin': 'ReteCommentPlugin' } } diff --git a/src/history.ts b/src/history.ts index d387ab0..e0c1e16 100644 --- a/src/history.ts +++ b/src/history.ts @@ -35,6 +35,22 @@ export default class History { return list } + removeRecent(predicate: (record: HistoryRecord) => boolean, ms: number) { + const treshold = Date.now() - ms + + for (let i = this.produced.length - 1; i >= 0; i--) { + const record = this.produced[i] + + if (record.time <= treshold) break + if (record.separated) break + + if (predicate(record)) { + this.produced.splice(i, 1) + return record + } + } + } + async move(from: HistoryRecord[], to: HistoryRecord[], type: 'undo' | 'redo') { const record = from.pop() diff --git a/src/index.ts b/src/index.ts index f5938b9..bf73963 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ export type { Action as HistoryAction } export * as HistoryExtensions from './extensions' export * as Presets from './presets' export type { HistoryActions } from './presets/classic' +export type { CommentHistoryActions } from './presets/comments' export type { Preset } from './presets/types' /** @@ -85,6 +86,13 @@ export class HistoryPlugin boolean, ms?: number) { + return this.history.removeRecent(predicate, ms ?? this.timing * 2) + } + /** * Clear history */ diff --git a/src/presets/comments/actions/comment.ts b/src/presets/comments/actions/comment.ts new file mode 100644 index 0000000..b72879d --- /dev/null +++ b/src/presets/comments/actions/comment.ts @@ -0,0 +1,221 @@ +import { BaseSchemes, NodeId } from 'rete' +import { BaseAreaPlugin } from 'rete-area-plugin' +import { + Comment, + CommentPlugin, + CommentRestoreData, + FrameBounds, + FrameComment +} from 'rete-comment-plugin' + +import { Action } from '../../../types' + +export type CommentSnapshot = CommentRestoreData + +export type FrameState = FrameBounds & { links: string[] } + +type Position = { x: number, y: number } + +type LinkedNodeSnapshot = { id: string, prev: Position, new: Position } + +export function commentSnapshot(comment: Comment): CommentSnapshot { + return { + id: comment.id, + text: comment.text, + x: comment.x, + y: comment.y, + width: comment.width, + height: comment.height, + links: [...comment.links], + kind: comment instanceof FrameComment + ? 'frame' + : 'inline' + } +} + +export class AddCommentAction implements Action { + private stored?: CommentSnapshot + + constructor( + private comment: CommentPlugin, + private commentId: string + ) { } + + undo() { + const item = this.comment.comments.get(this.commentId) + + if (!item) return + + this.stored = commentSnapshot(item) + this.comment.delete(this.commentId) + } + + redo() { + if (!this.stored) return + + this.comment.restoreComment(this.stored) + } +} + +export class RemoveCommentAction implements Action { + constructor( + private comment: CommentPlugin, + private stored: CommentSnapshot + ) { } + + undo() { + this.comment.restoreComment(this.stored) + } + + redo() { + this.comment.delete(this.stored.id) + } +} + +export class DragCommentAction implements Action { + prev: Position + new: Position + prevLinks: string[] + newLinks: string[] + private prevFrame?: FrameState + private newFrame?: FrameState + private nodes: LinkedNodeSnapshot[] + + constructor( + private comment: CommentPlugin, + private area: BaseAreaPlugin, + public commentId: string, + prev: Position, + current: Position, + prevLinks: string[], + newLinks: string[], + nodes?: { id: string, previous: Position, current: Position }[] + ) { + this.prev = { ...prev } + this.new = { ...current } + this.prevLinks = [...prevLinks] + this.newLinks = [...newLinks] + this.nodes = (nodes ?? []).map(({ id, previous, current: next }) => ({ + id, + prev: { ...previous }, + new: { ...next } + })) + + const item = comment.comments.get(commentId) + + // Frame drag changes position only; bounds stay until membership/resize elsewhere + if (item instanceof FrameComment) { + this.prevFrame = { + x: prev.x, + y: prev.y, + width: item.width, + height: item.height, + links: [...prevLinks] + } + this.newFrame = { + x: item.x, + y: item.y, + width: item.width, + height: item.height, + links: [...newLinks] + } + } + } + + private async applyInline(position: Position, links: string[]) { + const item = this.comment.comments.get(this.commentId) + + if (!item) return + + await this.comment.translate(this.commentId, position.x - item.x, position.y - item.y) + await this.comment.setCommentLinks(this.commentId, links) + } + + private async applyFrame(frame: FrameState, nodeTarget: 'prev' | 'new') { + this.comment.setFrameState(this.commentId, frame) + + for (const node of this.nodes) { + const position = nodeTarget === 'prev' + ? node.prev + : node.new + const view = this.area.nodeViews.get(node.id) + + if (view) await view.translate(position.x, position.y) + } + } + + async undo() { + if (this.prevFrame) { + await this.applyFrame(this.prevFrame, 'prev') + return + } + + await this.applyInline(this.prev, this.prevLinks) + } + + async redo() { + if (this.newFrame) { + await this.applyFrame(this.newFrame, 'new') + return + } + + await this.applyInline(this.new, this.newLinks) + } +} + +export class NodeFrameMembershipAction implements Action { + constructor( + private comment: CommentPlugin, + private frames: { + id: string + prev: FrameState + next: FrameState + }[], + private node: { id: NodeId, prev: Position, new: Position } | null + ) { } + + async undo() { + await this.comment.restoreNodeFrameMembership({ + frames: this.frames.map(frame => ({ id: frame.id, state: frame.prev })), + ...this.node + ? { node: { id: this.node.id, position: this.node.prev } } + : {} + }) + } + + async redo() { + await this.comment.restoreNodeFrameMembership({ + frames: this.frames.map(frame => ({ id: frame.id, state: frame.next })), + ...this.node + ? { node: { id: this.node.id, position: this.node.new } } + : {} + }) + } +} + +export class EditCommentAction implements Action { + constructor( + private comment: CommentPlugin, + private commentId: string, + private previousText: string, + private newText: string + ) { } + + undo() { + const item = this.comment.comments.get(this.commentId) + + if (!item) return + + item.text = this.previousText + item.update() + } + + redo() { + const item = this.comment.comments.get(this.commentId) + + if (!item) return + + item.text = this.newText + item.update() + } +} diff --git a/src/presets/comments/index.ts b/src/presets/comments/index.ts new file mode 100644 index 0000000..239586e --- /dev/null +++ b/src/presets/comments/index.ts @@ -0,0 +1,210 @@ +import { BaseSchemes } from 'rete' +import { BaseAreaPlugin } from 'rete-area-plugin' +import { CommentPlugin, FrameComment, FrameMembership } from 'rete-comment-plugin' + +import { HistoryPlugin } from '../..' +import { DragNodeAction } from '../classic/actions/node' +import { Preset } from '../types' +import { + AddCommentAction, + commentSnapshot, + DragCommentAction, + EditCommentAction, + FrameState, + NodeFrameMembershipAction, + RemoveCommentAction +} from './actions/comment' + +export type { CommentSnapshot, FrameState } from './actions/comment' +export { + AddCommentAction, + commentSnapshot, + DragCommentAction, + EditCommentAction, + NodeFrameMembershipAction, + RemoveCommentAction +} + +export type CommentHistoryActions = + | AddCommentAction + | RemoveCommentAction + | DragCommentAction + | NodeFrameMembershipAction + | EditCommentAction + +function toFrameState(bounds: FrameMembership['previous'], links: string[]): FrameState { + return { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + links: [...links] + } +} + +type Position = { x: number, y: number } + +function frameDragNodes( + item: FrameComment, + previous: Position, + area: BaseAreaPlugin +) { + const dx = item.x - previous.x + const dy = item.y - previous.y + + return item.links + .map(id => { + const view = area.nodeViews.get(id) + + if (!view) return null + + return { + id, + previous: { x: view.position.x - dx, y: view.position.y - dy }, + current: { ...view.position } + } + }) + .filter((node): node is { id: string, previous: Position, current: Position } => Boolean(node)) +} + +function absorbAllDragNodeActions( + history: HistoryPlugin, + nodeId: string, + timing: number +) { + while (history.removeRecent(record => { + const action = record.action + + return action instanceof DragNodeAction && action.nodeId === nodeId + }, timing)) { /* remove recent classic drag actions for the same node */ } +} + +function trackCreated( + history: HistoryPlugin, + comment: CommentPlugin +) { + comment.addPipe(context => { + if (context.type === 'commentcreated') { + history.add(new AddCommentAction(comment, context.data.id)) + } + return context + }) +} + +function trackRemoved( + history: HistoryPlugin, + comment: CommentPlugin +) { + comment.addPipe(context => { + if (context.type === 'commentremoved') { + history.add(new RemoveCommentAction(comment, commentSnapshot(context.data))) + } + return context + }) +} + +function trackEdited( + history: HistoryPlugin, + comment: CommentPlugin +) { + comment.addPipe(context => { + if (context.type === 'commentedited') { + const { comment: item, previousText } = context.data + + history.add(new EditCommentAction(comment, item.id, previousText, item.text)) + } + return context + }) +} + +function trackDragged( + history: HistoryPlugin, + comment: CommentPlugin, + area: BaseAreaPlugin +) { + comment.addPipe(context => { + if (context.type !== 'commentdragged') return context + + const { id, previous, prevLinks } = context.data + const item = comment.comments.get(id) + + if (!item) return context + + const newLinks = [...item.links] + const moved = previous.x !== item.x || previous.y !== item.y + const relinked = prevLinks.length !== newLinks.length + || prevLinks.some((linkId: string, index: number) => linkId !== newLinks[index]) + + if (moved || relinked) { + const nodes = item instanceof FrameComment + ? frameDragNodes(item, previous, area) + : [] + + history.add(new DragCommentAction( + comment, + area, + id, + previous, + { x: item.x, y: item.y }, + prevLinks, + newLinks, + nodes + )) + } + + return context + }) +} + +function trackMembershipChanged( + history: HistoryPlugin, + comment: CommentPlugin, + timing: number +) { + comment.addPipe(context => { + if (context.type !== 'commentmembershipchanged') return context + + const { node, frames } = context.data + + if (node) absorbAllDragNodeActions(history, node.id, timing) + + const nodeSnapshot = node + ? { id: node.id, prev: node.previous, new: node.current } + : null + + history.add(new NodeFrameMembershipAction( + comment, + frames.map((frame: FrameMembership) => ({ + id: frame.id, + prev: toFrameState(frame.previous, frame.links.prev), + next: toFrameState(frame.current, frame.links.next) + })), + nodeSnapshot + )) + + return context + }) +} + +/** + * Comments preset for the history plugin. Tracks comment add/remove/drag/edit. + * + * Register together with `Presets.classic.setup()` so node drags that change frame + * membership replace the classic `DragNodeAction` with `NodeFrameMembershipAction`. + */ +export function setup(props: { + comment: CommentPlugin +}): Preset { + return { + connect(history) { + const area = history.parentScope>(BaseAreaPlugin) + const timing = history.timing * 2 + + trackCreated(history, props.comment) + trackRemoved(history, props.comment) + trackEdited(history, props.comment) + trackDragged(history, props.comment, area) + trackMembershipChanged(history, props.comment, timing) + } + } +} diff --git a/src/presets/index.ts b/src/presets/index.ts index 20d6ab0..ff1c6d3 100644 --- a/src/presets/index.ts +++ b/src/presets/index.ts @@ -4,3 +4,4 @@ */ export * as classic from './classic' +export * as comments from './comments'