Merge pull request #1671 from pateljannat/notes
feat: notes and highlights in lesson
This commit is contained in:
@@ -32,13 +32,13 @@
|
||||
"
|
||||
:options="[
|
||||
{
|
||||
label: 'Edit',
|
||||
label: __('Edit'),
|
||||
onClick() {
|
||||
reply.editable = true
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
label: __('Delete'),
|
||||
onClick() {
|
||||
deleteReply(reply)
|
||||
},
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
class="float-right"
|
||||
@click="openTopicModal()"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="size-4" />
|
||||
</template>
|
||||
{{ __('New {0}').format(singularize(title)) }}
|
||||
</Button>
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
@@ -49,7 +52,7 @@
|
||||
class="flex flex-col items-center justify-center border-2 border-dashed mt-5 py-8 rounded-md"
|
||||
>
|
||||
<MessageSquareText class="w-7 h-7 text-ink-gray-4 stroke-1.5 mr-2" />
|
||||
<div class="">
|
||||
<div class="mt-2">
|
||||
<div v-if="emptyStateTitle" class="font-medium mb-2">
|
||||
{{ __(emptyStateTitle) }}
|
||||
</div>
|
||||
@@ -73,7 +76,7 @@ import { singularize, timeAgo } from '@/utils'
|
||||
import { ref, onMounted, inject, onUnmounted } from 'vue'
|
||||
import DiscussionReplies from '@/components/DiscussionReplies.vue'
|
||||
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
|
||||
import { MessageSquareText } from 'lucide-vue-next'
|
||||
import { MessageSquareText, Plus } from 'lucide-vue-next'
|
||||
import { getScrollContainer } from '@/utils/scrollContainer'
|
||||
|
||||
const showTopics = ref(true)
|
||||
@@ -102,7 +105,7 @@ const props = defineProps({
|
||||
},
|
||||
emptyStateText: {
|
||||
type: String,
|
||||
default: 'Start a discussion',
|
||||
default: 'Start a Discussion',
|
||||
},
|
||||
singleThread: {
|
||||
type: Boolean,
|
||||
|
||||
241
frontend/src/components/Notes/InlineLessonMenu.vue
Normal file
241
frontend/src/components/Notes/InlineLessonMenu.vue
Normal file
@@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<div
|
||||
class="text-sm absolute bg-white border rounded-md z-10 w-44"
|
||||
:style="{
|
||||
display: top > 0 ? 'block' : 'none',
|
||||
top: top + 'px',
|
||||
left: left + 'px',
|
||||
}"
|
||||
>
|
||||
<div class="space-y-2 py-2">
|
||||
<div class="text-xs text-ink-gray-5 font-medium px-3">
|
||||
{{ __('Highlight') }}
|
||||
</div>
|
||||
<div class="">
|
||||
<div
|
||||
v-for="color in colors"
|
||||
class="flex items-center space-x-2 px-3 py-2 cursor-pointer hover:bg-surface-gray-2"
|
||||
@click="saveHighLight(color)"
|
||||
>
|
||||
<span
|
||||
class="size-3 rounded-full"
|
||||
:style="{
|
||||
backgroundColor: theme.backgroundColor[color.toLowerCase()][400],
|
||||
}"
|
||||
></span>
|
||||
<span>
|
||||
{{ __(color) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t">
|
||||
<div
|
||||
@click="addToNotes()"
|
||||
class="flex items-center space-x-2 hover:bg-surface-gray-2 cursor-pointer rounded-b-md py-2 px-3"
|
||||
>
|
||||
<NotepadText class="size-3 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Add to Notes') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="highlightExists()"
|
||||
@click="deleteHighlight"
|
||||
class="flex items-center space-x-2 hover:bg-surface-gray-2 cursor-pointer rounded-b-md py-2 px-3"
|
||||
>
|
||||
<Trash2 class="size-3 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Remove Highlight') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
import { NotepadText, Trash2 } from 'lucide-vue-next'
|
||||
import { theme } from '@/utils/theme'
|
||||
import type { Note, Notes } from '@/components/Notes/types'
|
||||
import { blockQuotesClick, highlightText } from '@/utils'
|
||||
|
||||
const user = inject<any>('$user')
|
||||
const show = defineModel()
|
||||
const notes = defineModel<Notes>('notes')
|
||||
const top = ref(0)
|
||||
const left = ref(0)
|
||||
const currentSelection = ref<Selection | null>(null)
|
||||
const selectedText = ref('')
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateNotes'): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
lesson: string
|
||||
}>()
|
||||
|
||||
watch(show, () => {
|
||||
if (!show.value) {
|
||||
return resetMenuPosition()
|
||||
}
|
||||
|
||||
currentSelection.value = window.getSelection()
|
||||
if (!currentSelection.value?.toString()) {
|
||||
return resetMenuPosition()
|
||||
}
|
||||
|
||||
updateMenuPosition()
|
||||
})
|
||||
|
||||
const updateMenuPosition = () => {
|
||||
selectedText.value = currentSelection.value?.toString() || ''
|
||||
const range = currentSelection.value?.getRangeAt(0)
|
||||
const rect = range?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
|
||||
const offsetY = window.scrollY
|
||||
const offsetX = window.scrollX
|
||||
|
||||
top.value = Math.floor(rect.top + offsetY - 40)
|
||||
left.value = Math.floor(rect.right + offsetX + 10)
|
||||
}
|
||||
|
||||
const resetMenuPosition = () => {
|
||||
top.value = 0
|
||||
left.value = 0
|
||||
}
|
||||
|
||||
const colors = computed(() => {
|
||||
return ['Red', 'Blue', 'Green', 'Yellow', 'Purple']
|
||||
})
|
||||
|
||||
const highlightExists = () => {
|
||||
return notes.value?.data?.some(
|
||||
(note: Note) => note.highlighted_text === selectedText.value
|
||||
)
|
||||
}
|
||||
|
||||
const saveHighLight = (color: string) => {
|
||||
if (!selectedText.value) return
|
||||
|
||||
notes.value?.insert.submit(
|
||||
{
|
||||
lesson: props.lesson,
|
||||
member: user?.data?.name,
|
||||
highlighted_text: selectedText.value,
|
||||
color: color,
|
||||
name: '',
|
||||
},
|
||||
{
|
||||
onSuccess(data: Note) {
|
||||
highlightText(data)
|
||||
resetStates()
|
||||
emit('updateNotes')
|
||||
},
|
||||
onError(err: any) {
|
||||
console.error('Error saving highlight:', err)
|
||||
resetStates()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const deleteHighlight = () => {
|
||||
let notesToDelete = notes.value?.data.find(
|
||||
(note: Note) => note.highlighted_text === selectedText.value
|
||||
)
|
||||
if (!notesToDelete) return
|
||||
notes.value?.delete.submit(notesToDelete.name, {
|
||||
onSuccess() {
|
||||
resetStates()
|
||||
document.querySelectorAll('.highlighted-text').forEach((el) => {
|
||||
const element = el as HTMLElement
|
||||
if (element.dataset.name === notesToDelete.name) {
|
||||
element.style.backgroundColor = 'transparent'
|
||||
}
|
||||
})
|
||||
},
|
||||
onError(err: any) {
|
||||
console.error('Error deleting highlight:', err)
|
||||
resetStates()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const addToNotes = () => {
|
||||
if (!selectedText.value) return
|
||||
let noteToUpdate = notes.value?.data.find((note: Note) => {
|
||||
return !note.highlighted_text && note.note !== ''
|
||||
})
|
||||
if (!noteToUpdate) {
|
||||
createNote()
|
||||
} else {
|
||||
updateNote(noteToUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
const createNote = () => {
|
||||
notes.value?.insert.submit(
|
||||
{
|
||||
lesson: props.lesson,
|
||||
member: user?.data?.name,
|
||||
note: `<blockquote><p>${selectedText.value}</p></blockquote><br>`,
|
||||
color: 'Yellow',
|
||||
name: '',
|
||||
},
|
||||
{
|
||||
onSuccess(data: Note) {
|
||||
emit('updateNotes')
|
||||
setTimeout(() => {
|
||||
scrollToText(selectedText.value)
|
||||
blockQuotesClick()
|
||||
resetStates()
|
||||
}, 100)
|
||||
},
|
||||
onError(err: any) {
|
||||
console.error('Error creating note:', err)
|
||||
resetStates()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const updateNote = (noteToUpdate: Note) => {
|
||||
notes.value?.setValue.submit(
|
||||
{
|
||||
name: noteToUpdate.name,
|
||||
note: `${noteToUpdate.note}\n\n<blockquote><p>${selectedText.value}</p></blockquote><br>`,
|
||||
},
|
||||
{
|
||||
onSuccess(data: Note) {
|
||||
emit('updateNotes')
|
||||
setTimeout(() => {
|
||||
scrollToText(selectedText.value)
|
||||
blockQuotesClick()
|
||||
resetStates()
|
||||
}, 100)
|
||||
},
|
||||
onError(err: any) {
|
||||
console.error('Error updating note:', err)
|
||||
resetStates()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const scrollToText = (text: string) => {
|
||||
const elements = document.querySelectorAll('blockquote p')
|
||||
Array.from(elements).forEach((el) => {
|
||||
const element = el as HTMLElement
|
||||
if (element.textContent?.toLowerCase().includes(text.toLowerCase())) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const resetStates = () => {
|
||||
selectedText.value = ''
|
||||
show.value = false
|
||||
resetMenuPosition()
|
||||
}
|
||||
</script>
|
||||
115
frontend/src/components/Notes/Notes.vue
Normal file
115
frontend/src/components/Notes/Notes.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
{{ __('My Notes') }}
|
||||
</div>
|
||||
<TextEditor
|
||||
:content="note"
|
||||
:placeholder="__('Make notes for quick revision. Press / for menu.')"
|
||||
@change="(val: string) => updateNoteText(val)"
|
||||
:editable="true"
|
||||
editorClass="prose prose-sm min-h-[200px] max-w-none"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { TextEditor } from 'frappe-ui'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { inject, ref, onMounted, watch } from 'vue'
|
||||
import type { Note, Notes } from '@/components/Notes/types'
|
||||
import { blockQuotesClick } from '@/utils/'
|
||||
|
||||
const note = ref<string | null>(null)
|
||||
const currentNoteName = ref<string | null>(null)
|
||||
const user = inject<any>('$user')
|
||||
const notes = defineModel<Notes>('notes')
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateNotes'): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
lesson: string
|
||||
}>()
|
||||
|
||||
onMounted(() => {
|
||||
updateCurrentNote()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => notes.value?.data,
|
||||
() => {
|
||||
updateCurrentNote()
|
||||
blockQuotesClick()
|
||||
}
|
||||
)
|
||||
|
||||
const updateCurrentNote = () => {
|
||||
const currentNote = notes.value?.data?.filter((row: Note) => {
|
||||
return !row.highlighted_text && row.note !== ''
|
||||
})
|
||||
if (currentNote?.length === 0) {
|
||||
note.value = null
|
||||
currentNoteName.value = null
|
||||
return
|
||||
} else if (currentNote && currentNote.length > 0) {
|
||||
currentNoteName.value = currentNote[0].name
|
||||
note.value = currentNote[0].note || null
|
||||
}
|
||||
}
|
||||
|
||||
const updateNoteText = (val: string) => {
|
||||
note.value = val
|
||||
debouncedSave()
|
||||
}
|
||||
|
||||
const debouncedSave = useDebounceFn(() => {
|
||||
saveNotes()
|
||||
}, 2000)
|
||||
|
||||
const saveNotes = () => {
|
||||
if (currentNoteName.value) {
|
||||
updateNote()
|
||||
} else {
|
||||
createNote()
|
||||
}
|
||||
}
|
||||
|
||||
const createNote = () => {
|
||||
notes.value?.insert.submit(
|
||||
{
|
||||
lesson: props.lesson,
|
||||
member: user?.data?.name,
|
||||
note: note.value,
|
||||
color: 'Yellow',
|
||||
name: '',
|
||||
},
|
||||
{
|
||||
onSuccess(data: Note) {
|
||||
currentNoteName.value = data.name || null
|
||||
emit('updateNotes')
|
||||
},
|
||||
onError(err: any) {
|
||||
console.error('Error creating note:', err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const updateNote = () => {
|
||||
if (!currentNoteName.value) return
|
||||
notes.value?.setValue.submit(
|
||||
{
|
||||
name: currentNoteName.value,
|
||||
lesson: props.lesson,
|
||||
member: user?.data?.name,
|
||||
note: note.value,
|
||||
},
|
||||
{
|
||||
onSuccess(data: Note) {
|
||||
emit('updateNotes')
|
||||
},
|
||||
onError(err: any) {
|
||||
console.error('Error updating note:', err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
32
frontend/src/components/Notes/types.ts
Normal file
32
frontend/src/components/Notes/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
|
||||
export type Note = {
|
||||
highlighted_text?: string
|
||||
color?: string
|
||||
name: string
|
||||
note?: string | null
|
||||
lesson?: string
|
||||
member?: string
|
||||
}
|
||||
|
||||
export type Notes = {
|
||||
data: Note[]
|
||||
reload: () => void
|
||||
insert: {
|
||||
submit: (
|
||||
data: Note,
|
||||
options: { onSuccess: (data: Note) => void; onError: (err: any) => void }
|
||||
) => void
|
||||
}
|
||||
setValue: {
|
||||
submit: (
|
||||
data: Note,
|
||||
options: { onSuccess: (data: Note) => void; onError: (err: any) => void }
|
||||
) => void
|
||||
},
|
||||
delete: {
|
||||
submit: (
|
||||
data: Note | string,
|
||||
options?: { onSuccess: () => void; onError: (err: any) => void }
|
||||
) => void
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user