diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..e5263e76 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +src/url.js +*.css.map \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..1f143993 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,17 @@ +{ + "tabWidth": 2, + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "useTabs": false, + "quoteProps": "as-needed", + "jsxSingleQuote": true, + "arrowParens": "always", + "endOfLine": "auto", + "bracketSpacing": true, + "jsxBracketSameLine": false, + "requirePragma": false, + "insertPragma": false, + "proseWrap": "preserve" +} diff --git a/css/main.css b/css/main.css new file mode 100644 index 00000000..ba24ef51 --- /dev/null +++ b/css/main.css @@ -0,0 +1,317 @@ +body { + overflow: hidden; +} + +*, +*:focus { + outline: 0; +} + +.flex-row { + display: flex; + flex-direction: row; +} + +.flex-row-reverse { + display: flex; + flex-direction: row-reverse; +} + +.flex-column { + display: flex; + flex-direction: column; +} + +.full-size { + width: 100%; + height: 100%; +} + +.flex-item:nth-child(2) { + flex-grow: 1; +} + +.navigator { + min-width: 220px; + max-width: 220px; + background-color: rgb(251, 251, 250); +} +.navigator .scroller { + box-shadow: transparent 0px 0px 0px inset; + transition: box-shadow 300ms ease 0s; + max-height: calc(100% - 6rem); + overflow: hidden auto; +} + +.navigator-header { + padding: 1rem 1rem; + font-size: 1.1rem; + font-weight: bold; +} +.navigator-header:hover { + background-color: rgba(0, 0, 0, 0.04); + cursor: pointer; +} + +.document:hover { + background-color: rgba(0, 0, 0, 0.04); + cursor: pointer; +} +.document-add { + padding: 0.45rem 0.3rem; +} +.document-add-text { + color: rgba(55, 53, 47, 0.65); + font-size: 0.9rem; + line-height: 1.1rem; + margin-left: 0.6rem; +} +.document-item { + color: rgba(25, 23, 17, 0.6); + font-size: 0.9rem; + line-height: 1.4rem; + margin: 0 0.2rem; + padding: 0.2rem 0.15rem; + border-radius: 0.2rem; + position: relative; +} +.document-item .visible-when-hover { + display: none; + position: absolute; + right: 0.3rem; + top: 0.2rem; +} +.document-item:hover .visible-when-hover { + display: flex; + flex-direction: row; +} +.document-item:hover .title-wrapper { + width: calc(100% - 5rem); +} +.document .title-wrapper { + display: inline-block; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.icon-wrapper { + padding: 0.15rem; + border-radius: 0.2rem; +} +.icon-wrapper:hover { + background-color: rgba(0, 0, 0, 0.1); +} + +.home-page { + font-size: 2rem; + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.editor { + padding: 10% 0 0 0; + width: calc(100% - 220px); + max-width: calc(100% - 220px); + overflow: hidden auto; + position: relative; +} +.editor-header { + position: fixed; + top: 0; + right: 0; + margin: 0 2rem; + padding: 1rem; + width: calc(100% - 6rem - 220px); + background-color: white; +} +.editor-header-content { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 160px; + font-size: 14px; + line-height: 1.2; + min-width: 0px; + padding-left: 6px; + padding-right: 6px; +} +.editor-header-slash { + margin-left: 2px; + margin-right: 2px; + color: rgba(55, 53, 47, 0.5); +} +.editor-footer { + font-size: 0.8rem; + position: fixed; + bottom: 0; + right: 0; + padding: 1rem; + width: calc(100% - 2rem - 220px); + background-color: rgb(251, 251, 250); +} +.editor-footer .document-link { + padding: 0.2rem; + margin-left: 1rem; + cursor: pointer; +} +.editor-footer .document-link:hover { + font-weight: bold; + text-decoration: underline; + background-color: rgba(0, 0, 0, 0.04); +} +.editor-title { + padding: 0 15%; + width: 70%; + max-width: 100%; + font-size: 3rem; + font-weight: bold; + caret-color: rgb(55, 53, 47); + margin: 1rem 0.1rem; + display: inline-block; + white-space: pre-wrap; + word-break: break-word; +} +.editor-content { + padding: 0 15% 30vh 15%; + font-size: 1rem; + width: 70%; + max-width: 100%; + caret-color: rgb(55, 53, 47); + display: inline-block; + white-space: pre-wrap; + word-break: break-word; +} + +[contenteditable][placeholder]:empty:before { + content: attr(placeholder); + position: absolute; + color: rgb(182, 182, 182); + background-color: transparent; + cursor: text; +} + +.h1 { + display: block; + font-size: 2em; + margin: 0.67em 0; + font-weight: bold; +} + +.h2 { + display: block; + font-size: 1.5em; + margin-block-start: 0.83em; + margin-block-end: 0.83em; + margin-inline-start: 0px; + margin-inline-end: 0px; + font-weight: bold; +} + +.h3 { + display: block; + font-size: 1.17em; + margin-block-start: 1em; + margin-block-end: 1em; + margin-inline-start: 0px; + margin-inline-end: 0px; + font-weight: bold; +} + +.strong { + font-weight: bolder; +} + +.italic { + font-style: italic; +} + +.strike { + text-decoration: line-through; +} + +.underline { + text-decoration: underline; +} + +.hidden { + display: none; +} + +.modal-wrapper { + position: fixed; + z-index: 999; + width: 100vw; + height: 100vh; + inset: 0px; + overflow: hidden; + cursor: text; +} + +.modal { + position: fixed; + background-color: white; + box-shadow: rgba(15, 15, 15, 0.05) 0px 0px 0px 1px, rgba(15, 15, 15, 0.1) 0px 3px 6px, rgba(15, 15, 15, 0.2) 0px 9px 24px; + border-radius: 0.1rem; + padding: 6px 0; + display: flex; + flex-direction: column; + width: 330px; + min-width: 180px; + max-width: calc(100vw - 24px); + max-height: 40vh; + cursor: default; +} +.modal-header { + padding: 0 14px; + margin-top: 6px; + margin-bottom: 8px; + color: rgba(55, 53, 47, 0.65); + font-size: 11px; + font-weight: 500; + line-height: 120%; + user-select: none; + cursor: pointer; +} +.modal-header.active { + cursor: move; +} +.modal-content { + overflow: hidden auto; + padding-top: 6px; + box-shadow: rgba(55, 53, 47, 0.09) 0px -1px 0px; + margin-top: 6px; + text-overflow: ellipsis; +} +.modal-contents { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + user-select: none; + cursor: pointer; + width: calc(100% - 28px); + margin: 0 7px; + padding: 0 7px; + border-radius: 3px; + line-height: 200%; + min-height: 28px; + font-size: 14px; +} +.modal-contents:hover { + background-color: rgba(0, 0, 0, 0.04); + cursor: pointer; +} + +span.document-link { + text-decoration: underline; + font-weight: bold; +} +span.document-link:hover { + background-color: rgba(0, 0, 0, 0.04); + cursor: pointer; +} + +/*# sourceMappingURL=main.css.map */ diff --git a/css/main.css.map b/css/main.css.map new file mode 100644 index 00000000..8f05f776 --- /dev/null +++ b/css/main.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["main.scss"],"names":[],"mappings":"AAAA;EACE;;;AAGF;AAAA;EAEE;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;;;AAIA;EACE;;;AAIJ;EACE;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;;;AAIJ;EACE;EACA;EACA;;AAEA;EACE;EACA;;;AAKF;EACE;EACA;;AAEF;EACE;;AACA;EACE;EACA;EACA;EACA;;AAGJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;;AAIA;EACE;EACA;;AAGF;EACE;;AAIN;EACE;EACA;EACA;EACA;;;AAIJ;EAIE;EACA;;AAJA;EACE;;;AAMJ;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEF;EACE;EACA;EACA;;AAIJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;;AACA;EACE;EACA;EACA;;AAKN;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAIJ;EACE;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EAEA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;;AAIJ;EACE;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;;;AAKN;EACE;EACA;;AACA;EACE;EACA","file":"main.css"} \ No newline at end of file diff --git a/css/main.scss b/css/main.scss new file mode 100644 index 00000000..d7173dbc --- /dev/null +++ b/css/main.scss @@ -0,0 +1,338 @@ +body { + overflow: hidden; +} + +*, +*:focus { + outline: 0; +} + +.flex-row { + display: flex; + flex-direction: row; +} + +.flex-row-reverse { + display: flex; + flex-direction: row-reverse; +} + +.flex-column { + display: flex; + flex-direction: column; +} + +.full-size { + width: 100%; + height: 100%; +} + +.flex-item { + &:nth-child(2) { + flex-grow: 1; + } +} + +.navigator { + min-width: 220px; + max-width: 220px; + background-color: rgb(251, 251, 250); + + .scroller { + box-shadow: transparent 0px 0px 0px inset; + transition: box-shadow 300ms ease 0s; + max-height: calc(100% - 6rem); + overflow: hidden auto; + } +} + +.navigator-header { + padding: 1rem 1rem; + font-size: 1.1rem; + font-weight: bold; + + &:hover { + background-color: rgba(0, 0, 0, 0.04); + cursor: pointer; + } +} + +.document { + &:hover { + background-color: rgba(0, 0, 0, 0.04); + cursor: pointer; + } + &-add { + padding: 0.45rem 0.3rem; + &-text { + color: rgba(55, 53, 47, 0.65); + font-size: 0.9rem; + line-height: 1.1rem; + margin-left: 0.6rem; + } + } + &-item { + color: rgba(25, 23, 17, 0.6); + font-size: 0.9rem; + line-height: 1.4rem; + margin: 0 0.2rem; + padding: 0.2rem 0.15rem; + border-radius: 0.2rem; + position: relative; + + .visible-when-hover { + display: none; + position: absolute; + right: 0.3rem; + top: 0.2rem; + } + + &:hover { + .visible-when-hover { + display: flex; + flex-direction: row; + } + + .title-wrapper { + width: calc(100% - 5rem); + } + } + } + .title-wrapper { + display: inline-block; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } +} + +.icon-wrapper { + &:hover { + background-color: rgba(0, 0, 0, 0.1); + } + padding: 0.15rem; + border-radius: 0.2rem; +} + +.home-page { + font-size: 2rem; + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.editor { + padding: 10% 0 0 0; + width: calc(100% - 220px); + max-width: calc(100% - 220px); + overflow: hidden auto; + position: relative; + + &-header { + position: fixed; + top: 0; + right: 0; + margin: 0 2rem; + padding: 1rem; + width: calc(100% - 6rem - 220px); + background-color: white; + + &-content { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 160px; + font-size: 14px; + line-height: 1.2; + min-width: 0px; + padding-left: 6px; + padding-right: 6px; + } + &-slash { + margin-left: 2px; + margin-right: 2px; + color: rgba(55, 53, 47, 0.5); + } + } + + &-footer { + font-size: 0.8rem; + position: fixed; + bottom: 0; + right: 0; + padding: 1rem; + width: calc(100% - 2rem - 220px); + background-color: rgb(251, 251, 250); + + .document-link { + padding: 0.2rem; + margin-left: 1rem; + cursor: pointer; + &:hover { + font-weight: bold; + text-decoration: underline; + background-color: rgba(0, 0, 0, 0.04); + } + } + } + + &-title { + padding: 0 15%; + width: 70%; + max-width: 100%; + font-size: 3rem; + font-weight: bold; + caret-color: rgb(55, 53, 47); + margin: 1rem 0.1rem; + display: inline-block; + white-space: pre-wrap; + word-break: break-word; + } + + &-content { + padding: 0 15% 30vh 15%; + font-size: 1rem; + width: 70%; + max-width: 100%; + caret-color: rgb(55, 53, 47); + display: inline-block; + white-space: pre-wrap; + word-break: break-word; + } +} + +[contenteditable][placeholder]:empty:before { + content: attr(placeholder); + position: absolute; + color: rgb(182, 182, 182); + background-color: transparent; + cursor: text; +} + +.h1 { + display: block; + font-size: 2em; + margin: 0.67em 0; + font-weight: bold; +} + +.h2 { + display: block; + font-size: 1.5em; + margin-block-start: 0.83em; + margin-block-end: 0.83em; + margin-inline-start: 0px; + margin-inline-end: 0px; + font-weight: bold; +} + +.h3 { + display: block; + font-size: 1.17em; + margin-block-start: 1em; + margin-block-end: 1em; + margin-inline-start: 0px; + margin-inline-end: 0px; + font-weight: bold; +} + +.strong { + font-weight: bolder; +} + +.italic { + font-style: italic; +} + +.strike { + text-decoration: line-through; +} + +.underline { + text-decoration: underline; +} + +.hidden { + display: none; +} + +.modal-wrapper { + position: fixed; + z-index: 999; + width: 100vw; + height: 100vh; + inset: 0px; + overflow: hidden; + cursor: text; +} + +.modal { + position: fixed; + background-color: white; + box-shadow: rgb(15 15 15 / 5%) 0px 0px 0px 1px, rgb(15 15 15 / 10%) 0px 3px 6px, + rgb(15 15 15 / 20%) 0px 9px 24px; + border-radius: 0.1rem; + padding: 6px 0; + display: flex; + flex-direction: column; + width: 330px; + min-width: 180px; + max-width: calc(100vw - 24px); + max-height: 40vh; + cursor: default; + + &-header { + padding: 0 14px; + margin-top: 6px; + margin-bottom: 8px; + color: rgba(55, 53, 47, 0.65); + font-size: 11px; + font-weight: 500; + line-height: 120%; + user-select: none; + cursor: pointer; + + &.active { + cursor: move; + } + } + + &-content { + overflow: hidden auto; + padding-top: 6px; + box-shadow: rgb(55 53 47 / 9%) 0px -1px 0px; + margin-top: 6px; + text-overflow: ellipsis; + } + + &-contents { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + user-select: none; + cursor: pointer; + width: calc(100% - 28px); + margin: 0 7px; + padding: 0 7px; + border-radius: 3px; + line-height: 200%; + min-height: 28px; + font-size: 14px; + + &:hover { + background-color: rgba(0, 0, 0, 0.04); + cursor: pointer; + } + } +} + +span.document-link { + text-decoration: underline; + font-weight: bold; + &:hover { + background-color: rgba(0, 0, 0, 0.04); + cursor: pointer; + } +} diff --git a/css/normalize.css b/css/normalize.css new file mode 100644 index 00000000..e8e22e4b --- /dev/null +++ b/css/normalize.css @@ -0,0 +1,359 @@ +/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ + +/* Document + ========================================================================== */ + +/** + * 1. Correct the line height in all browsers. + * 2. Prevent adjustments of font size after orientation changes in iOS. + */ + +html { + line-height: 1.15; /* 1 */ + -webkit-text-size-adjust: 100%; /* 2 */ +} + +/* Sections + ========================================================================== */ + +/** + * Remove the margin in all browsers. + */ + +body { + margin: 0; +} + +/** + * Render the `main` element consistently in IE. + */ + +main { + display: block; +} + +/** + * Correct the font size and margin on `h1` elements within `section` and + * `article` contexts in Chrome, Firefox, and Safari. + */ + +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +/* Grouping content + ========================================================================== */ + +/** + * 1. Add the correct box sizing in Firefox. + * 2. Show the overflow in Edge and IE. + */ + +hr { + box-sizing: content-box; /* 1 */ + height: 0; /* 1 */ + overflow: visible; /* 2 */ +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +pre { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/* Text-level semantics + ========================================================================== */ + +/** + * Remove the gray background on active links in IE 10. + */ + +a { + background-color: transparent; +} + +/** + * 1. Remove the bottom border in Chrome 57- + * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. + */ + +abbr[title] { + border-bottom: none; /* 1 */ + text-decoration: underline; /* 2 */ + text-decoration: underline dotted; /* 2 */ +} + +/** + * Add the correct font weight in Chrome, Edge, and Safari. + */ + +b, +strong { + font-weight: bolder; +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +code, +kbd, +samp { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/** + * Add the correct font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` elements from affecting the line height in + * all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* Embedded content + ========================================================================== */ + +/** + * Remove the border on images inside links in IE 10. + */ + +img { + border-style: none; +} + +/* Forms + ========================================================================== */ + +/** + * 1. Change the font styles in all browsers. + * 2. Remove the margin in Firefox and Safari. + */ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; /* 1 */ + font-size: 100%; /* 1 */ + line-height: 1.15; /* 1 */ + margin: 0; /* 2 */ +} + +/** + * Show the overflow in IE. + * 1. Show the overflow in Edge. + */ + +button, +input { + /* 1 */ + overflow: visible; +} + +/** + * Remove the inheritance of text transform in Edge, Firefox, and IE. + * 1. Remove the inheritance of text transform in Firefox. + */ + +button, +select { + /* 1 */ + text-transform: none; +} + +/** + * Correct the inability to style clickable types in iOS and Safari. + */ + +button, +[type='button'], +[type='reset'], +[type='submit'] { + -webkit-appearance: button; +} + +/** + * Remove the inner border and padding in Firefox. + */ + +button::-moz-focus-inner, +[type='button']::-moz-focus-inner, +[type='reset']::-moz-focus-inner, +[type='submit']::-moz-focus-inner { + border-style: none; + padding: 0; +} + +/** + * Restore the focus styles unset by the previous rule. + */ + +button:-moz-focusring, +[type='button']:-moz-focusring, +[type='reset']:-moz-focusring, +[type='submit']:-moz-focusring { + outline: 1px dotted ButtonText; +} + +/** + * Correct the padding in Firefox. + */ + +fieldset { + padding: 0.35em 0.75em 0.625em; +} + +/** + * 1. Correct the text wrapping in Edge and IE. + * 2. Correct the color inheritance from `fieldset` elements in IE. + * 3. Remove the padding so developers are not caught out when they zero out + * `fieldset` elements in all browsers. + */ + +legend { + box-sizing: border-box; /* 1 */ + color: inherit; /* 2 */ + display: table; /* 1 */ + max-width: 100%; /* 1 */ + padding: 0; /* 3 */ + white-space: normal; /* 1 */ +} + +/** + * Add the correct vertical alignment in Chrome, Firefox, and Opera. + */ + +progress { + vertical-align: baseline; +} + +/** + * Remove the default vertical scrollbar in IE 10+. + */ + +textarea { + overflow: auto; +} + +/** + * 1. Add the correct box sizing in IE 10. + * 2. Remove the padding in IE 10. + */ + +[type='checkbox'], +[type='radio'] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Correct the cursor style of increment and decrement buttons in Chrome. + */ + +[type='number']::-webkit-inner-spin-button, +[type='number']::-webkit-outer-spin-button { + height: auto; +} + +/** + * 1. Correct the odd appearance in Chrome and Safari. + * 2. Correct the outline style in Safari. + */ + +[type='search'] { + -webkit-appearance: textfield; /* 1 */ + outline-offset: -2px; /* 2 */ +} + +/** + * Remove the inner padding in Chrome and Safari on macOS. + */ + +[type='search']::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * 1. Correct the inability to style clickable types in iOS and Safari. + * 2. Change font properties to `inherit` in Safari. + */ + +::-webkit-file-upload-button { + -webkit-appearance: button; /* 1 */ + font: inherit; /* 2 */ +} + +/* Interactive + ========================================================================== */ + +/* + * Add the correct display in Edge, IE 10+, and Firefox. + */ + +details { + display: block; +} + +/* + * Add the correct display in all browsers. + */ + +summary { + display: list-item; +} + +/* Misc + ========================================================================== */ + +/** + * Add the correct display in IE 10+. + */ + +template { + display: none; +} + +/** + * Add the correct display in IE 10. + */ + +[hidden] { + display: none; +} + +html, +body, +#root { + margin: 0; + padding: 0; + height: 100%; +} diff --git a/index.html b/index.html new file mode 100644 index 00000000..2a865e13 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + Ryu's Notion + + + + +
+ + + diff --git a/src/app.js b/src/app.js new file mode 100644 index 00000000..e1312561 --- /dev/null +++ b/src/app.js @@ -0,0 +1,217 @@ +import Editor from './components/editor.js'; +import Home from './components/home.js'; +import Navigator from './components/navigator.js'; +import { initRouter, push } from './router.js'; +import { request } from './utils/api.js'; +import { METHOD, STORAGE_KEY, TEXT } from './utils/constants.js'; +import { getWaysFromRootToLeaf } from './utils/getWayThroughRoot.js'; +import { getItem, removeItem, setItem } from './utils/storage.js'; +import { debounce } from './utils/debounce.js'; + +export default function App({ $target, initialState }) { + const $wrapper = document.createElement('div'); + $wrapper.classList.add('flex-row', 'full-size'); + + let documentLocalSaveKey = ''; + const currentDocument = getItem(documentLocalSaveKey, { + title: '', + content: '', + id: null, + }); + + const fetchDocuments = async () => { + const documents = await request('/documents'); + if (documents && documents.length) { + this.setState({ documents }); + editor.setState({ documents }); + navigator.setState({ openedDocuments: getItem(STORAGE_KEY.OPENED_DOCUMENTS, {}) }); + navigator.renderDocuments(); + } + }; + + const fetchDocument = async (documentId) => { + const fetchedDocument = await request(`/documents/${documentId}`); + const { document } = this.state; + + if ((document && document.id !== documentId) || !document) { + documentLocalSaveKey = `temp-document-${documentId}`; + const tempDocument = getItem(documentLocalSaveKey, { + title: '', + content: '', + id: null, + }); + + if (tempDocument.tempSaveDate && tempDocument.tempSaveDate > fetchedDocument.updatedAt) { + if (confirm(TEXT.LOAD_UNSAVED_DATA)) { + this.setState({ document: tempDocument }); + editor.setState({ document: tempDocument }); + editor.render(); + return; + } + } + } + + this.setState({ document: fetchedDocument }); + editor.setState({ document: fetchedDocument }); + editor.render(); + }; + + this.state = initialState; + + this.setState = (nextState) => { + for (const [key, value] of Object.entries(nextState)) { + this.state[key] = value; + } + }; + + const navigator = new Navigator({ + $target: $wrapper, + initialState: this.state, + addDocument: async (targetDocumentId) => { + const newDocument = await request( + '/documents', + { method: METHOD.POST }, + { title: TEXT.DEFAULT_TITLE, parent: targetDocumentId }, + ); + if (newDocument) { + const initializedDocument = await request( + `/documents/${newDocument.id}`, + { method: METHOD.PUT }, + { title: newDocument.title, content: '' }, + ); + if (initializedDocument) { + await fetchDocuments(); + + if (parseInt(targetDocumentId) === this.state.document.id) { + await fetchDocument(targetDocumentId); + } + } + } + }, + deleteDocument: async (targetDocumentId) => { + const deletedDocument = await request(`/documents/${targetDocumentId}`, { + method: METHOD.DELETE, + }); + if (deletedDocument) { + await fetchDocuments(); + const { documents, id } = this.state.document; + + if (documents && documents.filter((doc) => doc.id === deletedDocument.id).length) { + await fetchDocument(id); + } + + const { pathname } = window.location; + if (pathname.indexOf('/documents/') === 0) { + const [, , documentId] = pathname.split('/'); + if (documentId === targetDocumentId) push('/'); + } + } + }, + openDocument: async (targetDocumentId) => { + push(`/documents/${targetDocumentId}`); + }, + }); + + const optimisticTitleUpdate = (document) => { + const { id, title } = document; + const $currentNavigatorTitle = $wrapper.querySelector(`#id-${id}`); + const $currentEditorHeader = $wrapper.querySelector('.editor-header'); + if (typeof title !== 'undefined' && $currentNavigatorTitle.innerHTML !== title) { + $currentNavigatorTitle.innerHTML = title; + $currentEditorHeader.lastElementChild.innerHTML = title; + } + }; + + const editor = new Editor({ + $target: $wrapper, + initialState: { document: currentDocument, documents: this.state.documents }, + optimisticTitleUpdate, + onEditing: debounce(async (document) => { + const { id, title } = document; + const $currentNavigatorTitle = $wrapper.querySelector(`#id-${id}`); + const $currentEditorHeader = $wrapper.querySelector('.editor-header'); + if (typeof title !== 'undefined' && $currentNavigatorTitle.innerHTML !== title) { + $currentNavigatorTitle.innerHTML = title; + $currentEditorHeader.lastElementChild.innerHTML = title; + } + + setItem(documentLocalSaveKey, { + ...document, + tempSaveDate: new Date(), + }); + + const modifiedDocument = await request( + `/documents/${id}`, + { + method: METHOD.PUT, + }, + document, + ); + + if (modifiedDocument) { + if (title !== this.state.document.title) { + let changedDocuments = getItem(STORAGE_KEY.CHANGED_DOCUMENTS, []); + changedDocuments = changedDocuments.filter( + (changedDocument) => changedDocument.id !== id, + ); + setItem(STORAGE_KEY.CHANGED_DOCUMENTS, [...changedDocuments, { id, title }]); + editor.handleTitleChangedDocuments(); + } + + editor.setState({ document }); + removeItem(documentLocalSaveKey); + await fetchDocuments(); + } + }, 250), + openDocument: async (targetDocumentId) => { + targetDocumentId = parseInt(targetDocumentId); + const waysFromRootToLeafs = getWaysFromRootToLeaf(this.state.documents); + const ids = waysFromRootToLeafs.filter((rootToLeaf) => + rootToLeaf.includes(targetDocumentId), + )[0]; + + if (!ids) { + push('/'); + return; + } + + const currentWay = ids.slice(0, ids.findIndex((id) => id === targetDocumentId) + 1); + + let openDocuments = {}; + for (const way of currentWay) { + openDocuments = { ...openDocuments, [way]: true }; + } + + setItem(STORAGE_KEY.OPENED_DOCUMENTS, { ...this.state.openedDocuments, ...openDocuments }); + push(`/documents/${targetDocumentId}`); + }, + }); + + const home = new Home({ $target: $wrapper, initialState: TEXT.DEFAULT_HOME_PAGE }); + + this.render = () => { + $target.appendChild($wrapper); + }; + + initRouter(async () => { + const $modal = $wrapper.querySelector('.modal-wrapper'); + if ($modal) { + $wrapper.removeChild($modal); + $modal.innerHTML = ''; + } + + const { pathname } = window.location; + await fetchDocuments(); + + if (pathname === '/') { + editor.remove(); + home.render(); + } else if (pathname.indexOf('/documents/') === 0) { + const [, , documentId] = pathname.split('/'); + home.remove(); + await fetchDocument(documentId); + } + + this.render(); + }); +} diff --git a/src/components/editor.js b/src/components/editor.js new file mode 100644 index 00000000..72a01ebe --- /dev/null +++ b/src/components/editor.js @@ -0,0 +1,238 @@ +import { EVENT, EVENT_KEY, STORAGE_KEY, TEXT } from '../utils/constants.js'; +import { getLowerDocuments } from '../utils/getDocuments.js'; +import { getTitlesThroughRoot } from '../utils/getWayThroughRoot.js'; +import { getItem } from '../utils/storage.js'; +import { makeRichText } from '../utils/richText.js'; +import Modal from './modal.js'; + +export default function Editor({ + $target, + initialState = { + document: { + title: '', + content: '', + id: null, + }, + documents: [], + }, + optimisticTitleUpdate, + onEditing, + openDocument, +}) { + const $editor = document.createElement('div'); + $editor.classList.add('editor', 'flex-item', 'flex-column'); + + this.state = initialState; + + this.setState = (nextState) => { + for (const [key, value] of Object.entries(nextState)) { + this.state[key] = value; + } + }; + + this.setEvent = () => { + const $title = $editor.querySelector('[name=title]'); + const $content = $editor.querySelector('[name=content]'); + + if ($title) { + $title.addEventListener(EVENT.KEYUP, (e) => { + if (e.key === EVENT_KEY.ENTER) { + e.preventDefault(); + $content.focus(); + } + + const nextDocument = { ...this.state.document, title: e.target.textContent }; + + optimisticTitleUpdate(nextDocument); + onEditing(nextDocument); + }); + } + if ($content) { + $content.addEventListener(EVENT.KEYDOWN, (e) => { + const selection = window.getSelection(); + const { anchorNode } = selection; + if (e.key === EVENT_KEY.BACKSPACE && anchorNode.nodeName === '#text') { + const $parent = anchorNode.parentNode; + const text = $parent.innerHTML; + removeModal($parent, text); + } + }); + + $content.addEventListener(EVENT.KEYUP, (e) => { + const selection = window.getSelection(); + if (!selection.rangeCount) return null; + const { anchorNode } = selection; + + if (e.key === EVENT_KEY.ENTER) { + const $parent = anchorNode.parentNode; + if ($parent.classList.contains('editor-content')) { + anchorNode.classList = ''; + } + // else { + // $parent.classList = ''; + // } + } else if (e.key === EVENT_KEY.SPACE) { + if (anchorNode.nodeName === '#text' && $target.querySelector('.modal-wrapper')) { + const $parent = anchorNode.parentNode; + const text = $parent.innerHTML; + const keyword = text.substr(text.lastIndexOf(EVENT_KEY.AT) + 1); + + modal.setState({ keyword }); + modal.render(); + return; + } + + e.preventDefault(); + let $parent = anchorNode.parentNode; + + if ($parent.classList.contains('editor-content')) { + $parent.innerHTML = `
${anchorNode.textContent}
`; + $parent = $parent.firstChild; + } + + const currentLine = makeRichText($parent); + + if ($parent.innerHTML !== currentLine) { + $parent.innerHTML = currentLine; + } + } else if (e.key === EVENT_KEY.AT) { + const range = selection.getRangeAt(0).cloneRange(); + if (!range.getClientRects) return null; + + range.collapse(0); + const rects = range.getClientRects(); + if (rects.length <= 0) return null; + + const { x, y } = rects[0]; + const $parent = anchorNode.parentNode; + $parent.classList.add('current-link'); + + modal.setState({ documents: this.state.documents, position: { x, y } }); + modal.render(); + } else if (e.key === EVENT_KEY.ESC) { + const $parent = anchorNode.parentNode; + const text = $parent.innerHTML; + removeModal($parent, text); + } else { + if (anchorNode.nodeName === '#text' && $target.querySelector('.modal-wrapper')) { + const $parent = anchorNode.parentNode; + const text = $parent.innerHTML; + const keyword = text.substr(text.lastIndexOf(EVENT_KEY.AT) + 1); + + modal.setState({ keyword }); + modal.render(); + } + } + + if (Object.values(EVENT_KEY.DISABLE_API_CALL).includes(e.key)) { + return; + } + + const nextDocument = { ...this.state.document, content: e.target.innerHTML }; + + optimisticTitleUpdate(nextDocument); + onEditing(nextDocument); + }); + } + }; + + const modal = new Modal({ + $target, + initialState: { + documents: this.state.documents, + }, + onEditing: async (content) => { + const nextDocument = { ...this.state.document, content }; + await optimisticTitleUpdate(nextDocument); + await onEditing(nextDocument); + setDocumentLinkEvents(); + }, + }); + + $editor.innerHTML = ` +
+
+
+ + `; + + this.render = () => { + $target.appendChild($editor); + const { title, content, documents, id } = this.state.document; + const titles = getTitlesThroughRoot($target, id); + $editor.querySelector('[name=header]').innerHTML = ` + ${titles + .map((title, index) => { + return ` +
${title}
+ ${index !== titles.length - 1 ? `/` : ''} + `; + }) + .join('')} + `; + $editor.querySelector('[name=title]').textContent = title; + $editor.querySelector('[name=content]').innerHTML = content; + $editor.querySelector('[name=footer]').innerHTML = getLowerDocuments($editor, documents); + + this.handleTitleChangedDocuments(); + setDocumentLinkEvents(); + }; + + const setDocumentLinkEvents = () => { + const $documentLinks = $editor.querySelectorAll('.document-link'); + + if ($documentLinks && $documentLinks.length) { + [].forEach.call($documentLinks, ($documentLink) => { + $documentLink.addEventListener(EVENT.CLICK, (e) => { + const targetDocumentId = e.target.getAttribute('key'); + openDocument(targetDocumentId); + }); + }); + } + }; + + this.handleTitleChangedDocuments = () => { + const $documentLinks = $editor.querySelectorAll('span.document-link'); + const changedDocuments = getItem(STORAGE_KEY.CHANGED_DOCUMENTS, []); + + if ($documentLinks && $documentLinks.length && changedDocuments && changedDocuments.length) { + let changed = false; + + [].forEach.call(changedDocuments, (changedDocument) => { + const { id, title } = changedDocument; + Array.from($documentLinks) + .filter(($documentLink) => parseInt($documentLink.getAttribute('key')) === id) + .map((filtered) => { + if (filtered.innerHTML !== title) { + filtered.innerHTML = title; + changed = true; + } + }); + }); + + if (changed) { + this.setState({ + document: { + ...this.state.document, + content: $editor.querySelector('[name=content]').innerHTML, + }, + }); + } + } + }; + + const removeModal = ($parent, text) => { + if (text.at(-1) === '@' && $target.querySelector('.modal-wrapper')) { + $parent.classList.remove('current-link'); + modal.remove(); + } + }; + + this.remove = () => { + if ($target.querySelector('.editor')) { + $target.removeChild($editor); + } + }; + + this.setEvent(); +} diff --git a/src/components/home.js b/src/components/home.js new file mode 100644 index 00000000..85e27d0c --- /dev/null +++ b/src/components/home.js @@ -0,0 +1,18 @@ +export default function Home({ $target, initialState }) { + const $home = document.createElement('div'); + $home.classList.add('home-page'); + + this.state = initialState; + + $home.innerHTML = this.state; + + this.render = () => { + $target.appendChild($home); + }; + + this.remove = () => { + if ($target.querySelector('.home-page')) { + $target.removeChild($home); + } + }; +} diff --git a/src/components/icons/chevron.js b/src/components/icons/chevron.js new file mode 100644 index 00000000..7aace891 --- /dev/null +++ b/src/components/icons/chevron.js @@ -0,0 +1,18 @@ +import { DEGREE } from '../../utils/constants.js'; + +const Chevron = ({ color = 'rgba(55,53,47,0.35)', rotateDegree = DEGREE.CLOSED }) => { + return ` + + + + `; +}; + +export default Chevron; diff --git a/src/components/icons/document.js b/src/components/icons/document.js new file mode 100644 index 00000000..8438419d --- /dev/null +++ b/src/components/icons/document.js @@ -0,0 +1,15 @@ +const Document = ({ color = 'rgba(55,53,47,0.45)' }) => { + return ` + + + + `; +}; + +export default Document; diff --git a/src/components/icons/index.js b/src/components/icons/index.js new file mode 100644 index 00000000..af19ca67 --- /dev/null +++ b/src/components/icons/index.js @@ -0,0 +1,15 @@ +import Chevron from './chevron.js'; +import Document from './document.js'; +import Plus from './plus.js'; +import Trash from './trash.js'; + +const Icons = { + document: Document, + chevron: Chevron, + plus: Plus, + trash: Trash, +}; + +const Icon = ({ icon, ...props }) => Icons[icon](props); + +export default Icon; diff --git a/src/components/icons/plus.js b/src/components/icons/plus.js new file mode 100644 index 00000000..7e8cda05 --- /dev/null +++ b/src/components/icons/plus.js @@ -0,0 +1,15 @@ +const Plus = ({ color = 'rgba(55,53,47,0.45)' }) => { + return ` + + + + `; +}; + +export default Plus; diff --git a/src/components/icons/trash.js b/src/components/icons/trash.js new file mode 100644 index 00000000..16ac4f70 --- /dev/null +++ b/src/components/icons/trash.js @@ -0,0 +1,15 @@ +const Trash = ({ color = 'rgba(55,53,47,0.45)' }) => { + return ` + + + + `; +}; + +export default Trash; diff --git a/src/components/modal.js b/src/components/modal.js new file mode 100644 index 00000000..c25e958b --- /dev/null +++ b/src/components/modal.js @@ -0,0 +1,131 @@ +import { EVENT, TEXT } from '../utils/constants.js'; +import { getDocumentsByKeyword } from '../utils/getDocuments.js'; + +export default function Modal({ + $target, + initialState = { documents: [], position: { x: 0, y: 0 }, keyword: '' }, + onEditing, +}) { + const $wrapper = document.createElement('div'); + $wrapper.classList.add('modal-wrapper'); + + this.state = initialState; + + this.setState = (nextState) => { + for (const [key, value] of Object.entries(nextState)) { + this.state[key] = value; + } + }; + + $wrapper.addEventListener(EVENT.CLICK, () => { + const $currentTarget = $target.querySelector('.current-link'); + + $currentTarget.classList.remove('current-link'); + $currentTarget.classList.remove(); + + this.remove(); + }); + + this.setEvent = () => { + const $modal = $wrapper.querySelector('.modal'); + const $modalContents = $modal.querySelectorAll('.modal-contents'); + const $modalHeader = $modal.querySelector('.modal-header'); + + if ($modal) { + const { x, y } = this.state.position; + + $modal.style.left = `${$wrapper.clientWidth < x + 330 ? x - 330 : x}px`; + $modal.style.top = `${y > 420 ? y - 320 : y + 20}px`; + + $modal.addEventListener(EVENT.CLICK, (e) => { + e.stopPropagation(); + }); + } + + if ($modalContents && $modalContents.length) { + [].forEach.call($modalContents, ($modalContent) => { + $modalContent.addEventListener(EVENT.CLICK, (e) => { + const targetDocumentId = e.target.getAttribute('key'); + const targetDocumentTitle = e.target.innerHTML; + const $documentLink = document.createElement('span'); + const $documentLinkTarget = $target.querySelector('.current-link'); + + $documentLink.classList.add('document-link'); + $documentLink.setAttribute('contenteditable', false); + $documentLink.innerHTML = targetDocumentTitle; + $documentLink.setAttribute('key', targetDocumentId); + $documentLinkTarget.innerHTML = $documentLinkTarget.innerHTML.slice( + 0, + -(this.state.keyword.length + 1), + ); + $documentLinkTarget.appendChild($documentLink); + $documentLinkTarget.classList.remove('current-link'); + + onEditing($target.querySelector('.editor-content').innerHTML); + this.remove(); + }); + }); + } + + $modalHeader.addEventListener(EVENT.MOUSEDOWN, (e) => { + e.preventDefault(); + this.setState({ position: { x: e.clientX, y: e.clientY } }); + + $modalHeader.classList.add('active'); + $wrapper.addEventListener(EVENT.MOUSEUP, removeMouseMove); + $wrapper.addEventListener(EVENT.MOUSEMOVE, onMouseMove); + }); + }; + + const removeMouseMove = () => { + const $modalHeader = $wrapper.querySelector('.modal-header'); + + $modalHeader.classList.remove('active'); + $wrapper.removeEventListener(EVENT.MOUSEUP, removeMouseMove); + $wrapper.removeEventListener(EVENT.MOUSEMOVE, onMouseMove); + }; + + const onMouseMove = (e) => { + e.preventDefault(); + const $modal = $wrapper.querySelector('.modal'); + const { x, y } = this.state.position; + + const moveX = x - e.clientX; + const moveY = y - e.clientY; + this.setState({ position: { x: e.clientX, y: e.clientY } }); + + $modal.style.left = `${$modal.offsetLeft - moveX}px`; + $modal.style.top = `${$modal.offsetTop - moveY}px`; + }; + + this.render = () => { + $target.appendChild($wrapper); + const { documents, keyword } = this.state; + const searchedDocuments = getDocumentsByKeyword([], documents, keyword); + + $wrapper.innerHTML = ` + + `; + + this.setEvent(); + }; + + this.remove = () => { + if ($target.querySelector('.modal-wrapper')) { + $target.removeChild($wrapper); + $wrapper.innerHTML = ''; + } + }; +} diff --git a/src/components/navigator.js b/src/components/navigator.js new file mode 100644 index 00000000..dba12fc9 --- /dev/null +++ b/src/components/navigator.js @@ -0,0 +1,170 @@ +import { push } from '../router.js'; +import { DEGREE, EVENT, ICON, STORAGE_KEY, TEXT } from '../utils/constants.js'; +import { setItem } from '../utils/storage.js'; +import Icon from './icons/index.js'; + +export default function Navigator({ + $target, + initialState = { openedDocuments: {}, documents: [] }, + addDocument, + deleteDocument, + openDocument, +}) { + const $navigator = document.createElement('div'); + $navigator.classList.add('navigator', 'flex-item'); + + const documentIcon = Icon({ icon: ICON.DOCUMENT }); + const chevronIcon = Icon({ icon: ICON.CHEVRON }); + const chevronDownIcon = Icon({ icon: ICON.CHEVRON, rotateDegree: DEGREE.OPENED }); + const plusIcon = Icon({ icon: ICON.PLUS }); + const trashIcon = Icon({ icon: ICON.TRASH }); + + $target.appendChild($navigator); + this.state = initialState; + + this.setState = (nextState) => { + for (const [key, value] of Object.entries(nextState)) { + this.state[key] = value; + } + }; + + const getDocuments = (documents, depth = 0, opened = false) => { + if (!documents || documents.length === 0) return ''; + return ` +
+ ${documents + .map(({ id, title, documents }) => { + const isOpened = this.state.openedDocuments[id]; + return ` +
+
${ + isOpened ? chevronDownIcon : chevronIcon + }
+
${documentIcon}
+
+ ${title} +
+
${trashIcon}
+
${plusIcon}
+
+
+
+ ${isOpened ? getDocuments(documents, depth + 1, isOpened) : ''} + `; + }) + .join('')} +
+ `; + }; + + this.renderDocuments = () => { + const documentsWrapper = $navigator.querySelector('.documents-wrapper'); + documentsWrapper.innerHTML = ` + ${getDocuments(this.state.documents)} + `; + }; + + this.setEvent = () => { + $navigator.addEventListener(EVENT.CLICK, (e) => { + const $chevronButton = e.target.closest('.chevron-button'); + const $document = e.target.closest('.document-wrapper'); + const $deleteButton = e.target.closest('.document-delete'); + const $addButton = e.target.closest('.document-add'); + const $header = e.target.closest('.navigator-header'); + + if ($chevronButton && $document) { + const chevron = $chevronButton.querySelector('.chevron'); + const $document = chevron.closest('.document'); + const isClosed = chevron.style.transform === `rotateZ(${DEGREE.CLOSED}deg)`; + const nextChildren = $document.nextElementSibling; + const isNotDocument = nextChildren && !nextChildren.classList.value.includes('document'); + const keys = [$document.getAttribute('key')]; + + if (isClosed) { + chevron.style.transform = `rotateZ(${DEGREE.OPENED}deg)`; + if (isNotDocument) { + [...nextChildren.children] + .filter((child) => child.classList.value.includes('document')) + .map((childDocument) => (childDocument.style.display = '')); + } + this.setState({ openedDocuments: { ...this.state.openedDocuments, [keys[0]]: true } }); + } else { + chevron.style.transform = `rotateZ(${DEGREE.CLOSED}deg)`; + if (isNotDocument) { + nextChildren.querySelectorAll('.document').forEach((childDocument) => { + childDocument + .querySelectorAll('.chevron') + .forEach( + (childChevron) => (childChevron.style.transform = `rotateZ(${DEGREE.CLOSED}deg)`), + ); + childDocument.style.display = 'none'; + keys.push(childDocument.getAttribute('key')); + }); + } + keys.forEach((key) => delete this.state.openedDocuments[key]); + } + setItem(STORAGE_KEY.OPENED_DOCUMENTS, this.state.openedDocuments); + this.renderDocuments(); + } + + if ($document && !$chevronButton && !$deleteButton && !$addButton) { + const targetDocumentId = $document.getAttribute('key'); + + this.setState({ + openedDocuments: { ...this.state.openedDocuments, [targetDocumentId]: true }, + }); + setItem(STORAGE_KEY.OPENED_DOCUMENTS, this.state.openedDocuments); + openDocument(targetDocumentId); + } + + if ($deleteButton && $document) { + const targetDocumentId = $document.getAttribute('key'); + + delete this.state.openedDocuments[targetDocumentId]; + setItem(STORAGE_KEY.OPENED_DOCUMENTS, this.state.openedDocuments); + deleteDocument(targetDocumentId); + } + + if ($addButton) { + const targetDocumentId = $document ? $document.getAttribute('key') : null; + addDocument(targetDocumentId); + } + + if ($header) { + push('/'); + } + }); + + const $scroller = $navigator.querySelector('.scroller'); + $scroller.addEventListener(EVENT.SCROLL, (e) => { + const { scrollTop, offsetHeight, scrollHeight } = e.target; + if (scrollTop === 0 || scrollTop + offsetHeight === scrollHeight) { + $scroller.style.boxShadow = 'transparent 0px 0px 0px inset'; + } else { + $scroller.style.boxShadow = 'inset 0px 1px 0px rgba(55, 53, 47, 0.09)'; + } + }); + + const $header = $navigator.querySelector('.navigator-header'); + $header.addEventListener(EVENT.CLICK, (e) => push('/')); + }; + + this.render = () => { + $navigator.innerHTML = ` + +
+
+
+ ${plusIcon} +
${TEXT.DOCUMENT_ADD}
+
+
+ `; + this.renderDocuments(); + }; + + this.render(); + this.setEvent(); +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 00000000..681f2410 --- /dev/null +++ b/src/main.js @@ -0,0 +1,15 @@ +import App from './app.js'; +import { STORAGE_KEY } from './utils/constants.js'; +import { getItem } from './utils/storage.js'; + +const $root = document.querySelector('#root'); +const openedDocuments = getItem(STORAGE_KEY.OPENED_DOCUMENTS, []); + +new App({ + $target: $root, + initialState: { + openedDocuments, + documents: [], + document: {}, + }, +}); diff --git a/src/router.js b/src/router.js new file mode 100644 index 00000000..8a238db1 --- /dev/null +++ b/src/router.js @@ -0,0 +1,26 @@ +import { EVENT } from './utils/constants.js'; + +export const initRouter = (onRoute) => { + onRoute(); + + window.addEventListener(EVENT.POPSTATE, () => onRoute()); + + window.addEventListener(EVENT.ROUTECHANGE, (e) => { + const { nextUrl } = e.detail; + + if (nextUrl) { + history.pushState(null, null, nextUrl); + onRoute(); + } + }); +}; + +export const push = (nextUrl) => { + window.dispatchEvent( + new CustomEvent(EVENT.ROUTECHANGE, { + detail: { + nextUrl, + }, + }), + ); +}; diff --git a/src/utils/api.js b/src/utils/api.js new file mode 100644 index 00000000..3e5b5923 --- /dev/null +++ b/src/utils/api.js @@ -0,0 +1,21 @@ +import { push } from '../router.js'; +import { API_END_POINT } from '../url.js'; +import { USER_NAME } from './constants.js'; + +export const request = async (url, options = {}, data) => { + const res = await fetch(`${API_END_POINT}${url[0] === '/' ? url : `/${url}`}`, { + ...options, + headers: { + 'x-username': USER_NAME, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + + if (res.ok) { + return await res.json(); + } else { + push('/'); + throw new Error(`${res.status} Error`); + } +}; diff --git a/src/utils/constants.js b/src/utils/constants.js new file mode 100644 index 00000000..1cda4bb8 --- /dev/null +++ b/src/utils/constants.js @@ -0,0 +1,59 @@ +export const STORAGE_KEY = { + OPENED_DOCUMENTS: 'OPENED_DOCUMENTS', + CHANGED_DOCUMENTS: 'CHANGED_DOCUMENTS', +}; +export const USER_NAME = 'ryu'; +export const TEXT = { + DEFAULT_HEADER: '류건열의 Notion', + DOCUMENT_ADD: '페이지 추가', + LOAD_UNSAVED_DATA: '저장되지 않은 임시 데이터가 있습니다. 불러오겠습니까?', + DEFAULT_HOME_PAGE: "Welcome to 건열's Notion!", + DEFAULT_TITLE: '제목 없음', + DEFAULT_CONTENT: '내용을 입력해주세요', + DEFAULT_MODAL_HEADER: '페이지 링크', +}; +export const DEGREE = { + OPENED: 0, + CLOSED: -90, +}; +export const METHOD = { + GET: 'GET', + POST: 'POST', + DELETE: 'DELETE', + PUT: 'PUT', +}; +export const EVENT_KEY = { + DISABLE_API_CALL: { + SHIFT: 'Shift', + COMMAND: 'Meta', + ALT: 'Alt', + CONTROL: 'Control', + CAPSLOCK: 'Capslock', + ARROW_UP: 'ArrowUp', + ARROW_DOWN: 'ArrowDown', + ARROW_LEFT: 'ArrowLeft', + ARROW_RIGHT: 'ArrowRight', + }, + ENTER: 'Enter', + SPACE: ' ', + BACKSPACE: 'Backspace', + AT: '@', + ESC: 'Escape', +}; +export const ICON = { + DOCUMENT: 'document', + CHEVRON: 'chevron', + PLUS: 'plus', + TRASH: 'trash', +}; +export const EVENT = { + KEYUP: 'keyup', + KEYDOWN: 'keydown', + CLICK: 'click', + SCROLL: 'scroll', + MOUSEUP: 'mouseup', + MOUSEDOWN: 'mousedown', + MOUSEMOVE: 'mousemove', + POPSTATE: 'popstate', + ROUTECHANGE: 'routing-change', +}; diff --git a/src/utils/debounce.js b/src/utils/debounce.js new file mode 100644 index 00000000..98b6b222 --- /dev/null +++ b/src/utils/debounce.js @@ -0,0 +1,9 @@ +export const debounce = (func, timeout = 300) => { + let timer; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => { + func.apply(this, args); + }, timeout); + }; +}; diff --git a/src/utils/getDocuments.js b/src/utils/getDocuments.js new file mode 100644 index 00000000..4e2c122b --- /dev/null +++ b/src/utils/getDocuments.js @@ -0,0 +1,24 @@ +export const getDocumentsByKeyword = (array, childDocuments, keyword) => { + if (childDocuments.length === 0) return array; + childDocuments.map(({ title, id, documents }) => { + if (title.includes(keyword)) array.push({ id, title }); + if (documents.length > 0) getDocumentsByKeyword(array, documents, keyword); + }); + return array; +}; + +export const getLowerDocuments = (from, documents) => { + if (!documents || documents.length === 0) { + from.querySelector('[name=footer]').style.display = 'none'; + return ''; + } + + from.querySelector('[name=footer]').style.display = 'flex'; + return ` + ${documents + .map(({ id, title }) => { + return ``; + }) + .join('')} + `; +}; diff --git a/src/utils/getWayThroughRoot.js b/src/utils/getWayThroughRoot.js new file mode 100644 index 00000000..a491a273 --- /dev/null +++ b/src/utils/getWayThroughRoot.js @@ -0,0 +1,36 @@ +const getWaysToLeaf = (originArray, _documents, currentArray) => { + if (_documents.length === 0) { + originArray.push(currentArray); + return originArray; + } + + _documents.forEach((document) => { + const { documents, id } = document; + getWaysToLeaf(originArray, documents, [...currentArray, id]); + }); + + return currentArray; +}; + +export const getWaysFromRootToLeaf = (_documents) => { + const originArray = []; + + _documents.forEach(({ documents, id }) => { + getWaysToLeaf(originArray, documents, [id]); + }); + + return originArray; +}; + +export const getTitlesThroughRoot = (from, startId) => { + const start = from.querySelector(`#id-${startId}`); + + const titles = [start.dataset.title]; + let $parent = start.parentNode; + + while (!$parent.parentNode.parentNode.classList.contains('documents-wrapper')) { + $parent = $parent.parentNode.previousElementSibling; + titles.push($parent.querySelector('.title-wrapper').dataset.title); + } + return titles.reverse(); +}; diff --git a/src/utils/richText.js b/src/utils/richText.js new file mode 100644 index 00000000..be1870eb --- /dev/null +++ b/src/utils/richText.js @@ -0,0 +1,54 @@ +const headings = ['h1', 'h2', 'h3']; + +const toggleClass = ($container, tag) => { + switch (tag) { + case 'h1': + case 'h2': + case 'h3': + const otherHeadings = headings.filter((heading) => heading !== tag); + $container.classList.remove(...otherHeadings); + $container.classList.contains(tag) + ? $container.classList.remove(tag) + : $container.classList.add(tag); + break; + case 'strong': + case 'strike': + case 'italic': + case 'underline': + $container.classList.contains(tag) + ? $container.classList.remove(tag) + : $container.classList.add(tag); + break; + default: + break; + } +}; + +export const makeRichText = ($container) => { + let currentLine = $container.innerHTML; + + if (currentLine.indexOf('# ') === 0) { + currentLine = currentLine.substr(2); + toggleClass($container, 'h1'); + } else if (currentLine.indexOf('## ') === 0) { + currentLine = currentLine.substr(3); + toggleClass($container, 'h2'); + } else if (currentLine.indexOf('### ') === 0) { + currentLine = currentLine.substr(4); + toggleClass($container, 'h3'); + } else if (currentLine.indexOf('* ') === 0) { + currentLine = currentLine.substr(2); + toggleClass($container, 'strong'); + } else if (currentLine.indexOf('- ') === 0) { + currentLine = currentLine.substr(3); + toggleClass($container, 'strike'); + } else if (currentLine.indexOf('/ ') === 0) { + currentLine = currentLine.substr(2); + toggleClass($container, 'italic'); + } else if (currentLine.indexOf('_ ') === 0) { + currentLine = currentLine.substr(2); + toggleClass($container, 'underline'); + } + + return currentLine === '' ? '
' : currentLine; +}; diff --git a/src/utils/storage.js b/src/utils/storage.js new file mode 100644 index 00000000..26cb02cc --- /dev/null +++ b/src/utils/storage.js @@ -0,0 +1,26 @@ +const storage = window.localStorage; + +export const setItem = (key, value) => { + try { + storage.setItem(key, JSON.stringify(value)); + } catch (e) { + console.log(e.message); + } +}; + +export const getItem = (key, defaultValue) => { + try { + const storedValue = storage.getItem(key); + if (storedValue) return JSON.parse(storedValue); + + return defaultValue; + } catch (e) { + console.log(e.message); + storage.removeItem(key); + return defaultValue; + } +}; + +export const removeItem = (key) => { + storage.removeItem(key); +};