feat: ability to create new nodes on enter and delete old nodes on delete

This commit is contained in:
Jordan Knott 2021-02-07 16:29:13 -06:00
parent 19d302355f
commit 960f07cd11
9 changed files with 978 additions and 142 deletions

View File

@ -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"

View File

@ -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 : (
<Switch>

View File

@ -34,9 +34,93 @@ type DraggerProps = {
isDragging: boolean;
onDragEnd: (zone: ImpactZone) => void;
initialPos: { x: number; y: number };
pageRef: React.RefObject<HTMLDivElement>;
};
const Dragger: React.FC<DraggerProps> = ({ 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<HTMLDivElement>;
};
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<DraggerProps> = ({
draggedNodes,
container,
onDragEnd,
isDragging,
initialPos,
pageRef: $page,
}) => {
const [pos, setPos] = useState<{ x: number; y: number }>(initialPos);
const { outline, impact, setImpact } = useDrag();
const $handle = useRef<HTMLDivElement>(null);
@ -45,6 +129,8 @@ const Dragger: React.FC<DraggerProps> = ({ 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<DraggerProps> = ({ 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<DraggerProps> = ({ 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<DraggerProps> = ({ 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)`,

View File

@ -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<EditorProps> = ({
text,
onCreateEntry,
initFocus,
autoFocus,
onChangeCurrentText,
onDepthChange,
onDeleteEntry,
onNodeFocused,
handleChangeText,
onBlur,
}) => {
const $editor = useRef<HTMLInputElement>(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 (
<EntryContentEditor
value={text}
ref={$editor}
onChange={e => {
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<string>;
onNodeFocused: (id: string) => void;
text: string;
entries: Array<ItemElement>;
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<string>;
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<EntryProps> = ({
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<EntryProps> = ({
const $entry = useRef<HTMLDivElement>(null);
const $children = useRef<HTMLDivElement>(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<boolean>(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<EntryProps> = ({
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<EntryProps> = ({
if (selection && selection.find(c => c.id === id)) {
isSelected = true;
}
let onSaveTimer: any = null;
const onSaveTimeout = 300;
const renderMap: Array<number> = [];
const renderer = {
text(text: any) {
const localId = renderMap.length;
renderMap.push(text.length);
return `<span id="${id}_${localId}">${text}</span>`;
},
codespan(text: any) {
const localId = renderMap.length;
renderMap.push(text.length + 2);
return `<span class="markdown-code" id="${id}_${localId}">${text}</span>`;
},
strong(text: string) {
const idx = parseInt(text.split('"')[1].split('_')[1]);
renderMap[idx] += 4;
return text.replace('<span', '<span class="markdown-strong"');
},
em(text: string) {
const idx = parseInt(text.split('"')[1].split('_')[1]);
renderMap[idx] += 2;
return text.replace('<span', '<span class="markdown-em"');
},
del(text: string) {
const idx = parseInt(text.split('"')[1].split('_')[1]);
renderMap[idx] += 2;
return text.replace('<span', '<span class="markdown-del"');
},
};
// @ts-ignore
marked.use({ renderer });
const handleMouseDown = useCallback(
_.debounce((e: any) => {
onStartDrag({ id, clientX: e.clientX, clientY: e.clientY });
}, 100),
[],
);
return (
<EntryWrapper isSelected={isSelected} isDragging={!showHandle}>
{!isRoot && (
<EntryContent>
{entries.length !== 0 && (
<ExpandButton onClick={() => onToggleCollapse(id, !collapsed)}>
{collapsed ? <CaretRight width={20} height={20} /> : <CaretDown width={20} height={20} />}
</ExpandButton>
)}
{showHandle && (
<EntryHandle
onMouseUp={() => onCancelDrag()}
onMouseDown={e => {
onStartDrag({ id, clientX: e.clientX, clientY: e.clientY });
<VisibilitySensor
onChange={v => {
if (v) {
setVisible(v);
}
}}
>
<EntryWrapper isSelected={isSelected} isDragging={!showHandle}>
{!isRoot && (
<EntryContent>
{entries.length !== 0 && (
<ExpandButton onClick={() => onToggleCollapse(id, !collapsed)}>
{collapsed ? <CaretRight width={20} height={20} /> : <CaretDown width={20} height={20} />}
</ExpandButton>
)}
{showHandle && (
<EntryHandle
onMouseUp={() => {
handleMouseDown.cancel();
onHandleClick(id);
}}
onMouseDown={e => {
handleMouseDown(e);
}}
>
<Dot width={18} height={18} />
</EntryHandle>
)}
<EntryInnerContent
onMouseDown={() => {
onStartSelect({ id, depth });
}}
ref={$entry}
>
<Dot width={18} height={18} />
</EntryHandle>
)}
<EntryInnerContent
onMouseDown={() => {
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 ? (
<Editor
onDepthChange={delta => 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();
}}
/>
) : (
<EntryContentDisplay
onClick={e => {
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}`}
</EntryInnerContent>
</EntryContent>
)}
{entries.length !== 0 && !collapsed && (
<EntryChildren ref={$children} isRoot={isRoot}>
{entries
.sort((a, b) => a.position - b.position)
.map(entry => (
<Entry
parentID={id}
key={entry.id}
position={entry.position}
depth={depth + 1}
draggedNodes={draggedNodes}
collapsed={entry.collapsed}
id={entry.id}
onStartSelect={onStartSelect}
onStartDrag={onStartDrag}
onCancelDrag={onCancelDrag}
entries={entry.children ?? []}
chain={[...chain, id]}
selection={selection}
onToggleCollapse={onToggleCollapse}
/>
))}
</EntryChildren>
)}
</EntryWrapper>
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) }}
/>
)}
</EntryInnerContent>
</EntryContent>
)}
{entries.length !== 0 && !collapsed && (
<EntryChildren ref={$children} isRoot={isRoot}>
{entries
.sort((a, b) => a.position - b.position)
.map(entry => (
<Entry
onDeleteEntry={onDeleteEntry}
onHandleClick={onHandleClick}
onDepthChange={onDepthChange}
parentID={id}
key={entry.id}
onTextChange={onTextChange}
position={entry.position}
text={entry.text}
depth={depth + 1}
draggedNodes={draggedNodes}
collapsed={entry.collapsed}
id={entry.id}
autoFocus={entry.focus}
onNodeFocused={onNodeFocused}
onStartSelect={onStartSelect}
onStartDrag={onStartDrag}
onCancelDrag={onCancelDrag}
entries={entry.children ?? []}
chain={[...chain, id]}
selection={selection}
onToggleCollapse={onToggleCollapse}
onCreateEntry={onCreateEntry}
/>
))}
</EntryChildren>
)}
</EntryWrapper>
</VisibilitySensor>
);
};

View File

@ -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``;

View File

@ -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<ItemElement> = [];
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<ItemElement> = [
{ 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<HTMLDivElement>(null);
const $pageName = useRef<HTMLDivElement>(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 = () => {
}}
>
<>
<PageContainer>
<PageContainer ref={$page}>
<PageContent>
<RootWrapper ref={$content}>
<PageName>
<PageNameContent ref={$pageName}>
<PageNameText>entry-1-3-1</PageNameText>
</PageNameContent>
</PageName>
<Entry
onDepthChange={(id, parentID, position, depth, depthDelta) => {
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 = () => {
<Dragger
container={$content}
initialPos={dragging.initialPos}
pageRef={$page}
draggedNodes={{ nodes: dragging.draggedNodes, first: selection ? selection.first : null }}
isDragging={dragging.show}
onDragEnd={() => {
@ -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;

View File

@ -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<string, string>, 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);
}
}
}

View File

@ -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<ItemElement>;
};
type NodeDimensions = {
entry: React.RefObject<HTMLElement>;
children: React.RefObject<HTMLElement> | null;

View File

@ -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"