Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
779 changes: 75 additions & 704 deletions apps/snow-leopard/components/document/text-editor.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
ACTIVATE_SUGGESTION_CONTEXT,
DEACTIVATE_SUGGESTION_CONTEXT,
SET_SUGGESTION_LOADING_STATE
} from '@/lib/editor/selection-context-plugin';
} from '@/lib/editor/suggestion-plugin';

interface SuggestionOverlayContextType {
openSuggestionOverlay: (options: {
Expand Down
29 changes: 0 additions & 29 deletions apps/snow-leopard/lib/editor/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,8 @@ import { textblockTypeInputRule } from 'prosemirror-inputrules';
import { Schema } from 'prosemirror-model';
import { schema } from 'prosemirror-schema-basic';
import { addListNodes } from 'prosemirror-schema-list';
import type { Transaction } from 'prosemirror-state';
import type { EditorView } from 'prosemirror-view';
import type { MutableRefObject } from 'react';
import OrderedMap from 'orderedmap';
import { DiffType } from './diff';

import { buildContentFromDocument } from './functions';

const diffMarkSpec = {
Expand Down Expand Up @@ -43,28 +39,3 @@ export function headingRule(level: number) {
() => ({ level }),
);
}

export const handleTransaction = ({
transaction,
editorRef,
onSaveContent,
}: {
transaction: Transaction;
editorRef: MutableRefObject<EditorView | null>;
onSaveContent: (updatedContent: string, debounce: boolean) => void;
}) => {
if (!editorRef || !editorRef.current) return;

const newState = editorRef.current.state.apply(transaction);
editorRef.current.updateState(newState);

if (transaction.docChanged && !transaction.getMeta('no-save')) {
const updatedContent = buildContentFromDocument(newState.doc);

if (transaction.getMeta('no-debounce')) {
onSaveContent(updatedContent, false);
} else {
onSaveContent(updatedContent, true);
}
}
};
119 changes: 119 additions & 0 deletions apps/snow-leopard/lib/editor/diff-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { Plugin, PluginKey } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { buildContentFromDocument, buildDocumentFromContent } from './functions';
import { diffEditor } from './diff';
import { documentSchema } from './config';

export const diffPluginKey = new PluginKey('diff');

export function diffPlugin(documentId: string): Plugin {
let previewOriginalContentRef: string | null = null;
let previewActiveRef: boolean = false;
let lastPreviewContentRef: string | null = null;

return new Plugin({
key: diffPluginKey,
view(editorView: EditorView) {
const handlePreviewUpdate = (event: CustomEvent) => {
if (!event.detail) return;
const { documentId: previewDocId, newContent } = event.detail;
if (previewDocId !== documentId) return;

if (lastPreviewContentRef === newContent) return;

if (!previewActiveRef) {
previewOriginalContentRef = buildContentFromDocument(editorView.state.doc);
}

const oldContent = previewOriginalContentRef ?? buildContentFromDocument(editorView.state.doc);
if (newContent === oldContent) return;

const oldDocNode = buildDocumentFromContent(oldContent);
const newDocNode = buildDocumentFromContent(newContent);

const diffedDoc = diffEditor(documentSchema, oldDocNode.toJSON(), newDocNode.toJSON());

const tr = editorView.state.tr
.replaceWith(0, editorView.state.doc.content.size, diffedDoc.content)
.setMeta('external', true)
.setMeta('addToHistory', false);

requestAnimationFrame(() => editorView.dispatch(tr));

previewActiveRef = true;
lastPreviewContentRef = newContent;
};

const handleCancelPreview = (event: CustomEvent) => {
if (!event.detail) return;
const { documentId: cancelDocId } = event.detail;
if (cancelDocId !== documentId) return;
if (!previewActiveRef || previewOriginalContentRef === null) return;

const originalDocNode = buildDocumentFromContent(previewOriginalContentRef);
const tr = editorView.state.tr.replaceWith(0, editorView.state.doc.content.size, originalDocNode.content);
editorView.dispatch(tr);

previewActiveRef = false;
previewOriginalContentRef = null;
lastPreviewContentRef = null;
};

const handleApply = (event: CustomEvent) => {
if (!event.detail) return;
const { documentId: applyDocId } = event.detail;
if (applyDocId !== documentId) return;

const animationDuration = 500;

const finalizeApply = async () => {
const { state } = editorView;
let tr = state.tr;
const diffMarkType = state.schema.marks.diffMark;
const { DiffType } = await import('./diff');

const rangesToDelete: { from: number; to: number }[] = [];
state.doc.descendants((node, pos) => {
if (!node.isText) return;

const deletedMark = node.marks.find(
(mark) => mark.type === diffMarkType && mark.attrs.type === DiffType.Deleted
);
if (deletedMark) {
rangesToDelete.push({ from: pos, to: pos + node.nodeSize });
}
});

for (let i = rangesToDelete.length - 1; i >= 0; i--) {
const { from, to } = rangesToDelete[i];
tr.delete(from, to);
}

tr.removeMark(0, tr.doc.content.size, diffMarkType);
tr.setMeta('addToHistory', false);
editorView.dispatch(tr);
editorView.dom.classList.remove('applying-changes');

previewActiveRef = false;
previewOriginalContentRef = null;
lastPreviewContentRef = null;
};

editorView.dom.classList.add('applying-changes');
setTimeout(finalizeApply, animationDuration);
};

window.addEventListener('preview-document-update', handlePreviewUpdate as EventListener);
window.addEventListener('cancel-document-update', handleCancelPreview as EventListener);
window.addEventListener('apply-document-update', handleApply as EventListener);

return {
destroy() {
window.removeEventListener('preview-document-update', handlePreviewUpdate as EventListener);
window.removeEventListener('cancel-document-update', handleCancelPreview as EventListener);
window.removeEventListener('apply-document-update', handleApply as EventListener);
},
};
},
});
}
44 changes: 44 additions & 0 deletions apps/snow-leopard/lib/editor/editor-plugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { exampleSetup } from 'prosemirror-example-setup';
import { inputRules } from 'prosemirror-inputrules';
import { Plugin } from 'prosemirror-state';

import { documentSchema, headingRule } from './config';
import { creationStreamingPlugin } from './creation-streaming-plugin';
import { placeholderPlugin } from './placeholder-plugin';
import { inlineSuggestionPlugin } from './inline-suggestion-plugin';
import { selectionContextPlugin } from './suggestion-plugin';
import { synonymsPlugin } from './synonym-plugin';
import { diffPlugin } from './diff-plugin';
import { formatPlugin } from './format-plugin';
import { savePlugin } from './save-plugin';

export interface EditorPluginOptions {
documentId: string;
initialLastSaved: Date | null;
placeholder?: string;
performSave: (content: string) => Promise<any>;
requestInlineSuggestion: (state: any) => void;
setActiveFormats: (formats: any) => void;
}

export function createEditorPlugins(opts: EditorPluginOptions): Plugin[] {
return [
creationStreamingPlugin(opts.documentId),
placeholderPlugin(opts.placeholder ?? (opts.documentId === 'init' ? 'Start typing' : 'Start typing...')),
...exampleSetup({ schema: documentSchema, menuBar: false }),
inputRules({
rules: [1, 2, 3, 4, 5, 6].map((level) => headingRule(level)),
}),
inlineSuggestionPlugin({ requestSuggestion: opts.requestInlineSuggestion }),
selectionContextPlugin(opts.documentId),
synonymsPlugin(),
diffPlugin(opts.documentId),
formatPlugin(opts.setActiveFormats),
savePlugin({
saveFunction: opts.performSave,
initialLastSaved: opts.initialLastSaved,
debounceMs: 200,
documentId: opts.documentId,
}),
];
}
88 changes: 88 additions & 0 deletions apps/snow-leopard/lib/editor/format-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { Plugin, PluginKey, EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { documentSchema } from './config';

const { nodes, marks } = documentSchema;

export interface FormatState {
h1: boolean;
h2: boolean;
p: boolean;
bulletList: boolean;
orderedList: boolean;
bold: boolean;
italic: boolean;
}

export const formatPluginKey = new PluginKey<FormatState>('format');

function isMarkActive(state: EditorState, type: any): boolean {
const { from, $from, to, empty } = state.selection;
if (empty) {
return !!type.isInSet(state.storedMarks || $from.marks());
} else {
return state.doc.rangeHasMark(from, to, type);
}
}

function isBlockActive(state: EditorState, type: any, attrs: Record<string, any> = {}): boolean {
const { $from } = state.selection;
const node = $from.node($from.depth);
return node?.hasMarkup(type, attrs);
}

function isListActive(state: EditorState, type: any): boolean {
const { $from } = state.selection;
for (let d = $from.depth; d > 0; d--) {
if ($from.node(d).type === type) {
return true;
}
}
return false;
}

function getActiveFormats(state: EditorState): FormatState {
return {
h1: isBlockActive(state, nodes.heading, { level: 1 }),
h2: isBlockActive(state, nodes.heading, { level: 2 }),
p: isBlockActive(state, nodes.paragraph),
bulletList: isListActive(state, nodes.bullet_list),
orderedList: isListActive(state, nodes.ordered_list),
bold: isMarkActive(state, marks.strong),
italic: isMarkActive(state, marks.em),
};
}

export function formatPlugin(onFormatChange: (formats: FormatState) => void): Plugin<FormatState> {
return new Plugin<FormatState>({
key: formatPluginKey,
state: {
init(_, state): FormatState {
return getActiveFormats(state);
},
apply(tr, pluginState, oldState, newState): FormatState {
if (tr.selectionSet || tr.docChanged) {
return getActiveFormats(newState);
}
return pluginState;
},
},
view(editorView: EditorView) {
const initialState = formatPluginKey.getState(editorView.state);
if (initialState) {
onFormatChange(initialState);
}

return {
update(view: EditorView, prevState: EditorState) {
const newState = formatPluginKey.getState(view.state);
const oldState = formatPluginKey.getState(prevState);

if (newState && oldState && newState !== oldState) {
onFormatChange(newState);
}
},
};
},
});
}
Loading