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