a tool for shared writing and social publishing
1import { BlockProps } from "../Block";
2import { focusBlock } from "src/utils/focusBlock";
3import { EditorView } from "prosemirror-view";
4import { generateKeyBetween } from "fractional-indexing";
5import { baseKeymap, setBlockType, toggleMark } from "prosemirror-commands";
6import { keymap } from "prosemirror-keymap";
7import {
8 Command,
9 EditorState,
10 TextSelection,
11 Transaction,
12} from "prosemirror-state";
13import { RefObject } from "react";
14import { Replicache } from "replicache";
15import type { Fact, ReplicacheMutators } from "src/replicache";
16import { elementId } from "src/utils/elementId";
17import { schema } from "./schema";
18import { useUIState } from "src/useUIState";
19import { setEditorState, useEditorStates } from "src/state/useEditorState";
20import { focusPage } from "src/utils/focusPage";
21import { v7 } from "uuid";
22import { scanIndex } from "src/replicache/utils";
23import { indent, outdent } from "src/utils/list-operations";
24import { getBlocksWithType } from "src/replicache/getBlocks";
25import { isTextBlock } from "src/utils/isTextBlock";
26import { UndoManager } from "src/undoManager";
27type PropsRef = RefObject<
28 BlockProps & {
29 entity_set: { set: string };
30 alignment: Fact<"block/text-alignment">["data"]["value"];
31 }
32>;
33export const TextBlockKeymap = (
34 propsRef: PropsRef,
35 repRef: RefObject<Replicache<ReplicacheMutators> | null>,
36 um: UndoManager,
37 openMentionAutocomplete: () => void,
38) =>
39 ({
40 "Meta-b": toggleMark(schema.marks.strong),
41 "Ctrl-b": toggleMark(schema.marks.strong),
42 "Meta-u": toggleMark(schema.marks.underline),
43 "Ctrl-u": toggleMark(schema.marks.underline),
44 "Meta-i": toggleMark(schema.marks.em),
45 "Ctrl-i": toggleMark(schema.marks.em),
46 "Ctrl-Meta-x": toggleMark(schema.marks.strikethrough),
47 "Ctrl-Meta-h": (...args) => {
48 return toggleMark(schema.marks.highlight, {
49 color: useUIState.getState().lastUsedHighlight,
50 })(...args);
51 },
52 "Ctrl-a": metaA(propsRef, repRef),
53 "Meta-a": metaA(propsRef, repRef),
54 Escape: (_state, _dispatch, view) => {
55 view?.dom.blur();
56 useUIState.setState(() => ({
57 focusedEntity: {
58 entityType: "page",
59 entityID: propsRef.current.parent,
60 },
61 selectedBlocks: [],
62 }));
63
64 return false;
65 },
66 "Shift-ArrowDown": (state, _dispatch, view) => {
67 if (
68 state.doc.content.size - 1 === state.selection.from ||
69 state.doc.content.size - 1 === state.selection.to
70 ) {
71 if (propsRef.current.nextBlock) {
72 useUIState
73 .getState()
74 .setSelectedBlocks([propsRef.current, propsRef.current.nextBlock]);
75 useUIState.getState().setFocusedBlock({
76 entityType: "block",
77 entityID: propsRef.current.nextBlock.value,
78 parent: propsRef.current.parent,
79 });
80
81 document.getSelection()?.removeAllRanges();
82 view?.dom.blur();
83 return true;
84 }
85 }
86 return false;
87 },
88 "Shift-ArrowUp": (state, _dispatch, view) => {
89 if (state.selection.from <= 1 || state.selection.to <= 1) {
90 if (propsRef.current.previousBlock) {
91 useUIState
92 .getState()
93 .setSelectedBlocks([
94 propsRef.current,
95 propsRef.current.previousBlock,
96 ]);
97 useUIState.getState().setFocusedBlock({
98 entityType: "block",
99 entityID: propsRef.current.previousBlock.value,
100 parent: propsRef.current.parent,
101 });
102
103 document.getSelection()?.removeAllRanges();
104 view?.dom.blur();
105 return true;
106 }
107 }
108 return false;
109 },
110 "Ctrl-k": moveCursorUp(propsRef, repRef, true),
111 ArrowUp: moveCursorUp(propsRef, repRef),
112 "Ctrl-j": moveCursorDown(propsRef, repRef, true),
113 ArrowDown: moveCursorDown(propsRef, repRef),
114 ArrowLeft: (state, tr, view) => {
115 if (state.selection.content().size > 0) return false;
116 if (state.selection.anchor > 1) return false;
117 let block = propsRef.current.previousBlock;
118 if (block) {
119 view?.dom.blur();
120 focusBlock(block, { type: "end" });
121 }
122 return true;
123 },
124 ArrowRight: (state, tr, view) => {
125 if (state.selection.content().size > 0) return false;
126 if (state.doc.content.size - state.selection.anchor > 1) return false;
127 let block = propsRef.current.nextBlock;
128 if (block) {
129 view?.dom.blur();
130 focusBlock(block, { type: "start" });
131 }
132 return true;
133 },
134 Backspace: (state, dispatch, view) =>
135 um.withUndoGroup(() =>
136 backspace(propsRef, repRef)(state, dispatch, view),
137 ),
138 "Shift-Backspace": backspace(propsRef, repRef),
139 Enter: (state, dispatch, view) => {
140 return um.withUndoGroup(() => {
141 return enter(propsRef, repRef)(state, dispatch, view);
142 });
143 },
144 "Shift-Enter": (state, dispatch, view) => {
145 // Insert a hard break
146 let hardBreak = schema.nodes.hard_break.create();
147 if (dispatch) {
148 dispatch(state.tr.replaceSelectionWith(hardBreak).scrollIntoView());
149 }
150 return true;
151 },
152 "Ctrl-Enter": CtrlEnter(propsRef, repRef),
153 "Meta-Enter": CtrlEnter(propsRef, repRef),
154 }) as { [key: string]: Command };
155
156const moveCursorDown =
157 (
158 propsRef: PropsRef,
159 repRef: RefObject<Replicache<ReplicacheMutators> | null>,
160 jumpToNextBlock: boolean = false,
161 ) =>
162 (
163 state: EditorState,
164 dispatch?: (tr: Transaction) => void,
165 view?: EditorView,
166 ) => {
167 if (!view) return true;
168 if (state.doc.textContent.startsWith("/")) return true;
169 if (useUIState.getState().selectedBlocks.length > 1) return true;
170 if (view.state.selection.from !== view.state.selection.to) return false;
171 const viewClientRect = view.dom.getBoundingClientRect();
172 const coords = view.coordsAtPos(view.state.selection.anchor);
173 let isBottom = viewClientRect.bottom - coords.bottom < 12;
174 if (isBottom || jumpToNextBlock) {
175 let block = propsRef.current.nextBlock;
176 if (block) {
177 view.dom.blur();
178 focusBlock(block, { left: coords.left, type: "top" });
179 return true;
180 }
181 return false || jumpToNextBlock;
182 }
183 return false;
184 };
185const moveCursorUp =
186 (
187 propsRef: PropsRef,
188 repRef: RefObject<Replicache<ReplicacheMutators> | null>,
189 jumpToNextBlock: boolean = false,
190 ) =>
191 (
192 state: EditorState,
193 dispatch?: (tr: Transaction) => void,
194 view?: EditorView,
195 ) => {
196 if (!view) return false;
197 if (state.doc.textContent.startsWith("/")) return true;
198 if (useUIState.getState().selectedBlocks.length > 1) return true;
199 if (view.state.selection.from !== view.state.selection.to) return false;
200 const viewClientRect = view.dom.getBoundingClientRect();
201 const coords = view.coordsAtPos(view.state.selection.anchor);
202 if (coords.top - viewClientRect.top < 12 || jumpToNextBlock) {
203 let block = propsRef.current.previousBlock;
204 if (block) {
205 view.dom.blur();
206 focusBlock(block, { left: coords.left, type: "bottom" });
207 return true;
208 }
209 return false || jumpToNextBlock;
210 }
211 return false;
212 };
213
214const backspace =
215 (
216 propsRef: PropsRef,
217 repRef: RefObject<Replicache<ReplicacheMutators> | null>,
218 ) =>
219 (
220 state: EditorState,
221 dispatch?: (tr: Transaction) => void,
222 view?: EditorView,
223 ) => {
224 // if multiple blocks are selected, don't do anything (handled in SelectionManager)
225 if (useUIState.getState().selectedBlocks.length > 1) {
226 return false;
227 }
228 // if you are selecting text within a block, don't do anything (handled by proseMirror)
229 if (state.selection.anchor > 1 || state.selection.content().size > 0) {
230 return false;
231 }
232 // if you are in a list...
233 if (propsRef.current.listData) {
234 // ...and the item is a checklist item, remove the checklist attribute
235 if (propsRef.current.listData.checklist) {
236 repRef.current?.mutate.retractAttribute({
237 entity: propsRef.current.entityID,
238 attribute: "block/check-list",
239 });
240 return true;
241 }
242 // ...move the child list items to next eligible parent (?)
243 let depth = propsRef.current.listData.depth;
244 repRef.current?.mutate.moveChildren({
245 oldParent: propsRef.current.entityID,
246 newParent: propsRef.current.previousBlock?.listData
247 ? propsRef.current.previousBlock.value
248 : propsRef.current.listData.parent || propsRef.current.parent,
249 after:
250 propsRef.current.previousBlock?.listData?.path.find(
251 (f) => f.depth === depth,
252 )?.entity ||
253 propsRef.current.previousBlock?.value ||
254 null,
255 });
256 }
257 // if this is the first block and is it a list, remove list attribute
258 if (!propsRef.current.previousBlock) {
259 if (propsRef.current.listData) {
260 repRef.current?.mutate.retractAttribute({
261 entity: propsRef.current.entityID,
262 attribute: "block/is-list",
263 });
264 return true;
265 }
266
267 // If the block is a heading, convert it to a text block
268 if (propsRef.current.type === "heading") {
269 repRef.current?.mutate.assertFact({
270 entity: propsRef.current.entityID,
271 attribute: "block/type",
272 data: { type: "block-type-union", value: "text" },
273 });
274 setTimeout(
275 () =>
276 focusBlock(
277 {
278 value: propsRef.current.entityID,
279 type: "text",
280 parent: propsRef.current.parent,
281 },
282 { type: "start" },
283 ),
284 10,
285 );
286
287 return false;
288 }
289
290 if (propsRef.current.pageType === "canvas") {
291 repRef.current?.mutate.removeBlock({
292 blockEntity: propsRef.current.entityID,
293 });
294 }
295 return true;
296 }
297
298 let block = !!propsRef.current.previousBlock
299 ? useEditorStates.getState().editorStates[
300 propsRef.current.previousBlock.value
301 ]
302 : null;
303 if (
304 block &&
305 propsRef.current.previousBlock &&
306 block.editor.doc.textContent.length === 0 &&
307 !propsRef.current.previousBlock?.listData
308 ) {
309 repRef.current?.mutate.removeBlock({
310 blockEntity: propsRef.current.previousBlock.value,
311 });
312 return true;
313 }
314
315 if (state.doc.textContent.length === 0) {
316 repRef.current?.mutate.removeBlock({
317 blockEntity: propsRef.current.entityID,
318 });
319 if (propsRef.current.previousBlock) {
320 focusBlock(propsRef.current.previousBlock, { type: "end" });
321 } else {
322 useUIState.getState().setFocusedBlock({
323 entityType: "page",
324 entityID: propsRef.current.parent,
325 });
326 }
327 return true;
328 }
329
330 if (
331 propsRef.current.previousBlock &&
332 !isTextBlock[propsRef.current.previousBlock?.type]
333 ) {
334 focusBlock(propsRef.current.previousBlock, { type: "end" });
335 view?.dom.blur();
336 return true;
337 }
338
339 if (!block || !propsRef.current.previousBlock) return false;
340
341 repRef.current?.mutate.removeBlock({
342 blockEntity: propsRef.current.entityID,
343 });
344
345 let tr = block.editor.tr;
346
347 block.view?.focus();
348 let firstChild = state.doc.content.firstChild?.content;
349 if (firstChild) {
350 tr.insert(tr.doc.content.size - 1, firstChild);
351 tr.setSelection(
352 TextSelection.create(
353 tr.doc,
354 tr.doc.content.size - firstChild?.size - 1,
355 ),
356 );
357 }
358
359 let newState = block.editor.apply(tr);
360 setEditorState(propsRef.current.previousBlock.value, {
361 editor: newState,
362 });
363
364 return true;
365 };
366
367const shifttab =
368 (
369 propsRef: RefObject<BlockProps & { entity_set: { set: string } }>,
370 repRef: RefObject<Replicache<ReplicacheMutators> | null>,
371 ) =>
372 async () => {
373 if (useUIState.getState().selectedBlocks.length > 1) return false;
374 if (!repRef.current) return false;
375 if (!repRef.current) return false;
376 let { foldedBlocks, toggleFold } = useUIState.getState();
377 await outdent(propsRef.current, propsRef.current.previousBlock, repRef.current, {
378 foldedBlocks,
379 toggleFold,
380 });
381 return true;
382 };
383
384const enter =
385 (
386 propsRef: PropsRef,
387 repRef: RefObject<Replicache<ReplicacheMutators> | null>,
388 ) =>
389 (
390 state: EditorState,
391 dispatch?: (tr: Transaction) => void,
392 view?: EditorView,
393 ) => {
394 if (state.doc.textContent.startsWith("/")) return true;
395 // Empty list item: just outdent, don't create a new block
396 if (
397 propsRef.current.listData &&
398 propsRef.current.pageType !== "canvas" &&
399 state.doc.content.size <= 2
400 ) {
401 shifttab(propsRef, repRef)();
402 return true;
403 }
404 let tr = state.tr;
405 let newContent = tr.doc.slice(state.selection.anchor);
406 tr.delete(state.selection.anchor, state.doc.content.size);
407 dispatch?.(tr);
408
409 let newEntityID = v7();
410 let position: string;
411 let asyncRun = async () => {
412 let blockType =
413 propsRef.current.type === "heading" && state.selection.anchor <= 2
414 ? ("heading" as const)
415 : ("text" as const);
416 if (propsRef.current.pageType === "canvas") {
417 let el = document.getElementById(
418 elementId.block(propsRef.current.entityID).container,
419 );
420 let [position] =
421 (await repRef.current?.query((tx) =>
422 scanIndex(tx).vae(propsRef.current.entityID, "canvas/block"),
423 )) || [];
424 if (!position || !el) return;
425
426 let box = el.getBoundingClientRect();
427
428 await repRef.current?.mutate.addCanvasBlock({
429 newEntityID,
430 factID: v7(),
431 permission_set: propsRef.current.entity_set.set,
432 parent: propsRef.current.parent,
433 type: blockType,
434 position: {
435 x: position.data.position.x,
436 y: position.data.position.y + box.height,
437 },
438 });
439 if (propsRef.current.listData) {
440 await repRef.current?.mutate.assertFact({
441 entity: newEntityID,
442 attribute: "block/is-list",
443 data: { type: "boolean", value: true },
444 });
445 // Copy list style for canvas blocks
446 let listStyle = await repRef.current?.query((tx) =>
447 scanIndex(tx).eav(propsRef.current.entityID, "block/list-style"),
448 );
449 if (listStyle?.[0]) {
450 await repRef.current?.mutate.assertFact({
451 entity: newEntityID,
452 attribute: "block/list-style",
453 data: {
454 type: "list-style-union",
455 value: listStyle[0].data.value,
456 },
457 });
458 }
459 }
460 return;
461 }
462 if (propsRef.current.listData) {
463 let createChild =
464 propsRef.current.nextBlock?.listData &&
465 propsRef.current.nextBlock.listData.depth >
466 propsRef.current.listData.depth &&
467 state.selection.anchor === state.doc.content.size - 1 &&
468 !useUIState
469 .getState()
470 .foldedBlocks.includes(propsRef.current.entityID);
471
472 if (!createChild) {
473 //get this items next sibling
474 let parent = propsRef.current.listData.parent;
475 let siblings = (
476 (await repRef.current?.query((tx) =>
477 scanIndex(tx).eav(parent, "card/block"),
478 )) || []
479 ).sort((a, b) => (a.data.position > b.data.position ? 1 : -1));
480 let index = siblings.findIndex(
481 (sib) => sib.data.value === propsRef.current.entityID,
482 );
483 position = generateKeyBetween(
484 propsRef.current.position,
485 siblings[index + 1]?.data.position || null,
486 );
487 } else {
488 //Get this blocks children and get the first one
489 let children = (
490 (await repRef.current?.query((tx) =>
491 scanIndex(tx).eav(propsRef.current.entityID, "card/block"),
492 )) || []
493 ).sort((a, b) => (a.data.position > b.data.position ? 1 : -1));
494 position = generateKeyBetween(
495 createChild ? null : propsRef.current.position,
496 children[0]?.data.position || null,
497 );
498 }
499 await repRef.current?.mutate.addBlock({
500 newEntityID,
501 factID: v7(),
502 permission_set: propsRef.current.entity_set.set,
503 parent: createChild
504 ? propsRef.current.entityID
505 : propsRef.current.listData.parent,
506 type: blockType,
507 position,
508 });
509 if (
510 !createChild &&
511 (!useUIState
512 .getState()
513 .foldedBlocks.includes(propsRef.current.entityID) ||
514 state.selection.anchor === 1)
515 ) {
516 await repRef.current?.mutate.moveChildren({
517 oldParent: propsRef.current.entityID,
518 newParent: newEntityID,
519 after: null,
520 });
521 }
522 await repRef.current?.mutate.assertFact({
523 entity: newEntityID,
524 attribute: "block/is-list",
525 data: { type: "boolean", value: true },
526 });
527 // Copy list style (ordered/unordered) to new list item
528 let listStyle = await repRef.current?.query((tx) =>
529 scanIndex(tx).eav(propsRef.current.entityID, "block/list-style"),
530 );
531 if (listStyle?.[0]) {
532 await repRef.current?.mutate.assertFact({
533 entity: newEntityID,
534 attribute: "block/list-style",
535 data: {
536 type: "list-style-union",
537 value: listStyle[0].data.value,
538 },
539 });
540 }
541 let checked = await repRef.current?.query((tx) =>
542 scanIndex(tx).eav(propsRef.current.entityID, "block/check-list"),
543 );
544 if (checked?.[0])
545 await repRef.current?.mutate.assertFact({
546 entity: newEntityID,
547 attribute: "block/check-list",
548 data: {
549 type: "boolean",
550 value:
551 state.selection.anchor === 1 ? checked?.[0].data.value : false,
552 },
553 });
554 }
555 // if the block is not a list, add a new text block after it
556 if (!propsRef.current.listData) {
557 position = generateKeyBetween(
558 propsRef.current.position,
559 propsRef.current.nextPosition,
560 );
561 await repRef.current?.mutate.addBlock({
562 newEntityID,
563 factID: v7(),
564 permission_set: propsRef.current.entity_set.set,
565 parent: propsRef.current.parent,
566 type: blockType,
567 position,
568 });
569 }
570 // if you are are the beginning of a heading, move the heading level to the new block
571 if (blockType === "heading") {
572 await repRef.current?.mutate.assertFact({
573 entity: propsRef.current.entityID,
574 attribute: "block/type",
575 data: { type: "block-type-union", value: "text" },
576 });
577 let [headingLevel] =
578 (await repRef.current?.query((tx) =>
579 scanIndex(tx).eav(propsRef.current.entityID, "block/heading-level"),
580 )) || [];
581 await repRef.current?.mutate.assertFact({
582 entity: newEntityID,
583 attribute: "block/heading-level",
584 data: { type: "number", value: headingLevel.data.value || 0 },
585 });
586 }
587 if (propsRef.current.alignment !== "left") {
588 await repRef.current?.mutate.assertFact({
589 entity: newEntityID,
590 attribute: "block/text-alignment",
591 data: {
592 type: "text-alignment-type-union",
593 value: propsRef.current.alignment,
594 },
595 });
596 }
597 let [textSize] =
598 (await repRef.current?.query((tx) =>
599 scanIndex(tx).eav(propsRef.current.entityID, "block/text-size"),
600 )) || [];
601 if (textSize) {
602 await repRef.current?.mutate.assertFact({
603 entity: newEntityID,
604 attribute: "block/text-size",
605 data: {
606 type: "text-size-union",
607 value: textSize.data.value,
608 },
609 });
610 }
611 };
612 asyncRun().then(() => {
613 useUIState.getState().setSelectedBlock({
614 value: newEntityID,
615 parent: propsRef.current.parent,
616 });
617
618 setTimeout(() => {
619 let block = useEditorStates.getState().editorStates[newEntityID];
620 if (block) {
621 let tr = block.editor.tr;
622 if (newContent.content.size > 2) {
623 tr.replaceWith(0, tr.doc.content.size, newContent.content);
624 tr.setSelection(TextSelection.create(tr.doc, 0));
625 let newState = block.editor.apply(tr);
626 setEditorState(newEntityID, {
627 editor: newState,
628 });
629 }
630 focusBlock(
631 {
632 value: newEntityID,
633 parent: propsRef.current.parent,
634 type: "text",
635 },
636 { type: "start" },
637 );
638 }
639 }, 10);
640 });
641
642 // if you are in the middle of a text block, split the block
643 return true;
644 };
645
646const CtrlEnter =
647 (
648 propsRef: RefObject<BlockProps & { entity_set: { set: string } }>,
649 repRef: RefObject<Replicache<ReplicacheMutators> | null>,
650 ) =>
651 (
652 state: EditorState,
653 dispatch?: (tr: Transaction) => void,
654 view?: EditorView,
655 ) => {
656 repRef.current?.mutate.toggleTodoState({
657 entityID: propsRef.current.entityID,
658 });
659 return true;
660 };
661
662const metaA =
663 (
664 propsRef: RefObject<BlockProps & { entity_set: { set: string } }>,
665 repRef: RefObject<Replicache<ReplicacheMutators> | null>,
666 ) =>
667 (
668 state: EditorState,
669 dispatch: ((tr: Transaction) => void) | undefined,
670 view: EditorView | undefined,
671 ) => {
672 const { from, to } = state.selection;
673 // Check if the entire content of the blockk is selected
674 const isFullySelected = from === 0 && to === state.doc.content.size;
675
676 if (!isFullySelected) {
677 // If the entire block is selected, we don't need to do anything
678 return false;
679 } else {
680 // Remove the selection
681 view?.dispatch(
682 state.tr.setSelection(TextSelection.create(state.doc, from)),
683 );
684 view?.dom.blur();
685 repRef.current?.query(async (tx) => {
686 let allBlocks =
687 (await getBlocksWithType(tx, propsRef.current.parent)) || [];
688 useUIState.setState({
689 selectedBlocks: allBlocks.map((b) => ({
690 value: b.value,
691 parent: propsRef.current.parent,
692 })),
693 });
694 });
695 return true;
696 }
697 };