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 `
+
+ ${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 = `
+
+
+
+ ${searchedDocuments
+ .map((searchedDocument) => {
+ const { id, title } = searchedDocument;
+ return `
+
${title}
+ `;
+ })
+ .join('')}
+
+
+ `;
+
+ 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 = `
+
+
+ `;
+ 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 `${title}
`;
+ })
+ .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);
+};