From 960f07cd1137bd6dbb3ecd93d12de241d938a062 Mon Sep 17 00:00:00 2001 From: Jordan Knott Date: Sun, 7 Feb 2021 16:29:13 -0600 Subject: [PATCH] feat: ability to create new nodes on enter and delete old nodes on delete --- frontend/package.json | 5 + frontend/src/App/Routes.tsx | 6 + frontend/src/Outline/Dragger.tsx | 157 +++++++++++- frontend/src/Outline/Entry.tsx | 394 ++++++++++++++++++++++++------- frontend/src/Outline/Styles.ts | 100 +++++++- frontend/src/Outline/index.tsx | 344 ++++++++++++++++++++++++--- frontend/src/Outline/utils.ts | 76 ++++-- frontend/src/taskcafe.d.ts | 4 + frontend/yarn.lock | 34 ++- 9 files changed, 978 insertions(+), 142 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 2fd8606..abea4da 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "@types/jest": "^24.0.0", "@types/jwt-decode": "^2.2.1", "@types/lodash": "^4.14.149", + "@types/marked": "^1.2.2", "@types/node": "^12.0.0", "@types/query-string": "^6.3.0", "@types/react": "^16.9.21", @@ -22,6 +23,7 @@ "@types/react-router-dom": "^5.1.3", "@types/react-select": "^3.0.13", "@types/react-timeago": "^4.1.1", + "@types/react-window": "^1.8.2", "@types/styled-components": "^5.0.0", "apollo-cache-inmemory": "^1.6.5", "apollo-client": "^2.6.8", @@ -41,6 +43,7 @@ "immer": "^6.0.3", "jwt-decode": "^2.2.0", "lodash": "^4.17.20", + "marked": "^2.0.0", "prop-types": "^15.7.2", "query-string": "^6.13.7", "react": "^16.12.0", @@ -56,6 +59,8 @@ "react-select": "^3.1.0", "react-timeago": "^4.4.0", "react-toastify": "^6.0.8", + "react-visibility-sensor": "^5.1.1", + "react-window": "^1.8.6", "rich-markdown-editor": "^10.6.5", "styled-components": "^5.0.1", "typescript": "~3.7.2" diff --git a/frontend/src/App/Routes.tsx b/frontend/src/App/Routes.tsx index 2c656b5..40371aa 100644 --- a/frontend/src/App/Routes.tsx +++ b/frontend/src/App/Routes.tsx @@ -36,7 +36,10 @@ const AuthorizedRoutes = () => { const [loading, setLoading] = useState(true); const { setUser } = useCurrentUser(); useEffect(() => { + const abortController = new AbortController(); + fetch('/auth/refresh_token', { + signal: abortController.signal, method: 'POST', credentials: 'include', }).then(async x => { @@ -60,6 +63,9 @@ const AuthorizedRoutes = () => { } setLoading(false); }); + return () => { + abortController.abort(); + }; }, []); return loading ? null : ( diff --git a/frontend/src/Outline/Dragger.tsx b/frontend/src/Outline/Dragger.tsx index 0eb516c..c9b909e 100644 --- a/frontend/src/Outline/Dragger.tsx +++ b/frontend/src/Outline/Dragger.tsx @@ -34,9 +34,93 @@ type DraggerProps = { isDragging: boolean; onDragEnd: (zone: ImpactZone) => void; initialPos: { x: number; y: number }; + pageRef: React.RefObject; }; -const Dragger: React.FC = ({ draggedNodes, container, onDragEnd, isDragging, initialPos }) => { +let timer: any = null; + +type windowScrollOptions = { + maxScrollX: number; + maxScrollY: number; + isInTopEdge: boolean; + isInBottomEdge: boolean; + edgeTop: number; + edgeBottom: number; + edgeSize: number; + viewportY: number; + $page: React.RefObject; +}; +function adjustWindowScroll({ + maxScrollY, + maxScrollX, + $page, + isInTopEdge, + isInBottomEdge, + edgeTop, + edgeBottom, + edgeSize, + viewportY, +}: windowScrollOptions) { + // Get the current scroll position of the document. + if ($page.current) { + var currentScrollX = $page.current.scrollLeft; + var currentScrollY = $page.current.scrollTop; + + // Determine if the window can be scrolled in any particular direction. + var canScrollUp = currentScrollY > 0; + var canScrollDown = currentScrollY < maxScrollY; + + // Since we can potentially scroll in two directions at the same time, + // let's keep track of the next scroll, starting with the current scroll. + // Each of these values can then be adjusted independently in the logic + // below. + var nextScrollX = currentScrollX; + var nextScrollY = currentScrollY; + + // As we examine the mouse position within the edge, we want to make the + // incremental scroll changes more "intense" the closer that the user + // gets the viewport edge. As such, we'll calculate the percentage that + // the user has made it "through the edge" when calculating the delta. + // Then, that use that percentage to back-off from the "max" step value. + var maxStep = 50; + + // Should we scroll up? + if (isInTopEdge && canScrollUp) { + var intensity = (edgeTop - viewportY) / edgeSize; + + nextScrollY = nextScrollY - maxStep * intensity; + + // Should we scroll down? + } else if (isInBottomEdge && canScrollDown) { + var intensity = (viewportY - edgeBottom) / edgeSize; + + nextScrollY = nextScrollY + maxStep * intensity; + } + + // Sanitize invalid maximums. An invalid scroll offset won't break the + // subsequent .scrollTo() call; however, it will make it harder to + // determine if the .scrollTo() method should have been called in the + // first place. + nextScrollX = Math.max(0, Math.min(maxScrollX, nextScrollX)); + nextScrollY = Math.max(0, Math.min(maxScrollY, nextScrollY)); + + if (nextScrollX !== currentScrollX || nextScrollY !== currentScrollY) { + $page.current.scrollTo(nextScrollX, nextScrollY); + return true; + } else { + return false; + } + } +} + +const Dragger: React.FC = ({ + draggedNodes, + container, + onDragEnd, + isDragging, + initialPos, + pageRef: $page, +}) => { const [pos, setPos] = useState<{ x: number; y: number }>(initialPos); const { outline, impact, setImpact } = useDrag(); const $handle = useRef(null); @@ -45,6 +129,8 @@ const Dragger: React.FC = ({ draggedNodes, container, onDragEnd, i }, [impact]); const handleMouseMove = useCallback( e => { + var t0 = performance.now(); + e.preventDefault(); const { clientX, clientY, pageX, pageY } = e; setPos({ x: clientX, y: clientY }); @@ -53,6 +139,61 @@ const Dragger: React.FC = ({ draggedNodes, container, onDragEnd, i let aboveNode: null | OutlineNode = null; let belowNode: null | OutlineNode = null; + const edgeSize = 50; + + const viewportWidth = document.documentElement.clientWidth; + const viewportHeight = document.documentElement.clientHeight; + + var edgeTop = edgeSize + 80; + var edgeBottom = viewportHeight - edgeSize; + + var isInTopEdge = clientY < edgeTop; + var isInBottomEdge = clientY > edgeBottom; + + if ((isInBottomEdge || isInTopEdge) && $page.current) { + var documentWidth = Math.max( + $page.current.scrollWidth, + $page.current.offsetWidth, + $page.current.clientWidth, + $page.current.scrollWidth, + $page.current.offsetWidth, + $page.current.clientWidth, + ); + var documentHeight = Math.max( + $page.current.scrollHeight, + $page.current.offsetHeight, + $page.current.clientHeight, + $page.current.scrollHeight, + $page.current.offsetHeight, + $page.current.clientHeight, + ); + + var maxScrollX = documentWidth - viewportWidth; + var maxScrollY = documentHeight - viewportHeight; + + (function checkForWindowScroll() { + clearTimeout(timer); + + if ( + adjustWindowScroll({ + maxScrollX, + maxScrollY, + edgeBottom, + $page, + edgeTop, + edgeSize, + isInBottomEdge, + isInTopEdge, + viewportY: clientY, + }) + ) { + timer = setTimeout(checkForWindowScroll, 30); + } + })(); + } else { + clearTimeout(timer); + } + if (curPosition === 'before') { belowNode = curDraggable; } else { @@ -131,21 +272,23 @@ const Dragger: React.FC = ({ draggedNodes, container, onDragEnd, i } if (aboveNode) { - const { ancestors } = findNodeDepth(outline.current.published, aboveNode.id); + const foundDepth = findNodeDepth(outline.current.published, aboveNode.id); + if (foundDepth === null) return; for (let i = 0; i < draggedNodes.nodes.length; i++) { const nodeID = draggedNodes.nodes[i]; - if (ancestors.find(c => c === nodeID)) { + if (foundDepth.ancestors.find(c => c === nodeID)) { if (draggedNodes.first) { belowNode = draggedNodes.first; aboveNode = findNodeAbove(outline.current, aboveNode ? aboveNode.depth : 1, draggedNodes.first); } else { - const { depth } = findNodeDepth(outline.current.published, nodeID); - const nodeDepth = outline.current.nodes.get(depth); + const foundDepth = findNodeDepth(outline.current.published, nodeID); + if (foundDepth === null) return; + const nodeDepth = outline.current.nodes.get(foundDepth.depth); const targetNode = nodeDepth ? nodeDepth.get(nodeID) : null; if (targetNode) { belowNode = targetNode; - aboveNode = findNodeAbove(outline.current, depth, targetNode); + aboveNode = findNodeAbove(outline.current, foundDepth.depth, targetNode); } } } @@ -218,7 +361,7 @@ const Dragger: React.FC = ({ draggedNodes, container, onDragEnd, i }; }, []); const styles = useMemo(() => { - const position: 'absolute' | 'relative' = isDragging ? 'absolute' : 'relative'; + const position: 'fixed' | 'relative' = isDragging ? 'fixed' : 'relative'; return { cursor: isDragging ? '-webkit-grabbing' : '-webkit-grab', transform: `translate(${pos.x - 10}px, ${pos.y - 4}px)`, diff --git a/frontend/src/Outline/Entry.tsx b/frontend/src/Outline/Entry.tsx index 68fb65a..b8d743b 100644 --- a/frontend/src/Outline/Entry.tsx +++ b/frontend/src/Outline/Entry.tsx @@ -1,24 +1,101 @@ -import React, { useRef, useEffect } from 'react'; +import React, { useRef, useEffect, useCallback, useState } from 'react'; import { Dot, CaretDown, CaretRight } from 'shared/icons'; +import _ from 'lodash'; +import marked from 'marked'; +import VisibilitySensor from 'react-visibility-sensor'; -import { EntryChildren, EntryWrapper, EntryContent, EntryInnerContent, EntryHandle, ExpandButton } from './Styles'; +import { + EntryChildren, + EntryWrapper, + EntryContent, + EntryInnerContent, + EntryHandle, + ExpandButton, + EntryContentEditor, + EntryContentDisplay, +} from './Styles'; import { useDrag } from './useDrag'; +import { getCaretPosition, setCurrentCursorPosition } from './utils'; +import useOnOutsideClick from 'shared/hooks/onOutsideClick'; -function getCaretPosition(editableDiv: any) { - let caretPos = 0; - let sel: any = null; - let range: any = null; - if (window.getSelection) { - sel = window.getSelection(); - if (sel && sel.rangeCount) { - range = sel.getRangeAt(0); - if (range.commonAncestorContainer.parentNode === editableDiv) { - caretPos = range.endOffset; - } +type EditorProps = { + text: string; + initFocus: null | { caret: null | number }; + autoFocus: number | null; + onChangeCurrentText: (text: string) => void; + onDeleteEntry: (caret: number) => void; + onBlur: () => void; + handleChangeText: (caret: number) => void; + onDepthChange: (delta: number) => void; + onCreateEntry: () => void; + onNodeFocused: () => void; +}; +const Editor: React.FC = ({ + text, + onCreateEntry, + initFocus, + autoFocus, + onChangeCurrentText, + onDepthChange, + onDeleteEntry, + onNodeFocused, + handleChangeText, + onBlur, +}) => { + const $editor = useRef(null); + useOnOutsideClick($editor, true, () => onBlur(), null); + useEffect(() => { + if (autoFocus && $editor.current) { + $editor.current.focus(); + $editor.current.setSelectionRange(autoFocus, autoFocus); + onNodeFocused(); } - } - return caretPos; -} + }, [autoFocus]); + useEffect(() => { + if (initFocus && $editor.current) { + $editor.current.focus(); + if (initFocus.caret) { + $editor.current.setSelectionRange(initFocus.caret ?? 0, initFocus.caret ?? 0); + } + onNodeFocused(); + } + }, []); + return ( + { + onChangeCurrentText(e.currentTarget.value); + }} + onKeyDown={e => { + if (e.keyCode === 13) { + e.preventDefault(); + // onCreateEntry(parentID, position * 2); + onCreateEntry(); + return; + } else if (e.keyCode === 9) { + e.preventDefault(); + onDepthChange(e.shiftKey ? -1 : 1); + } else if (e.keyCode === 8) { + const caretPos = e.currentTarget.selectionEnd; + if (caretPos === 0) { + // handleChangeText.flush(); + // onDeleteEntry(depth, id, currentText, caretPos); + onDeleteEntry(caretPos); + e.preventDefault(); + return; + } + } else if (e.key === 'z' && e.ctrlKey) { + e.preventDefault(); + return; + } + handleChangeText(e.currentTarget.selectionEnd ?? 0); + // setCaretPos(e.currentTarget.selectionEnd ?? 0); + // handleChangeText(); + }} + /> + ); +}; type EntryProps = { id: string; @@ -30,21 +107,37 @@ type EntryProps = { isRoot?: boolean; selection: null | Array<{ id: string }>; draggedNodes: null | Array; + onNodeFocused: (id: string) => void; + text: string; entries: Array; + onTextChange: (id: string, prex: string, next: string, caret: number) => void; onCancelDrag: () => void; + autoFocus: null | { caret: null | number }; + onCreateEntry: (parent: string, nextPositon: number) => void; position: number; chain?: Array; + onHandleClick: (id: string) => void; + onDepthChange: (id: string, parent: string, position: number, depth: number, depthDelta: number) => void; + onDeleteEntry: (depth: number, id: string, text: string, caretPos: number) => void; depth?: number; }; const Entry: React.FC = ({ id, + text, parentID, isRoot = false, selection, onToggleCollapse, + autoFocus, onStartSelect, + onHandleClick, + onTextChange, position, + onNodeFocused, + onDepthChange, + onCreateEntry, + onDeleteEntry, onCancelDrag, onStartDrag, collapsed = false, @@ -56,8 +149,46 @@ const Entry: React.FC = ({ const $entry = useRef(null); const $children = useRef(null); const { setNodeDimensions, clearNodeDimensions } = useDrag(); + if (autoFocus) { + } + + const $snapshot = useRef<{ now: string; prev: string }>({ now: text, prev: text }); + const [currentText, setCurrentText] = useState(text); + const [caretPos, setCaretPos] = useState(0); + const $firstRun = useRef(true); + useEffect(() => { + if ($firstRun.current) { + $firstRun.current = false; + return; + } + console.log('updating text'); + setCurrentText(text); + }, [text]); + + const [editor, setEditor] = useState<{ open: boolean; caret: null | number }>({ + open: false, + caret: null, + }); + useEffect(() => { + if (autoFocus) setEditor({ open: true, caret: null }); + }, [autoFocus]); + useEffect(() => { + $snapshot.current.now = currentText; + }, [currentText]); + const handleChangeText = useCallback( + _.debounce(() => { + onTextChange(id, $snapshot.current.prev, $snapshot.current.now, caretPos); + $snapshot.current.prev = $snapshot.current.now; + }, 500), + [], + ); + const [visible, setVisible] = useState(false); useEffect(() => { if (isRoot) return; + if (!visible) { + clearNodeDimensions(id); + return; + } if ($entry && $entry.current) { setNodeDimensions(id, { entry: $entry, @@ -67,7 +198,7 @@ const Entry: React.FC = ({ return () => { clearNodeDimensions(id); }; - }, [position, depth, entries]); + }, [position, depth, entries, visible]); let showHandle = true; if (draggedNodes && draggedNodes.length === 1 && draggedNodes.find(c => c === id)) { showHandle = false; @@ -76,79 +207,170 @@ const Entry: React.FC = ({ if (selection && selection.find(c => c.id === id)) { isSelected = true; } - let onSaveTimer: any = null; - const onSaveTimeout = 300; + const renderMap: Array = []; + const renderer = { + text(text: any) { + const localId = renderMap.length; + renderMap.push(text.length); + return `${text}`; + }, + codespan(text: any) { + const localId = renderMap.length; + renderMap.push(text.length + 2); + return `${text}`; + }, + strong(text: string) { + const idx = parseInt(text.split('"')[1].split('_')[1]); + renderMap[idx] += 4; + return text.replace(' { + onStartDrag({ id, clientX: e.clientX, clientY: e.clientY }); + }, 100), + [], + ); return ( - - {!isRoot && ( - - {entries.length !== 0 && ( - onToggleCollapse(id, !collapsed)}> - {collapsed ? : } - - )} - {showHandle && ( - onCancelDrag()} - onMouseDown={e => { - onStartDrag({ id, clientX: e.clientX, clientY: e.clientY }); + { + if (v) { + setVisible(v); + } + }} + > + + {!isRoot && ( + + {entries.length !== 0 && ( + onToggleCollapse(id, !collapsed)}> + {collapsed ? : } + + )} + {showHandle && ( + { + handleMouseDown.cancel(); + onHandleClick(id); + }} + onMouseDown={e => { + handleMouseDown(e); + }} + > + + + )} + { + onStartSelect({ id, depth }); }} + ref={$entry} > - - - )} - { - onStartSelect({ id, depth }); - }} - onKeyDown={e => { - if (e.key === 'z' && e.ctrlKey) { - if ($entry && $entry.current) { - console.log(getCaretPosition($entry.current)); - } - } else { - clearTimeout(onSaveTimer); - if ($entry && $entry.current) { - onSaveTimer = setTimeout(() => { - if ($entry && $entry.current) { - console.log($entry.current.textContent); + {editor.open ? ( + onDepthChange(id, parentID, depth, position, delta)} + onBlur={() => setEditor({ open: false, caret: null })} + onNodeFocused={() => onNodeFocused(id)} + autoFocus={autoFocus ? (autoFocus.caret ? autoFocus.caret : 0) : null} + initFocus={editor.open ? { caret: editor.caret } : null} + text={currentText} + onDeleteEntry={caret => { + handleChangeText.flush(); + onDeleteEntry(depth, id, currentText, caret); + }} + onCreateEntry={() => { + onCreateEntry(parentID, position * 2); + }} + onChangeCurrentText={text => setCurrentText(text)} + handleChangeText={caret => { + setCaretPos(caret); + handleChangeText(); + }} + /> + ) : ( + { + let offset = 0; + let textNode: any; + if (document.caretPositionFromPoint) { + // standard + const range = document.caretPositionFromPoint(e.pageX, e.pageY); + console.dir(range); + if (range) { + textNode = range.offsetNode; + offset = range.offset; + } + } else if (document.caretRangeFromPoint) { + // WebKit + const range = document.caretRangeFromPoint(e.pageX, e.pageY); + if (range) { + textNode = range.startContainer; + offset = range.startOffset; + } } - }, onSaveTimeout); - } - } - }} - contentEditable - ref={$entry} - > - {`${id.toString()} - ${position}`} - - - )} - {entries.length !== 0 && !collapsed && ( - - {entries - .sort((a, b) => a.position - b.position) - .map(entry => ( - - ))} - - )} - + + const id = textNode.parentNode.id.split('_'); + const index = parseInt(id[1]); + let caret = offset; + for (let i = 0; i < index; i++) { + caret += renderMap[i]; + } + setEditor({ open: true, caret }); + }} + dangerouslySetInnerHTML={{ __html: marked.parseInline(text) }} + /> + )} + + + )} + {entries.length !== 0 && !collapsed && ( + + {entries + .sort((a, b) => a.position - b.position) + .map(entry => ( + + ))} + + )} + + ); }; diff --git a/frontend/src/Outline/Styles.ts b/frontend/src/Outline/Styles.ts index b981daf..869f3a6 100644 --- a/frontend/src/Outline/Styles.ts +++ b/frontend/src/Outline/Styles.ts @@ -97,11 +97,84 @@ export const EntryHandle = styled.div` stroke: ${p => p.theme.colors.text.primary}; } `; +export const EntryContentDisplay = styled.div` + display: inline-flex; + align-items: center; + width: 100%; + font-size: 15px; + white-space: pre-wrap; + background: none; + outline: none; + border: none; + line-height: 24px; + min-height: 24px; + overflow-wrap: break-word; + position: relative; + padding: 0; + margin: 0; + color: ${p => p.theme.colors.text.primary}; + user-select: none; + + cursor: text; + .markdown-del { + text-decoration: line-through; + } + .markdown-code { + margin-top: -4px; + font-size: 16px; + line-height: 19px; + color: ${props => props.theme.colors.primary}; + font-family: monospace; + padding: 4px 5px 0; + font-family: 'Consolas', Courier, monospace; + background: ${props => props.theme.colors.bg.primary}; + display: inline-block; + vertical-align: middle; + border-radius: 4px; + } + .markdown-em { + margin-top: -4px; + font-style: italic; + } + .markdown-strong { + font-weight: 700; + color: #fff; + } + &:focus { + outline: 0; + } +`; + +export const EntryContentEditor = styled.input` + width: 100%; + font-size: 15px; + padding: 0; + margin: 0; + white-space: pre-wrap; + background: none; + outline: none; + border: none; + line-height: 24px; + min-height: 24px; + overflow-wrap: break-word; + position: relative; + user-select: text; + color: ${p => p.theme.colors.text.primary}; + &::selection { + background: #a49de8; + } + &:focus { + outline: 0; + } +`; export const EntryInnerContent = styled.div` padding-top: 4px; font-size: 15px; white-space: pre-wrap; + background: none; + outline: none; + border: none; line-height: 24px; min-height: 24px; overflow-wrap: break-word; @@ -124,7 +197,7 @@ export const DragDebugWrapper = styled.div` `; export const DragIndicatorBar = styled.div<{ left: number; top: number; width: number }>` - position: absolute; + position: fixed; width: ${props => props.width}px; top: ${props => props.top}px; left: ${props => props.left}px; @@ -160,5 +233,28 @@ export const EntryContent = styled.div` `; export const PageContainer = styled.div` - overflow: scroll; + overflow-y: auto; + overflow-x: hidden; `; + +export const PageName = styled.div` + position: relative; + margin-left: -100px; + padding-left: 100px; + margin-bottom: 10px; + border-color: rgb(170, 170, 170); + font-size: 26px; + font-weight: bold; + color: #fff; +`; + +export const PageNameContent = styled.div` + white-space: pre-wrap; + line-height: 34px; + min-height: 34px; + overflow-wrap: break-word; + position: relative; + user-select: text; +`; + +export const PageNameText = styled.span``; diff --git a/frontend/src/Outline/index.tsx b/frontend/src/Outline/index.tsx index a0c649e..e4a45b6 100644 --- a/frontend/src/Outline/index.tsx +++ b/frontend/src/Outline/index.tsx @@ -21,6 +21,9 @@ import { EntryContent, RootWrapper, EntryHandle, + PageNameContent, + PageNameText, + PageName, } from './Styles'; import { transformToTree, @@ -33,14 +36,49 @@ import { getNodeOver, getCorrectNode, findCommonParent, + getNodeAbove, + findNodeAbove, } from './utils'; import NOOP from 'shared/utils/noop'; +enum CommandType { + MOVE, + MERGE, + CHANGE_TEXT, + DELETE, + CREATE, +} + +type MoveData = { + prev: { position: number; parent: string | null }; + next: { position: number; parent: string | null }; +}; + +type ChangeTextData = { + node: { + id: string; + parentID: string; + position: number; + }; + caret: number; + prev: string; + next: string; +}; + +type DeleteData = { + node: { + id: string; + parentID: string; + position: number; + text: string; + }; +}; + type OutlineCommand = { nodes: Array<{ id: string; - prev: { position: number; parent: string | null }; - next: { position: number; parent: string | null }; + type: CommandType; + data: MoveData | DeleteData | ChangeTextData; }>; }; @@ -49,19 +87,49 @@ type ItemCollapsed = { collapsed: boolean; }; +function generateItems(c: number) { + const items: Array = []; + for (let i = 0; i < c; i++) { + items.push({ + collapsed: false, + focus: null, + id: `entry-gen-${i}`, + text: `entry-gen-${i}`, + parent: 'root', + position: 4096 * (6 + i), + }); + } + return items; +} + const listItems: Array = [ - { id: 'root', position: 4096, parent: null, collapsed: false }, - { id: 'entry-1', position: 4096, parent: 'root', collapsed: false }, - { id: 'entry-1_3', position: 4096 * 3, parent: 'entry-1', collapsed: false }, - { id: 'entry-1_3_1', position: 4096, parent: 'entry-1_3', collapsed: false }, - { id: 'entry-1_3_2', position: 4096 * 2, parent: 'entry-1_3', collapsed: false }, - { id: 'entry-1_3_3', position: 4096 * 3, parent: 'entry-1_3', collapsed: false }, - { id: 'entry-1_3_3_1', position: 4096 * 1, parent: 'entry-1_3_3', collapsed: false }, - { id: 'entry-1_3_3_1_1', position: 4096 * 1, parent: 'entry-1_3_3_1', collapsed: false }, - { id: 'entry-2', position: 4096 * 2, parent: 'root', collapsed: false }, - { id: 'entry-3', position: 4096 * 3, parent: 'root', collapsed: false }, - { id: 'entry-4', position: 4096 * 4, parent: 'root', collapsed: false }, - { id: 'entry-5', position: 4096 * 5, parent: 'root', collapsed: false }, + { id: 'root', text: '', position: 4096, parent: null, collapsed: false, focus: null }, + { id: 'entry-1', text: 'entry-1', position: 4096, parent: 'root', collapsed: false, focus: null }, + { id: 'entry-1-3', text: 'entry-1-3', position: 4096 * 3, parent: 'entry-1', collapsed: false, focus: null }, + { id: 'entry-1-3-1', text: 'entry-1-3-1', position: 4096, parent: 'entry-1-3', collapsed: false, focus: null }, + { id: 'entry-1-3-2', text: 'entry-1-3-2', position: 4096 * 2, parent: 'entry-1-3', collapsed: false, focus: null }, + { id: 'entry-1-3-3', text: 'entry-1-3-3', position: 4096 * 3, parent: 'entry-1-3', collapsed: false, focus: null }, + { + id: 'entry-1-3-3-1', + text: '*Hello!* I am `doing super` well ~how~ are **you**?', + position: 4096 * 1, + parent: 'entry-1-3-3', + collapsed: false, + focus: null, + }, + { + id: 'entry-1-3-3-1-1', + text: 'entry-1-3-3-1-1', + position: 4096 * 1, + parent: 'entry-1-3-3-1', + collapsed: false, + focus: null, + }, + { id: 'entry-2', text: 'entry-2', position: 4096 * 2, parent: 'root', collapsed: false, focus: null }, + { id: 'entry-3', text: 'entry-3', position: 4096 * 3, parent: 'root', collapsed: false, focus: null }, + { id: 'entry-4', text: 'entry-4', position: 4096 * 4, parent: 'root', collapsed: false, focus: null }, + { id: 'entry-5', text: 'entry-5', position: 4096 * 5, parent: 'root', collapsed: false, focus: null }, + ...generateItems(100), ]; const Outline: React.FC = () => { @@ -133,7 +201,11 @@ const Outline: React.FC = () => { } const parent = curParent ?? 'root'; outline.current.published.set(id, parent ?? 'root'); - const { depth, ancestors } = findNodeDepth(outline.current.published, id); + const foundDepth = findNodeDepth(outline.current.published, id); + if (foundDepth === null) { + continue; + } + const { depth, ancestors } = foundDepth; const collapsedParent = ancestors.slice(0, -1).find(a => collapsedMap.get(a)); if (collapsedParent) { continue; @@ -184,9 +256,29 @@ const Outline: React.FC = () => { produce(prevItems, draftItems => { currentCommand.nodes.forEach(node => { const idx = prevItems.findIndex(c => c.id === node.id); - if (idx !== -1) { - draftItems[idx].parent = node.prev.parent; - draftItems[idx].position = node.prev.position; + if (node.type === CommandType.MOVE) { + if (idx === -1) return; + const data = node.data as MoveData; + draftItems[idx].parent = data.prev.parent; + draftItems[idx].position = data.prev.position; + } else if (node.type === CommandType.CHANGE_TEXT) { + if (idx === -1) return; + const data = node.data as ChangeTextData; + draftItems[idx] = produce(prevItems[idx], draftItem => { + draftItem.text = data.prev; + draftItem.focus = { caret: data.caret }; + }); + } else if (node.type === CommandType.DELETE) { + const data = node.data as DeleteData; + draftItems.push({ + id: data.node.id, + position: data.node.position, + parent: data.node.parentID, + text: '', + focus: { caret: null }, + children: [], + collapsed: false, + }); } }); outlineHistory.current.current--; @@ -201,8 +293,11 @@ const Outline: React.FC = () => { currentCommand.nodes.forEach(node => { const idx = prevItems.findIndex(c => c.id === node.id); if (idx !== -1) { - draftItems[idx].parent = node.next.parent; - draftItems[idx].position = node.next.position; + if (node.type === CommandType.MOVE) { + const data = node.data as MoveData; + draftItems[idx].parent = data.next.parent; + draftItems[idx].position = data.next.position; + } } }); outlineHistory.current.current++; @@ -308,6 +403,8 @@ const Outline: React.FC = () => { }; }, []); + const $page = useRef(null); + const $pageName = useRef(null); if (!root) { return null; } @@ -331,13 +428,9 @@ const Outline: React.FC = () => { listPosition = lastChild.position * 2.0; } } else { - console.log(zone.above); - console.log(zone.below); const correctNode = getCorrectNode(outline.current, zone.above ? zone.above.node : null, depth); - console.log(correctNode); const listAbove = validateDepth(correctNode, depth); const listBelow = validateDepth(zone.below ? zone.below.node : null, depth); - console.log(listAbove, listBelow); if (listAbove && listBelow) { listPosition = (listAbove.position + listBelow.position) / 2.0; } else if (listAbove && !listBelow) { @@ -378,10 +471,192 @@ const Outline: React.FC = () => { }} > <> - + + + + entry-1-3-1 + + { + if (depthDelta === -1) { + const parentRelation = outline.current.relationships.get(parentID); + if (parentRelation) { + const nodeIdx = parentRelation.children + .sort((a, b) => a.position - b.position) + .findIndex(c => c.id === id); + if (parentRelation.children.length !== 0) { + const grandparent = outline.current.published.get(parentID); + if (grandparent) { + const grandparentNode = outline.current.relationships.get(grandparent); + if (grandparentNode) { + const parents = grandparentNode.children.sort((a, b) => a.position - b.position); + const parentIdx = parents.findIndex(c => c.id === parentID); + if (parentIdx === -1) return; + let position = parents[parentIdx].position * 2; + const nextParent = parents[parentIdx + 1]; + if (nextParent) { + position = (parents[parentIdx].position + nextParent.position) / 2.0; + } + setItems(prevItems => + produce(prevItems, draftItems => { + const idx = prevItems.findIndex(c => c.id === id); + draftItems[idx] = produce(prevItems[idx], draftItem => { + draftItem.parent = grandparent; + draftItem.position = position; + draftItem.focus = { caret: 0 }; + }); + }), + ); + } + } + } + } + } else { + const parent = outline.current.relationships.get(parentID); + if (parent) { + const nodeIdx = parent.children + .sort((a, b) => a.position - b.position) + .findIndex(c => c.id === id); + const aboveNode = parent.children[nodeIdx - 1]; + if (aboveNode) { + const aboveNodeRelations = outline.current.relationships.get(aboveNode.id); + let position = 65535; + if (aboveNodeRelations) { + const children = aboveNodeRelations.children.sort((a, b) => a.position - b.position); + if (children.length !== 0) { + position = children[children.length - 1].position * 2; + } + } + setItems(prevItems => + produce(prevItems, draftItems => { + const idx = prevItems.findIndex(c => c.id === id); + draftItems[idx] = produce(prevItems[idx], draftItem => { + draftItem.parent = aboveNode.id; + draftItem.position = position; + draftItem.focus = { caret: 0 }; + }); + }), + ); + } + } + } + }} + onTextChange={(id, prev, next, caret) => { + outlineHistory.current.current += 1; + const data: ChangeTextData = { + node: { + id, + position: 0, + parentID: '', + }, + caret, + prev, + next, + }; + const command: OutlineCommand = { + nodes: [ + { + id, + type: CommandType.CHANGE_TEXT, + data, + }, + ], + }; + outlineHistory.current.commands[outlineHistory.current.current] = command; + if (outlineHistory.current.commands[outlineHistory.current.current + 1]) { + outlineHistory.current.commands.splice(outlineHistory.current.current + 1); + } + setItems(prevItems => + produce(prevItems, draftItems => { + const idx = prevItems.findIndex(c => c.id === id); + if (idx !== -1) { + draftItems[idx] = produce(prevItems[idx], draftItem => { + draftItem.text = next; + }); + } + }), + ); + }} + text="" + autoFocus={null} + onDeleteEntry={(depth, id, text, caretPos) => { + const nodeDepth = outline.current.nodes.get(depth); + if (nodeDepth) { + const node = nodeDepth.get(id); + if (node) { + const nodeAbove = findNodeAbove(outline.current, depth, node); + setItems(prevItems => { + return produce(prevItems, draftItems => { + draftItems = prevItems.filter(c => c.id !== id); + const idx = prevItems.findIndex(c => c.id === nodeAbove?.id); + if (idx !== -1) { + draftItems[idx] = produce(prevItems[idx], draftItem => { + draftItem.focus = { caret: draftItem.text.length }; + const cType = CommandType.DELETE; + const data: DeleteData = { + node: { + id, + position: node.position, + parentID: node.parent, + text: '', + }, + }; + if (text !== '') { + draftItem.text += text; + } + + const command: OutlineCommand = { + nodes: [ + { + id, + type: cType, + data, + }, + ], + }; + outlineHistory.current.current += 1; + outlineHistory.current.commands[outlineHistory.current.current] = command; + if (outlineHistory.current.commands[outlineHistory.current.current + 1]) { + outlineHistory.current.commands.splice(outlineHistory.current.current + 1); + } + }); + } + return draftItems; + }); + }); + } + } + }} + onCreateEntry={(parent, position) => { + setItems(prevItems => + produce(prevItems, draftItems => { + draftItems.push({ + id: '' + Math.random(), + collapsed: false, + position, + text: '', + focus: { + caret: null, + }, + parent, + children: [], + }); + }), + ); + }} + onNodeFocused={id => { + setItems(prevItems => + produce(prevItems, draftItems => { + const idx = draftItems.findIndex(c => c.id === id); + draftItems[idx] = produce(draftItems[idx], draftItem => { + draftItem.focus = null; + }); + }), + ); + }} onStartSelect={({ id, depth }) => { setSelection(null); setSelecting({ isSelecting: true, node: { id, depth } }); @@ -407,6 +682,7 @@ const Outline: React.FC = () => { setImpact(null); setDragging({ show: false, draggedNodes: null, initialPos: { x: 0, y: 0 } }); }} + onHandleClick={id => {}} onStartDrag={e => { if (e.id !== 'root') { if (selectRef.current.hasSelection && selection && selection.nodes.find(c => c.id === e.id)) { @@ -430,6 +706,7 @@ const Outline: React.FC = () => { { @@ -464,13 +741,16 @@ const Outline: React.FC = () => { const curDragging = itemsPrev.findIndex(i => i.id === n); command.nodes.push({ id: n, - prev: { - parent: draftItems[curDragging].parent, - position: draftItems[curDragging].position, - }, - next: { - parent: parentID, - position: listPosition, + type: CommandType.MOVE, + data: { + prev: { + parent: draftItems[curDragging].parent, + position: draftItems[curDragging].position, + }, + next: { + parent: parentID, + position: listPosition, + }, }, }); draftItems[curDragging].parent = parentID; diff --git a/frontend/src/Outline/utils.ts b/frontend/src/Outline/utils.ts index 1220334..628183c 100644 --- a/frontend/src/Outline/utils.ts +++ b/frontend/src/Outline/utils.ts @@ -2,12 +2,10 @@ import _ from 'lodash'; export function getCorrectNode(data: OutlineData, node: OutlineNode | null, depth: number) { if (node) { - console.log(depth, node); if (depth === node.depth) { return node; } const parent = node.ancestors[depth]; - console.log('parent', parent); if (parent) { const parentNode = data.relationships.get(parent); if (parentNode) { @@ -43,7 +41,6 @@ export function getNodeAbove(node: OutlineNode, startingParent: RelationshipChil position: parentNode.position, children: parentNode.children, }; - console.log('node above', nodeAbove); } hasChildren = false; continue; @@ -65,7 +62,6 @@ export function getNodeAbove(node: OutlineNode, startingParent: RelationshipChil } } } - console.log('final node above', nodeAbove); return nodeAbove; } @@ -115,7 +111,6 @@ export function getTargetDepth(mouseX: number, handleLeft: number, availableDept let curDepth = availableDepths.max - 1; for (let x = availableDepths.min; x < availableDepths.max; x++) { const breakpoint = handleLeft - x * 35; - // console.log(`mouseX=${mouseX} breakpoint=${breakpoint} x=${x} curDepth=${curDepth}`); if (mouseX > breakpoint) { return curDepth; } @@ -137,10 +132,6 @@ export function findNextDraggable(pos: { x: number; y: number }, outline: Outlin const target = dimensions ? getDimensions(dimensions.entry) : null; const children = dimensions ? getDimensions(dimensions.children) : null; if (target) { - console.log( - `[${id}] ${pos.y} <= ${target.bottom} = ${pos.y <= target.bottom} / ${pos.y} >= ${target.top} = ${pos.y >= - target.top}`, - ); if (pos.y <= target.bottom && pos.y >= target.top) { const middlePoint = target.top + target.height / 2; const position: ImpactPosition = pos.y > middlePoint ? 'after' : 'before'; @@ -152,10 +143,6 @@ export function findNextDraggable(pos: { x: number; y: number }, outline: Outlin } } if (children) { - console.log( - `[${id}] ${pos.y} <= ${children.bottom} = ${pos.y <= children.bottom} / ${pos.y} >= ${children.top} = ${pos.y >= - children.top}`, - ); if (pos.y <= children.bottom && pos.y >= children.top) { const position: ImpactPosition = 'after'; return { found: false, node, position }; @@ -207,7 +194,7 @@ export function findNodeDepth(published: Map, id: string) { throw new Error('node depth breaker was thrown'); } } else { - throw new Error('unable to find nextID'); + return null; } } return { depth, ancestors }; @@ -359,3 +346,64 @@ export function getLastChildInBranch(outline: OutlineData, lastParentNode: Outli } return null; } + +export function getCaretPosition(editableDiv: any) { + /* + let caretPos = 0; + let sel: any = null; + let range: any = null; + if (window.getSelection) { + sel = window.getSelection(); + if (sel && sel.rangeCount) { + range = sel.getRangeAt(0); + if (range.commonAncestorContainer.parentNode === editableDiv.current) { + caretPos = range.endOffset; + } + } + } + */ + return editableDiv.selectionEnd; +} + +export function createRange(node: any, chars: any, range: any) { + if (!range) { + range = document.createRange(); + range.selectNode(node); + range.setStart(node, 0); + } + + if (chars.count === 0) { + range.setEnd(node, chars.count); + } else if (node && chars.count > 0) { + if (node.nodeType === Node.TEXT_NODE) { + if (node.textContent.length < chars.count) { + chars.count -= node.textContent.length; + } else { + range.setEnd(node, chars.count); + chars.count = 0; + } + } else { + for (var lp = 0; lp < node.childNodes.length; lp++) { + range = createRange(node.childNodes[lp], chars, range); + + if (chars.count === 0) { + break; + } + } + } + } + + return range; +} + +export function setCurrentCursorPosition(element: any, chars: any) { + if (chars >= 0) { + const selection = window.getSelection(); + const range = createRange(element, { count: chars }, false); + if (range && selection) { + range.collapse(false); + selection.removeAllRanges(); + selection.addRange(range); + } + } +} diff --git a/frontend/src/taskcafe.d.ts b/frontend/src/taskcafe.d.ts index 94dba3d..838dfef 100644 --- a/frontend/src/taskcafe.d.ts +++ b/frontend/src/taskcafe.d.ts @@ -206,10 +206,14 @@ type ImpactAction = { type ItemElement = { id: string; parent: null | string; + text: string; + focus: null | { caret: number | null }; + zooming?: { x: number; y: number }; position: number; collapsed: boolean; children?: Array; }; + type NodeDimensions = { entry: React.RefObject; children: React.RefObject | null; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index ce1271e..ec3ff5f 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2934,6 +2934,11 @@ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.165.tgz#74d55d947452e2de0742bad65270433b63a8c30f" integrity sha512-tjSSOTHhI5mCHTy/OOXYIhi2Wt1qcbHmuXD1Ha7q70CgI/I71afO4XtLb/cVexki1oVYchpul/TOuu3Arcdxrg== +"@types/marked@^1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@types/marked/-/marked-1.2.2.tgz#1f858a0e690247ecf3b2eef576f98f86e8d960d4" + integrity sha512-wLfw1hnuuDYrFz97IzJja0pdVsC0oedtS4QsKH1/inyW9qkLQbXgMUqEQT0MVtUBx3twjWeInUfjQbhBVLECXw== + "@types/mdast@^3.0.0": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.3.tgz#2d7d671b1cd1ea3deb306ea75036c2a0407d2deb" @@ -3088,6 +3093,13 @@ dependencies: "@types/react" "*" +"@types/react-window@^1.8.2": + version "1.8.2" + resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.2.tgz#a5a6b2762ce73ffaab7911ee1397cf645f2459fe" + integrity sha512-gP1xam68Wc4ZTAee++zx6pTdDAH08rAkQrWm4B4F/y6hhmlT9Mgx2q8lTCXnrPHXsr15XjRN9+K2DLKcz44qEQ== + dependencies: + "@types/react" "*" + "@types/react@*": version "17.0.0" resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.0.tgz#5af3eb7fad2807092f0046a1302b7823e27919b8" @@ -11023,6 +11035,11 @@ markdown-to-jsx@^6.11.4: prop-types "^15.6.2" unquote "^1.1.0" +marked@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/marked/-/marked-2.0.0.tgz#9662bbcb77ebbded0662a7be66ff929a8611cee5" + integrity sha512-NqRSh2+LlN2NInpqTQnS614Y/3NkVMFFU6sJlRFEpxJ/LHuK/qJECH7/fXZjk4VZstPW/Pevjil/VtSONsLc7Q== + material-colors@^1.2.1: version "1.2.6" resolved "https://registry.yarnpkg.com/material-colors/-/material-colors-1.2.6.tgz#6d1958871126992ceecc72f4bcc4d8f010865f46" @@ -11113,7 +11130,7 @@ mem@^4.0.0: mimic-fn "^2.0.0" p-is-promise "^2.0.0" -memoize-one@^5.0.0, memoize-one@^5.1.1: +"memoize-one@>=3.1.1 <6", memoize-one@^5.0.0, memoize-one@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0" integrity sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA== @@ -14145,6 +14162,21 @@ react-transition-group@^4.3.0, react-transition-group@^4.4.1: loose-envify "^1.4.0" prop-types "^15.6.2" +react-visibility-sensor@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/react-visibility-sensor/-/react-visibility-sensor-5.1.1.tgz#5238380960d3a0b2be0b7faddff38541e337f5a9" + integrity sha512-cTUHqIK+zDYpeK19rzW6zF9YfT4486TIgizZW53wEZ+/GPBbK7cNS0EHyJVyHYacwFEvvHLEKfgJndbemWhB/w== + dependencies: + prop-types "^15.7.2" + +react-window@^1.8.6: + version "1.8.6" + resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.6.tgz#d011950ac643a994118632665aad0c6382e2a112" + integrity sha512-8VwEEYyjz6DCnGBsd+MgkD0KJ2/OXFULyDtorIiTz+QzwoP94tBoA7CnbtyXMm+cCeAUER5KJcPtWl9cpKbOBg== + dependencies: + "@babel/runtime" "^7.0.0" + memoize-one ">=3.1.1 <6" + react@^16.12.0, react@^16.8.3: version "16.14.0" resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d"