Implement draft save and resume functionality
authorAdam Dullage <redacted>
Thu, 16 May 2024 12:58:04 +0000 (13:58 +0100)
committerAdam Dullage <redacted>
Thu, 16 May 2024 12:58:04 +0000 (13:58 +0100)
client/components/ConfirmModal.vue
client/components/CustomButton.vue
client/components/toastui/ToastEditor.vue
client/views/Note.vue

index ee4395927fa46acfcf72a62008235c065faff26a..91fb91cfdcf8c192a84ca785674d70742aea5e4d 100644 (file)
     <div class="mb-6">{{ message }}</div>
     <!-- Buttons -->
     <div class="flex justify-end">
-      <CustomButton label="Cancel" @click="cancelHandler" class="mr-2" />
+      <CustomButton
+        :label="cancelButtonText"
+        :style="cancelButtonStyle"
+        @click="cancelHandler"
+        class="mr-2"
+      />
       <CustomButton
         v-focus
         :label="confirmButtonText"
+        :style="confirmButtonStyle"
         @click="confirmHandler"
-        danger
       />
     </div>
   </Modal>
@@ -30,7 +35,10 @@ import Modal from "./Modal.vue";
 const props = defineProps({
   title: { type: String, default: "Confirmation" },
   message: String,
+  confirmButtonStyle: { type: String, default: "cta" },
   confirmButtonText: { type: String, default: "Confirm" },
+  cancelButtonStyle: { type: String, default: "subtle" },
+  cancelButtonText: { type: String, default: "Cancel" },
   isDanger: Boolean,
 });
 const emit = defineEmits(["confirm", "cancel"]);
index f958a411184033dfb3cd368e5759887a992cecb3..14009cca7860ff236a833f3001779b85b3d6f631 100644 (file)
@@ -3,8 +3,11 @@
     class="rounded px-2 py-1"
     :class="{
       'bg-theme-background text-theme-text-muted hover:bg-theme-background-elevated':
-        !danger,
-      'bg-theme-danger hover:bg-theme-danger/80 text-slate-50': danger,
+        style === 'subtle',
+      'border bg-theme-background hover:bg-theme-background-elevated':
+        style === 'cta',
+      'bg-theme-danger text-slate-50 hover:bg-theme-danger/80':
+        style === 'danger',
     }"
   >
     <IconLabel :iconPath="iconPath" :iconSize="iconSize" :label="label" />
@@ -18,6 +21,12 @@ defineProps({
   iconPath: String,
   iconSize: String,
   label: String,
-  danger: Boolean,
+  style: {
+    type: String,
+    default: "subtle",
+    validator: (value) => {
+      return ["subtle", "cta", "danger"].includes(value);
+    },
+  },
 });
 </script>
index 4048ea59e940073ae824a86c788ecaf6204b419b..5971607bd499eb26ec992c4cdc9c2f5ad98ad69e 100644 (file)
@@ -17,6 +17,8 @@ const props = defineProps({
   addImageBlobHook: Function,
 });
 
+const emit = defineEmits(["change"]);
+
 const editorElement = ref();
 let toastEditor;
 
@@ -26,6 +28,11 @@ onMounted(() => {
     el: editorElement.value,
     initialValue: props.initialValue,
     initialEditType: props.initialEditType,
+    events: {
+      change: () => {
+        emit("change");
+      },
+    },
     hooks: props.addImageBlobHook
       ? { addImageBlobHook: props.addImageBlobHook }
       : {},
index c032169fd021ddd42a66944618872338873663bd..2cb49e91ba9bf3dd3a38373b1354c207a989014d 100644 (file)
@@ -5,7 +5,7 @@
     title="Confirm Deletion"
     :message="`Are you sure you want to delete the note '${note.title}'?`"
     confirmButtonText="Delete"
-    isDanger
+    confirmButtonStyle="danger"
     @confirm="deleteConfirmedHandler"
   />
 
     title="Confirm Closure"
     :message="`Changes have been made. Are you sure you want to close the note '${note.title}'?`"
     confirmButtonText="Close"
-    isDanger
+    confirmButtonStyle="danger"
     @confirm="cancelConfirmedHandler"
   />
 
+  <!-- Draft Modal -->
+  <ConfirmModal
+    v-model="isDraftModalVisible"
+    title="Draft Detected"
+    message="There is an unsaved draft of this note stored in this browser. Do you want to resume the draft version or delete it?"
+    confirmButtonText="Resume Draft"
+    confirmButtonStyle="cta"
+    cancelButtonText="Delete Draft"
+    cancelButtonStyle="danger"
+    @confirm="setEditMode()"
+    @cancel="
+      clearDraft();
+      setEditMode();
+    "
+  />
+
   <LoadingIndicator ref="loadingIndicator" class="flex h-full flex-col">
     <!-- Header -->
     <div class="flex flex-col-reverse md:flex-row md:items-baseline">
       <ToastEditor
         v-if="editMode"
         ref="toastEditor"
-        :initialValue="note.content"
+        :initialValue="getInitialEditorValue()"
         :initialEditType="loadDefaultEditorMode()"
         :addImageBlobHook="addImageBlobHook"
+        @change="startDraftSaveTimeout"
       />
     </div>
   </LoadingIndicator>
@@ -118,10 +135,12 @@ const props = defineProps({
 });
 
 const canModify = computed(() => globalStore.authType != authTypes.readOnly);
+let draftSaveTimeout = null;
 const editMode = ref(false);
 const globalStore = useGlobalStore();
 const isCancellationModalVisible = ref(false);
 const isDeleteModalVisible = ref(false);
+const isDraftModalVisible = ref(false);
 const isNewNote = computed(() => !props.title);
 const loadingIndicator = ref();
 const note = ref({});
@@ -167,61 +186,44 @@ function init() {
   }
 }
 
-// Helpers
-function entityTooLargeToast(entityName) {
-  toast.add(
-    getToastOptions(
-      `This ${entityName} is too large. Please try again with a smaller ${entityName} or adjust your server configuration.`,
-      "Failure",
-      "error",
-    ),
-  );
-}
-
-function badFilenameToast(entityName) {
-  toast.add(
-    getToastOptions(
-      'Due to filename restrictions, the following characters are not allowed: <>:"/\\|?*',
-      `Invalid ${entityName}`,
-      "error",
-    ),
-  );
-}
-
-function setBeforeUnloadConfirmation(enable = true) {
-  if (enable) {
-    window.onbeforeunload = () => {
-      return true;
-    };
+// Note Editing
+function editHandler() {
+  const draftContent = loadDraft();
+  if (draftContent) {
+    isDraftModalVisible.value = true;
   } else {
-    window.onbeforeunload = null;
+    setEditMode();
   }
 }
 
-function saveDefaultEditorMode() {
-  const isWysiwygMode = toastEditor.value.isWysiwygMode();
-  localStorage.setItem(
-    "defaultEditorMode",
-    isWysiwygMode ? "wysiwyg" : "markdown",
-  );
-}
-
-function loadDefaultEditorMode() {
-  const defaultWysiwygMode = localStorage.getItem("defaultEditorMode");
-  return defaultWysiwygMode || "markdown";
-}
-
-// Button Handlers
-function editHandler() {
+function setEditMode() {
   setBeforeUnloadConfirmation(true);
   newTitle.value = note.value.title;
   editMode.value = true;
 }
 
+function getInitialEditorValue() {
+  const draftContent = loadDraft();
+  return draftContent ? draftContent : note.value.content;
+}
+
+// Note Deletion
 function deleteHandler() {
   isDeleteModalVisible.value = true;
 }
 
+function deleteConfirmedHandler() {
+  deleteNote(note.value.title)
+    .then(() => {
+      toast.add(getToastOptions("Note deleted ✓", "Success", "success"));
+      router.push({ name: "home" });
+    })
+    .catch((error) => {
+      apiErrorHandler(error, toast);
+    });
+}
+
+// Note Edit Cancellation
 function cancelHandler() {
   if (
     newTitle.value != note.value.title ||
@@ -233,6 +235,18 @@ function cancelHandler() {
   }
 }
 
+function cancelConfirmedHandler() {
+  clearDraft();
+  setBeforeUnloadConfirmation(false);
+  editMode.value = false;
+  if (!props.title) {
+    router.push({ name: "home" });
+  } else {
+    editMode.value = false;
+  }
+}
+
+// Note Saving
 function saveHandler() {
   // Save Default Editor Mode
   saveDefaultEditorMode();
@@ -260,31 +274,10 @@ function saveHandler() {
   }
 }
 
-// Additional Logic
-function cancelConfirmedHandler() {
-  setBeforeUnloadConfirmation(false);
-  editMode.value = false;
-  if (!props.title) {
-    router.push({ name: "home" });
-  } else {
-    editMode.value = false;
-  }
-}
-
-function deleteConfirmedHandler() {
-  deleteNote(note.value.title)
-    .then(() => {
-      toast.add(getToastOptions("Note deleted ✓", "Success", "success"));
-      router.push({ name: "home" });
-    })
-    .catch((error) => {
-      apiErrorHandler(error, toast);
-    });
-}
-
 function saveNew(newTitle, newContent) {
   createNote(newTitle, newContent)
     .then((data) => {
+      clearDraft();
       note.value = data;
       router.push({ name: "note", params: { title: note.value.title } });
       noteSaveSuccess();
@@ -301,6 +294,7 @@ function saveExisting(newTitle, newContent) {
 
   updateNote(note.value.title, newTitle, newContent)
     .then((data) => {
+      clearDraft();
       note.value = data;
       router.replace({ name: "note", params: { title: note.value.title } });
       noteSaveSuccess();
@@ -330,6 +324,7 @@ function noteSaveSuccess() {
   toast.add(getToastOptions("Note saved successfully ✓", "Success", "success"));
 }
 
+// Image Upload
 function addImageBlobHook(file, callback) {
   const altTextInputValue = document.getElementById(
     "toastuiAltTextInput",
@@ -387,6 +382,77 @@ function postAttachment(file) {
     });
 }
 
+// Drafts
+function clearDraftSaveTimeout() {
+  if (draftSaveTimeout != null) {
+    clearTimeout(draftSaveTimeout);
+  }
+}
+
+function saveDraft() {
+  const content = toastEditor.value.getMarkdown();
+  if (content) {
+    localStorage.setItem(note.value.title, content);
+  }
+}
+
+function startDraftSaveTimeout() {
+  clearDraftSaveTimeout();
+  draftSaveTimeout = setTimeout(saveDraft, 1000);
+}
+
+function clearDraft() {
+  localStorage.removeItem(note.value.title);
+}
+
+function loadDraft() {
+  return localStorage.getItem(note.value.title);
+}
+
+// Helpers
+function entityTooLargeToast(entityName) {
+  toast.add(
+    getToastOptions(
+      `This ${entityName} is too large. Please try again with a smaller ${entityName} or adjust your server configuration.`,
+      "Failure",
+      "error",
+    ),
+  );
+}
+
+function badFilenameToast(entityName) {
+  toast.add(
+    getToastOptions(
+      'Due to filename restrictions, the following characters are not allowed: <>:"/\\|?*',
+      `Invalid ${entityName}`,
+      "error",
+    ),
+  );
+}
+
+function setBeforeUnloadConfirmation(enable = true) {
+  if (enable) {
+    window.onbeforeunload = () => {
+      return true;
+    };
+  } else {
+    window.onbeforeunload = null;
+  }
+}
+
+function saveDefaultEditorMode() {
+  const isWysiwygMode = toastEditor.value.isWysiwygMode();
+  localStorage.setItem(
+    "defaultEditorMode",
+    isWysiwygMode ? "wysiwyg" : "markdown",
+  );
+}
+
+function loadDefaultEditorMode() {
+  const defaultWysiwygMode = localStorage.getItem("defaultEditorMode");
+  return defaultWysiwygMode || "markdown";
+}
+
 watch(() => props.title, init);
 onMounted(init);
 </script>
git clone https://git.99rst.org/PROJECT