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