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 let tr = state.tr;
396 let newContent = tr.doc.slice(state.selection.anchor);
397 tr.delete(state.selection.anchor, state.doc.content.size);
398 dispatch?.(tr);
399
400 let newEntityID = v7();
401 let position: string;
402 let asyncRun = async () => {
403 let blockType =
404 propsRef.current.type === "heading" && state.selection.anchor <= 2
405 ? ("heading" as const)
406 : ("text" as const);
407 if (propsRef.current.pageType === "canvas") {
408 let el = document.getElementById(
409 elementId.block(propsRef.current.entityID).container,
410 );
411 let [position] =
412 (await repRef.current?.query((tx) =>
413 scanIndex(tx).vae(propsRef.current.entityID, "canvas/block"),
414 )) || [];
415 if (!position || !el) return;
416
417 let box = el.getBoundingClientRect();
418
419 await repRef.current?.mutate.addCanvasBlock({
420 newEntityID,
421 factID: v7(),
422 permission_set: propsRef.current.entity_set.set,
423 parent: propsRef.current.parent,
424 type: blockType,
425 position: {
426 x: position.data.position.x,
427 y: position.data.position.y + box.height,
428 },
429 });
430 if (propsRef.current.listData) {
431 await repRef.current?.mutate.assertFact({
432 entity: newEntityID,
433 attribute: "block/is-list",
434 data: { type: "boolean", value: true },
435 });
436 // Copy list style for canvas blocks
437 let listStyle = await repRef.current?.query((tx) =>
438 scanIndex(tx).eav(propsRef.current.entityID, "block/list-style"),
439 );
440 if (listStyle?.[0]) {
441 await repRef.current?.mutate.assertFact({
442 entity: newEntityID,
443 attribute: "block/list-style",
444 data: {
445 type: "list-style-union",
446 value: listStyle[0].data.value,
447 },
448 });
449 }
450 }
451 return;
452 }
453 if (propsRef.current.listData) {
454 if (state.doc.content.size <= 2) {
455 return shifttab(propsRef, repRef)();
456 }
457 let createChild =
458 propsRef.current.nextBlock?.listData &&
459 propsRef.current.nextBlock.listData.depth >
460 propsRef.current.listData.depth &&
461 state.selection.anchor === state.doc.content.size - 1 &&
462 !useUIState
463 .getState()
464 .foldedBlocks.includes(propsRef.current.entityID);
465
466 if (!createChild) {
467 //get this items next sibling
468 let parent = propsRef.current.listData.parent;
469 let siblings = (
470 (await repRef.current?.query((tx) =>
471 scanIndex(tx).eav(parent, "card/block"),
472 )) || []
473 ).sort((a, b) => (a.data.position > b.data.position ? 1 : -1));
474 let index = siblings.findIndex(
475 (sib) => sib.data.value === propsRef.current.entityID,
476 );
477 position = generateKeyBetween(
478 propsRef.current.position,
479 siblings[index + 1]?.data.position || null,
480 );
481 } else {
482 //Get this blocks children and get the first one
483 let children = (
484 (await repRef.current?.query((tx) =>
485 scanIndex(tx).eav(propsRef.current.entityID, "card/block"),
486 )) || []
487 ).sort((a, b) => (a.data.position > b.data.position ? 1 : -1));
488 position = generateKeyBetween(
489 createChild ? null : propsRef.current.position,
490 children[0]?.data.position || null,
491 );
492 }
493 await repRef.current?.mutate.addBlock({
494 newEntityID,
495 factID: v7(),
496 permission_set: propsRef.current.entity_set.set,
497 parent: createChild
498 ? propsRef.current.entityID
499 : propsRef.current.listData.parent,
500 type: blockType,
501 position,
502 });
503 if (
504 !createChild &&
505 (!useUIState
506 .getState()
507 .foldedBlocks.includes(propsRef.current.entityID) ||
508 state.selection.anchor === 1)
509 ) {
510 await repRef.current?.mutate.moveChildren({
511 oldParent: propsRef.current.entityID,
512 newParent: newEntityID,
513 after: null,
514 });
515 }
516 await repRef.current?.mutate.assertFact({
517 entity: newEntityID,
518 attribute: "block/is-list",
519 data: { type: "boolean", value: true },
520 });
521 // Copy list style (ordered/unordered) to new list item
522 let listStyle = await repRef.current?.query((tx) =>
523 scanIndex(tx).eav(propsRef.current.entityID, "block/list-style"),
524 );
525 if (listStyle?.[0]) {
526 await repRef.current?.mutate.assertFact({
527 entity: newEntityID,
528 attribute: "block/list-style",
529 data: {
530 type: "list-style-union",
531 value: listStyle[0].data.value,
532 },
533 });
534 }
535 let checked = await repRef.current?.query((tx) =>
536 scanIndex(tx).eav(propsRef.current.entityID, "block/check-list"),
537 );
538 if (checked?.[0])
539 await repRef.current?.mutate.assertFact({
540 entity: newEntityID,
541 attribute: "block/check-list",
542 data: {
543 type: "boolean",
544 value:
545 state.selection.anchor === 1 ? checked?.[0].data.value : false,
546 },
547 });
548 }
549 // if the block is not a list, add a new text block after it
550 if (!propsRef.current.listData) {
551 position = generateKeyBetween(
552 propsRef.current.position,
553 propsRef.current.nextPosition,
554 );
555 await repRef.current?.mutate.addBlock({
556 newEntityID,
557 factID: v7(),
558 permission_set: propsRef.current.entity_set.set,
559 parent: propsRef.current.parent,
560 type: blockType,
561 position,
562 });
563 }
564 // if you are are the beginning of a heading, move the heading level to the new block
565 if (blockType === "heading") {
566 await repRef.current?.mutate.assertFact({
567 entity: propsRef.current.entityID,
568 attribute: "block/type",
569 data: { type: "block-type-union", value: "text" },
570 });
571 let [headingLevel] =
572 (await repRef.current?.query((tx) =>
573 scanIndex(tx).eav(propsRef.current.entityID, "block/heading-level"),
574 )) || [];
575 await repRef.current?.mutate.assertFact({
576 entity: newEntityID,
577 attribute: "block/heading-level",
578 data: { type: "number", value: headingLevel.data.value || 0 },
579 });
580 }
581 if (propsRef.current.alignment !== "left") {
582 await repRef.current?.mutate.assertFact({
583 entity: newEntityID,
584 attribute: "block/text-alignment",
585 data: {
586 type: "text-alignment-type-union",
587 value: propsRef.current.alignment,
588 },
589 });
590 }
591 let [textSize] =
592 (await repRef.current?.query((tx) =>
593 scanIndex(tx).eav(propsRef.current.entityID, "block/text-size"),
594 )) || [];
595 if (textSize) {
596 await repRef.current?.mutate.assertFact({
597 entity: newEntityID,
598 attribute: "block/text-size",
599 data: {
600 type: "text-size-union",
601 value: textSize.data.value,
602 },
603 });
604 }
605 };
606 asyncRun().then(() => {
607 useUIState.getState().setSelectedBlock({
608 value: newEntityID,
609 parent: propsRef.current.parent,
610 });
611
612 setTimeout(() => {
613 let block = useEditorStates.getState().editorStates[newEntityID];
614 if (block) {
615 let tr = block.editor.tr;
616 if (newContent.content.size > 2) {
617 tr.replaceWith(0, tr.doc.content.size, newContent.content);
618 tr.setSelection(TextSelection.create(tr.doc, 0));
619 let newState = block.editor.apply(tr);
620 setEditorState(newEntityID, {
621 editor: newState,
622 });
623 }
624 focusBlock(
625 {
626 value: newEntityID,
627 parent: propsRef.current.parent,
628 type: "text",
629 },
630 { type: "start" },
631 );
632 }
633 }, 10);
634 });
635
636 // if you are in the middle of a text block, split the block
637 return true;
638 };
639
640const CtrlEnter =
641 (
642 propsRef: RefObject<BlockProps & { entity_set: { set: string } }>,
643 repRef: RefObject<Replicache<ReplicacheMutators> | null>,
644 ) =>
645 (
646 state: EditorState,
647 dispatch?: (tr: Transaction) => void,
648 view?: EditorView,
649 ) => {
650 repRef.current?.mutate.toggleTodoState({
651 entityID: propsRef.current.entityID,
652 });
653 return true;
654 };
655
656const metaA =
657 (
658 propsRef: RefObject<BlockProps & { entity_set: { set: string } }>,
659 repRef: RefObject<Replicache<ReplicacheMutators> | null>,
660 ) =>
661 (
662 state: EditorState,
663 dispatch: ((tr: Transaction) => void) | undefined,
664 view: EditorView | undefined,
665 ) => {
666 const { from, to } = state.selection;
667 // Check if the entire content of the blockk is selected
668 const isFullySelected = from === 0 && to === state.doc.content.size;
669
670 if (!isFullySelected) {
671 // If the entire block is selected, we don't need to do anything
672 return false;
673 } else {
674 // Remove the selection
675 view?.dispatch(
676 state.tr.setSelection(TextSelection.create(state.doc, from)),
677 );
678 view?.dom.blur();
679 repRef.current?.query(async (tx) => {
680 let allBlocks =
681 (await getBlocksWithType(tx, propsRef.current.parent)) || [];
682 useUIState.setState({
683 selectedBlocks: allBlocks.map((b) => ({
684 value: b.value,
685 parent: propsRef.current.parent,
686 })),
687 });
688 });
689 return true;
690 }
691 };